Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00342601bf | |||
| b48b701657 | |||
| d7e9802deb | |||
| bd29afc4c9 | |||
| caa75ecbc0 | |||
| 7861bbcad1 | |||
| 84578d2f6f | |||
| 9210d01137 | |||
| 8ee353c93a | |||
| 51db5a067a | |||
| ed01f5305d | |||
| 2ef73c84e2 | |||
| 7fb2a0013c | |||
| 3265d3e16b | |||
| c46902dab8 | |||
| 57ac969092 | |||
| dff16e8d2d | |||
| f0eea58bd8 | |||
| d64d0e9d7a | |||
| 0caebce9ff | |||
| f2866ae281 | |||
| 4963ec54ed | |||
| 99b952fcf4 | |||
| b5beab55e6 | |||
| aa9126482f | |||
| a2d75dca0e | |||
| 5cd5858967 | |||
| d869e4186f | |||
| dd7a1fc9bd | |||
| fada162030 | |||
| a242bd3c94 | |||
| c12c773af0 | |||
| 75804f470a | |||
| 04989ad159 | |||
| 7365bc91ef | |||
| 257e5fca30 | |||
| 6a9546bb1f | |||
| b230ed0369 | |||
| a8b4101c52 | |||
| 2a1b55f79f | |||
| 47731bfeff | |||
| 841bde1377 | |||
| 37779e1b54 | |||
| ba5bc4f861 | |||
| 80d546de03 | |||
| 88db9aeafe | |||
| 37c51d9b46 | |||
| 75df39fc1f | |||
| 6b5affa46e | |||
| 1e362b9eea | |||
| f7d9895e36 | |||
| 04b6e385ea | |||
| 9f2e3d32df | |||
| 524aec2a04 | |||
| 03f90728b4 | |||
| 0d95dbd3d4 | |||
| fd14ca2327 | |||
| 20fb782a3e | |||
| 9fa7e20221 | |||
| b5e3aa897c | |||
| 1b45a6a922 | |||
| d3d0b086f1 | |||
| 6cc43d9dc0 | |||
| e081d3a88d | |||
| e5c309eac4 | |||
| 3f542f92e3 | |||
| 7a9f013631 | |||
| 8dffe79e28 | |||
| d5a1c0065c | |||
| b9761d3eb9 | |||
| c3c0a8a7c4 | |||
| b597faac76 | |||
| fc1099f486 | |||
| 94950db4d5 | |||
| 251a830281 | |||
| b2b0b83b4b | |||
| 94a98ee2c7 | |||
| da015797dd | |||
| 617297cbcd | |||
| 3909818a3f | |||
| df24b3d1c2 | |||
| 07a16d5548 | |||
| 4512fd0d3e | |||
| 8a54155e47 |
@@ -0,0 +1,64 @@
|
||||
disabled_rules:
|
||||
- explicit_acl
|
||||
- trailing_whitespace
|
||||
- force_cast
|
||||
- unused_closure_parameter
|
||||
- multiple_closures_with_trailing_closure
|
||||
opt_in_rules:
|
||||
- anyobject_protocol
|
||||
- array_init
|
||||
- attributes
|
||||
- collection_alignment
|
||||
- colon
|
||||
- conditional_returns_on_newline
|
||||
- convenience_type
|
||||
- empty_count
|
||||
- empty_string
|
||||
- empty_collection_literal
|
||||
- enum_case_associated_values_count
|
||||
- function_default_parameter_at_end
|
||||
- fatal_error_message
|
||||
- file_name
|
||||
- first_where
|
||||
- modifier_order
|
||||
- toggle_bool
|
||||
- unused_private_declaration
|
||||
- yoda_condition
|
||||
excluded:
|
||||
- Carthage
|
||||
- Pods
|
||||
- SwiftLint/Common/3rdPartyLib
|
||||
identifier_name:
|
||||
excluded:
|
||||
- a
|
||||
- b
|
||||
- c
|
||||
- i
|
||||
- id
|
||||
- t
|
||||
- to
|
||||
- x
|
||||
- y
|
||||
line_length:
|
||||
warning: 150
|
||||
error: 200
|
||||
ignores_function_declarations: true
|
||||
ignores_comments: true
|
||||
ignores_urls: true
|
||||
function_body_length:
|
||||
warning: 300
|
||||
error: 500
|
||||
function_parameter_count:
|
||||
warning: 6
|
||||
error: 8
|
||||
type_body_length:
|
||||
warning: 300
|
||||
error: 400
|
||||
file_length:
|
||||
warning: 500
|
||||
error: 1200
|
||||
ignore_comment_only_lines: true
|
||||
cyclomatic_complexity:
|
||||
warning: 15
|
||||
error: 21
|
||||
reporter: "xcode"
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>SwiftUICharts.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Bucket
|
||||
uuid = "91E23D30-CB6C-44DA-BEFC-9D39A1DA2242"
|
||||
type = "1"
|
||||
version = "2.0">
|
||||
</Bucket>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>SwiftUICharts.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>SwiftUICharts</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>SwiftUIChartsTests</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -6,13 +6,13 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "SwiftUICharts",
|
||||
platforms: [
|
||||
.iOS(.v13),
|
||||
.iOS(.v13), .watchOS(.v6), .macOS(.v10_15)
|
||||
],
|
||||
products: [
|
||||
// Products define the executables and libraries produced by a package, and make them visible to other packages.
|
||||
.library(
|
||||
name: "SwiftUICharts",
|
||||
targets: ["SwiftUICharts"]),
|
||||
targets: ["SwiftUICharts"])
|
||||
],
|
||||
dependencies: [
|
||||
// Dependencies declare other packages that this package depends on.
|
||||
@@ -26,6 +26,6 @@ let package = Package(
|
||||
dependencies: []),
|
||||
.testTarget(
|
||||
name: "SwiftUIChartsTests",
|
||||
dependencies: ["SwiftUICharts"]),
|
||||
dependencies: ["SwiftUICharts"])
|
||||
]
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Swift package for displaying charts effortlessly.
|
||||
|
||||

|
||||

|
||||
|
||||
It supports:
|
||||
* Line charts
|
||||
@@ -11,9 +11,9 @@ It supports:
|
||||
|
||||
### Installation:
|
||||
|
||||
It requires iOS 13 and xCode 11!
|
||||
It requires iOS 13 and Xcode 11!
|
||||
|
||||
In xCode got to `File -> Swift Packages -> Add Package Dependency` and paste inthe repo's url: `https://github.com/AppPear/ChartView`
|
||||
In Xcode got to `File -> Swift Packages -> Add Package Dependency` and paste inthe repo's url: `https://github.com/AppPear/ChartView`
|
||||
|
||||
### Usage:
|
||||
|
||||
@@ -21,8 +21,52 @@ import the package in the file you would like to use it: `import SwiftUICharts`
|
||||
|
||||
You can display a Chart by adding a chart view to your parent view:
|
||||
|
||||
### Demo
|
||||
|
||||
Added an example project, with **iOS, watchOS** target: https://github.com/AppPear/ChartViewDemo
|
||||
|
||||
## Line charts
|
||||

|
||||
|
||||
**LineChartView with multiple lines!**
|
||||
First release of this feature, interaction is disabled for now, I'll figure it out how could be the best to interact with multiple lines with a single touch.
|
||||

|
||||
|
||||
Usage:
|
||||
```swift
|
||||
MultiLineChartView(data: [([8,32,11,23,40,28], GradientColors.green), ([90,99,78,111,70,60,77], GradientColors.purple), ([34,56,72,38,43,100,50], GradientColors.orngPink)], title: "Title")
|
||||
```
|
||||
Gradient colors are now under the `GradientColor` struct you can create your own gradient by `GradientColor(start: Color, end: Color)`
|
||||
|
||||
Available preset gradients:
|
||||
* orange
|
||||
* blue
|
||||
* green
|
||||
* blu
|
||||
* bluPurpl
|
||||
* purple
|
||||
* prplPink
|
||||
* prplNeon
|
||||
* orngPink
|
||||
|
||||
**Full screen view called LineView!!!**
|
||||
|
||||

|
||||
|
||||
```swift
|
||||
LineView(data: [8,23,54,32,12,37,7,23,43], title: "Line chart", legend: "Full screen") // legend is optional, use optional .padding()
|
||||
```
|
||||
|
||||
Adopts to dark mode automatically
|
||||
|
||||

|
||||
|
||||
You can add your custom darkmode style by specifying:
|
||||
|
||||
```swift
|
||||
let myCustomStyle = ChartStyle(...)
|
||||
let myCutsomDarkModeStyle = ChartStyle(...)
|
||||
myCustomStyle.darkModeStyle = myCutsomDarkModeStyle
|
||||
```
|
||||
|
||||
**Line chart is interactive, so you can drag across to reveal the data points**
|
||||
|
||||
@@ -32,52 +76,117 @@ You can add a line chart with the following code:
|
||||
LineChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") // legend is optional
|
||||
```
|
||||
|
||||
**Turn drop shadow off by adding to the Initialiser: `dropShadow: false`**
|
||||
|
||||
|
||||
## Bar charts
|
||||

|
||||

|
||||
|
||||
**[New feature] you can display labels also along values and points for each bar to descirbe your data better!**
|
||||
**Bar chart is interactive, so you can drag across to reveal the data points**
|
||||
|
||||
You can add a bar chart with the following code:
|
||||
|
||||
Labels and points:
|
||||
|
||||
```swift
|
||||
BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") // legend is optional
|
||||
BarChartView(data: ChartData(values: [("2018 Q4",63150), ("2019 Q1",50900), ("2019 Q2",77550), ("2019 Q3",79600), ("2019 Q4",92550)]), title: "Sales", legend: "Quarterly") // legend is optional
|
||||
```
|
||||
Only points:
|
||||
|
||||
```swift
|
||||
BarChartView(data: ChartData(points: [8,23,54,32,12,37,7,23,43]), title: "Title", legend: "Legendary") // legend is optional
|
||||
```
|
||||
|
||||
You can add different formats:
|
||||
* Small `Form.small`
|
||||
* Medium `Form.medium`
|
||||
* Large `Form.large`
|
||||
**ChartData** structure
|
||||
Stores values in data pairs (actually tuple): `(String,Double)`
|
||||
* you can have duplicate values
|
||||
* keeps the data order
|
||||
|
||||
You can initialise ChartData multiple ways:
|
||||
* For integer values: `ChartData(points: [8,23,54,32,12,37,7,23,43])`
|
||||
* For floating point values: `ChartData(points: [2.34,3.14,4.56])`
|
||||
* For label,value pairs: `ChartData(values: [("2018 Q4",63150), ("2019 Q1",50900)])`
|
||||
|
||||
|
||||
You can add different formats:
|
||||
* Small `ChartForm.small`
|
||||
* Medium `ChartForm.medium`
|
||||
* Large `ChartForm.large`
|
||||
|
||||
```swift
|
||||
BarChartView(data: ChartData(points: [8,23,54,32,12,37,7,23,43]), title: "Title", form: ChartForm.small)
|
||||
```
|
||||
|
||||
For floating point numbers, you can set a custom specifier:
|
||||
|
||||
```swift
|
||||
BarChartView(data: ChartData(points:[1.23,2.43,3.37]) ,title: "A", valueSpecifier: "%.2f")
|
||||
```
|
||||
For integers you can disable by passing: `valueSpecifier: "%.0f"`
|
||||
|
||||
|
||||
You can set your custom image in the upper right corner by passing in the initialiser: `cornerImage:Image(systemName: "waveform.path.ecg")`
|
||||
|
||||
|
||||
**Turn drop shadow off by adding to the Initialiser: `dropShadow: false`**
|
||||
|
||||
```swift
|
||||
BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: ChartStyle(formSize: Form.small))
|
||||
```
|
||||
|
||||
### You can customize styling of the chart with a ChartStyle object:
|
||||
|
||||
Customizable:
|
||||
* background color
|
||||
* accent color
|
||||
* second gradient color
|
||||
* chart form size
|
||||
* text color
|
||||
* legend text color
|
||||
|
||||
```swift
|
||||
let chartStyle = ChartStyle(backgroundColor: Color.black, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, chartFormSize: Form.medium, textColor: Color.white, legendTextColor: Color.white )
|
||||
let chartStyle = ChartStyle(backgroundColor: Color.black, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, chartFormSize: ChartForm.medium, textColor: Color.white, legendTextColor: Color.white )
|
||||
...
|
||||
BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: chartStyle)
|
||||
```
|
||||
|
||||

|
||||
You can access built-in styles:
|
||||
```swift
|
||||
BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: Styles.barChartMidnightGreen)
|
||||
```
|
||||
#### All styles available as a preset:
|
||||
* barChartStyleOrangeLight
|
||||
* barChartStyleOrangeDark
|
||||
* barChartStyleNeonBlueLight
|
||||
* barChartStyleNeonBlueDark
|
||||
* barChartMidnightGreenLight
|
||||
* barChartMidnightGreenDark
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
### You can customize the size of the chart with a ChartForm object:
|
||||
|
||||
**ChartForm**
|
||||
* `.small`
|
||||
* `.medium`
|
||||
* `.large`
|
||||
* `.detail`
|
||||
|
||||
```swift
|
||||
BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", form: ChartForm.small)
|
||||
```
|
||||
|
||||
### WatchOS support for Bar charts:
|
||||
|
||||

|
||||
|
||||
## Pie charts
|
||||

|
||||

|
||||
|
||||
You can add a line chart with the following code:
|
||||
You can add a pie chart with the following code:
|
||||
|
||||
```swift
|
||||
PieChartView(data: [8,23,54,32], title: "Title", legend: "Legendary") // legend is optional
|
||||
```
|
||||
|
||||
**Turn drop shadow off by adding to the Initialiser: `dropShadow: false`**
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -1,49 +0,0 @@
|
||||
//
|
||||
// ChartCell.swift
|
||||
// ChartView
|
||||
//
|
||||
// Created by András Samu on 2019. 06. 12..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct BarChartCell : View {
|
||||
var value: Double
|
||||
var index: Int = 0
|
||||
var width: Float
|
||||
var numberOfDataPoints: Int
|
||||
var cellWidth: Double {
|
||||
return Double(width)/(Double(numberOfDataPoints) * 1.5)
|
||||
}
|
||||
var accentColor: Color
|
||||
var secondGradientAccentColor: Color?
|
||||
var gradientColors:[Color] {
|
||||
if (secondGradientAccentColor != nil) {
|
||||
return [secondGradientAccentColor!, accentColor]
|
||||
}
|
||||
return [accentColor, accentColor]
|
||||
}
|
||||
@State var scaleValue: Double = 0
|
||||
@Binding var touchLocation: CGFloat
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(LinearGradient(gradient: Gradient(colors: gradientColors), startPoint: .bottom, endPoint: .top))
|
||||
}
|
||||
.frame(width: CGFloat(self.cellWidth))
|
||||
.scaleEffect(CGSize(width: 1, height: self.scaleValue), anchor: .bottom)
|
||||
.onAppear(){
|
||||
self.scaleValue = self.value
|
||||
}
|
||||
.animation(Animation.spring().delay(self.touchLocation < 0 ? Double(self.index) * 0.04 : 0))
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ChartCell_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
BarChartCell(value: Double(0.75), width: 320, numberOfDataPoints: 12, accentColor: Colors.OrangeStart, secondGradientAccentColor: nil, touchLocation: .constant(-1))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,39 +0,0 @@
|
||||
//
|
||||
// ChartRow.swift
|
||||
// ChartView
|
||||
//
|
||||
// Created by András Samu on 2019. 06. 12..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct BarChartRow : View {
|
||||
var data: [Int]
|
||||
var accentColor: Color
|
||||
var secondGradientAccentColor: Color?
|
||||
var maxValue: Int {
|
||||
data.max() ?? 0
|
||||
}
|
||||
@Binding var touchLocation: CGFloat
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
HStack(alignment: .bottom, spacing: (geometry.frame(in: .local).width-22)/CGFloat(self.data.count * 3)){
|
||||
ForEach(0..<self.data.count) { i in
|
||||
BarChartCell(value: Double(self.data[i])/Double(self.maxValue), index: i, width: Float(geometry.frame(in: .local).width - 22), numberOfDataPoints: self.data.count, accentColor: self.accentColor, secondGradientAccentColor: self.secondGradientAccentColor, touchLocation: self.$touchLocation)
|
||||
.scaleEffect(self.touchLocation > CGFloat(i)/CGFloat(self.data.count) && self.touchLocation < CGFloat(i+1)/CGFloat(self.data.count) ? CGSize(width: 1.4, height: 1.1) : CGSize(width: 1, height: 1), anchor: .bottom)
|
||||
|
||||
}
|
||||
}
|
||||
.padding([.top, .leading, .trailing], 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ChartRow_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
BarChartRow(data: [8,23,54,32,12,37,7], accentColor: Colors.OrangeStart, touchLocation: .constant(-1))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,104 +0,0 @@
|
||||
//
|
||||
// ChartView.swift
|
||||
// ChartView
|
||||
//
|
||||
// Created by András Samu on 2019. 06. 12..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct BarChartView : View {
|
||||
public var data: [Int]
|
||||
public var title: String
|
||||
public var legend: String?
|
||||
public var style: ChartStyle
|
||||
let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
|
||||
|
||||
@State private var touchLocation: CGFloat = -1.0
|
||||
@State private var showValue: Bool = false
|
||||
@State private var currentValue: Int = 0 {
|
||||
didSet{
|
||||
if(oldValue != self.currentValue && self.showValue) {
|
||||
selectionFeedbackGenerator.selectionChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
var isFullWidth:Bool {
|
||||
return self.style.chartFormSize == Form.large
|
||||
}
|
||||
public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.barChartStyleOne ){
|
||||
self.data = data
|
||||
self.title = title
|
||||
self.legend = legend
|
||||
self.style = style
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack{
|
||||
Rectangle()
|
||||
.fill(self.style.backgroundColor)
|
||||
.cornerRadius(20)
|
||||
.shadow(color: Color.gray, radius: 8 )
|
||||
VStack(alignment: .leading){
|
||||
HStack{
|
||||
if(!showValue){
|
||||
Text(self.title)
|
||||
.font(.headline)
|
||||
.foregroundColor(self.style.textColor)
|
||||
}else{
|
||||
Text("\(self.currentValue)")
|
||||
.font(.headline)
|
||||
.foregroundColor(self.style.textColor)
|
||||
}
|
||||
if(self.style.chartFormSize == Form.large && self.legend != nil && !showValue) {
|
||||
Text(self.legend!)
|
||||
.font(.callout)
|
||||
.foregroundColor(self.style.accentColor)
|
||||
.transition(.opacity)
|
||||
.animation(.easeOut)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "waveform.path.ecg")
|
||||
.imageScale(.large)
|
||||
.foregroundColor(self.style.legendTextColor)
|
||||
}.padding()
|
||||
BarChartRow(data: data, accentColor: self.style.accentColor, secondGradientAccentColor: self.style.secondGradientColor, touchLocation: self.$touchLocation)
|
||||
if self.legend != nil && self.style.chartFormSize == Form.medium {
|
||||
Text(self.legend!)
|
||||
.font(.headline)
|
||||
.foregroundColor(self.style.legendTextColor)
|
||||
.padding()
|
||||
}
|
||||
|
||||
}
|
||||
}.frame(minWidth:self.style.chartFormSize.width, maxWidth: self.isFullWidth ? .infinity : self.style.chartFormSize.width, minHeight:self.style.chartFormSize.height, maxHeight:self.style.chartFormSize.height)
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
self.touchLocation = value.location.x/self.style.chartFormSize.width
|
||||
self.showValue = true
|
||||
self.currentValue = self.getCurrentValue()
|
||||
})
|
||||
.onEnded({ value in
|
||||
self.showValue = false
|
||||
self.touchLocation = -1
|
||||
})
|
||||
)
|
||||
.gesture(TapGesture()
|
||||
)
|
||||
}
|
||||
|
||||
func getCurrentValue()-> Int{
|
||||
let index = max(0,min(self.data.count-1,Int(floor((self.touchLocation*self.style.chartFormSize.width)/(self.style.chartFormSize.width/CGFloat(self.data.count))))))
|
||||
print(index)
|
||||
return self.data[index]
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct ChartView_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,98 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct AxisLabels<Content: View>: View {
|
||||
struct YAxisViewKey: ViewPreferenceKey { }
|
||||
struct ChartViewKey: ViewPreferenceKey { }
|
||||
|
||||
var axisLabelsData = AxisLabelsData()
|
||||
var axisLabelsStyle = AxisLabelsStyle()
|
||||
|
||||
@State private var yAxisWidth: CGFloat = 25
|
||||
@State private var chartWidth: CGFloat = 0
|
||||
@State private var chartHeight: CGFloat = 0
|
||||
|
||||
let content: () -> Content
|
||||
|
||||
public init(@ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var yAxis: some View {
|
||||
VStack(spacing: 0.0) {
|
||||
ForEach(Array(axisLabelsData.axisYLabels.reversed().enumerated()), id: \.element) { index, axisYData in
|
||||
Text(axisYData)
|
||||
.font(axisLabelsStyle.axisFont)
|
||||
.foregroundColor(axisLabelsStyle.axisFontColor)
|
||||
.frame(height: getYHeight(index: index,
|
||||
chartHeight: chartHeight,
|
||||
count: axisLabelsData.axisYLabels.count),
|
||||
alignment: getYAlignment(index: index, count: axisLabelsData.axisYLabels.count))
|
||||
}
|
||||
}
|
||||
.padding([.leading, .trailing], 4.0)
|
||||
.background(ViewGeometry<YAxisViewKey>())
|
||||
.onPreferenceChange(YAxisViewKey.self) { value in
|
||||
yAxisWidth = value.first?.size.width ?? 0.0
|
||||
}
|
||||
}
|
||||
|
||||
func xAxis(chartWidth: CGFloat) -> some View {
|
||||
HStack(spacing: 0.0) {
|
||||
ForEach(Array(axisLabelsData.axisXLabels.enumerated()), id: \.element) { index, axisXData in
|
||||
Text(axisXData)
|
||||
.font(axisLabelsStyle.axisFont)
|
||||
.foregroundColor(axisLabelsStyle.axisFontColor)
|
||||
.frame(width: chartWidth / CGFloat(axisLabelsData.axisXLabels.count - 1))
|
||||
}
|
||||
}
|
||||
.frame(height: 24.0, alignment: .top)
|
||||
}
|
||||
|
||||
var chart: some View {
|
||||
self.content()
|
||||
.background(ViewGeometry<ChartViewKey>())
|
||||
.onPreferenceChange(ChartViewKey.self) { value in
|
||||
chartWidth = value.first?.size.width ?? 0.0
|
||||
chartHeight = value.first?.size.height ?? 0.0
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0.0) {
|
||||
HStack {
|
||||
if axisLabelsStyle.axisLabelsYPosition == .leading {
|
||||
yAxis
|
||||
} else {
|
||||
Spacer(minLength: yAxisWidth)
|
||||
}
|
||||
chart
|
||||
if axisLabelsStyle.axisLabelsYPosition == .leading {
|
||||
Spacer(minLength: yAxisWidth)
|
||||
} else {
|
||||
yAxis
|
||||
}
|
||||
}
|
||||
xAxis(chartWidth: chartWidth)
|
||||
}
|
||||
}
|
||||
|
||||
private func getYHeight(index: Int, chartHeight: CGFloat, count: Int) -> CGFloat {
|
||||
if index == 0 || index == count - 1 {
|
||||
return chartHeight / (CGFloat(count - 1) * 2) + 10
|
||||
}
|
||||
|
||||
return chartHeight / CGFloat(count - 1)
|
||||
}
|
||||
|
||||
private func getYAlignment(index: Int, count: Int) -> Alignment {
|
||||
if index == 0 {
|
||||
return .top
|
||||
}
|
||||
|
||||
if index == count - 1 {
|
||||
return .bottom
|
||||
}
|
||||
|
||||
return .center
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import SwiftUI
|
||||
|
||||
extension AxisLabels {
|
||||
public func setAxisYLabels(_ labels: [String],
|
||||
position: AxisLabelsYPosition = .leading) -> AxisLabels {
|
||||
self.axisLabelsData.axisYLabels = labels
|
||||
self.axisLabelsStyle.axisLabelsYPosition = position
|
||||
return self
|
||||
}
|
||||
|
||||
public func setAxisXLabels(_ labels: [String]) -> AxisLabels {
|
||||
self.axisLabelsData.axisXLabels = labels
|
||||
return self
|
||||
}
|
||||
|
||||
public func setAxisYLabels(_ labels: [(Double, String)],
|
||||
range: ClosedRange<Int>,
|
||||
position: AxisLabelsYPosition = .leading) -> AxisLabels {
|
||||
let overreach = range.overreach + 1
|
||||
var labelArray = [String](repeating: "", count: overreach)
|
||||
labels.forEach {
|
||||
let index = Int($0.0) - range.lowerBound
|
||||
if labelArray[safe: index] != nil {
|
||||
labelArray[index] = $0.1
|
||||
}
|
||||
}
|
||||
|
||||
self.axisLabelsData.axisYLabels = labelArray
|
||||
self.axisLabelsStyle.axisLabelsYPosition = position
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
public func setAxisXLabels(_ labels: [(Double, String)], range: ClosedRange<Int>) -> AxisLabels {
|
||||
let overreach = range.overreach + 1
|
||||
var labelArray = [String](repeating: "", count: overreach)
|
||||
labels.forEach {
|
||||
let index = Int($0.0) - range.lowerBound
|
||||
if labelArray[safe: index] != nil {
|
||||
labelArray[index] = $0.1
|
||||
}
|
||||
}
|
||||
|
||||
self.axisLabelsData.axisXLabels = labelArray
|
||||
return self
|
||||
}
|
||||
|
||||
public func setColor(_ color: Color) -> AxisLabels {
|
||||
self.axisLabelsStyle.axisFontColor = color
|
||||
return self
|
||||
}
|
||||
|
||||
public func setFont(_ font: Font) -> AxisLabels {
|
||||
self.axisLabelsStyle.axisFont = font
|
||||
return self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public enum AxisLabelsYPosition {
|
||||
case leading
|
||||
case trailing
|
||||
}
|
||||
|
||||
public enum AxisLabelsXPosition {
|
||||
case top
|
||||
case bottom
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
public final class AxisLabelsStyle: ObservableObject {
|
||||
@Published public var axisFont: Font = .callout
|
||||
@Published public var axisFontColor: Color = .primary
|
||||
@Published var axisLabelsYPosition: AxisLabelsYPosition = .leading
|
||||
@Published var axisLabelsXPosition: AxisLabelsXPosition = .bottom
|
||||
public init() {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
public final class AxisLabelsData: ObservableObject {
|
||||
@Published public var axisYLabels: [String] = []
|
||||
@Published public var axisXLabels: [String] = []
|
||||
|
||||
public init() {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import SwiftUI
|
||||
|
||||
/// View containing data and some kind of chart content
|
||||
public struct CardView<Content: View>: View, ChartBase {
|
||||
public var chartData = ChartData()
|
||||
let content: () -> Content
|
||||
|
||||
private var showShadow: Bool
|
||||
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
/// Initialize with view options and a nested `ViewBuilder`
|
||||
/// - Parameters:
|
||||
/// - showShadow: should card have a rounded-rectangle shadow around it
|
||||
/// - content: <#content description#>
|
||||
public init(showShadow: Bool = true, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.showShadow = showShadow
|
||||
self.content = content
|
||||
}
|
||||
|
||||
/// The content and behavior of the `CardView`.
|
||||
///
|
||||
///
|
||||
public var body: some View {
|
||||
ZStack{
|
||||
if showShadow {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color.white)
|
||||
.shadow(color: Color(white: 0.9, opacity: 1), radius: 8)
|
||||
}
|
||||
VStack (alignment: .leading) {
|
||||
self.content()
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: showShadow ? 20 : 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Protocol for any type of chart, to get access to underlying data
|
||||
public protocol ChartBase: View {
|
||||
var chartData: ChartData { get }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
|
||||
/// An observable wrapper for an array of data for use in any chart
|
||||
public class ChartData: ObservableObject {
|
||||
@Published public var data: [(Double, Double)] = []
|
||||
public var rangeY: ClosedRange<Double>?
|
||||
public var rangeX: ClosedRange<Double>?
|
||||
|
||||
var points: [Double] {
|
||||
data.filter { rangeX?.contains($0.0) ?? true }.map { $0.1 }
|
||||
}
|
||||
|
||||
var values: [Double] {
|
||||
data.filter { rangeX?.contains($0.0) ?? true }.map { $0.0 }
|
||||
}
|
||||
|
||||
var normalisedPoints: [Double] {
|
||||
let absolutePoints = points.map { abs($0) }
|
||||
var maxPoint = absolutePoints.max()
|
||||
if let rangeY = rangeY {
|
||||
maxPoint = Double(rangeY.overreach)
|
||||
return points.map { ($0 - rangeY.lowerBound) / (maxPoint ?? 1.0) }
|
||||
}
|
||||
|
||||
return points.map { $0 / (maxPoint ?? 1.0) }
|
||||
}
|
||||
|
||||
var normalisedValues: [Double] {
|
||||
let absoluteValues = values.map { abs($0) }
|
||||
var maxValue = absoluteValues.max()
|
||||
if let rangeX = rangeX {
|
||||
maxValue = Double(rangeX.overreach)
|
||||
return values.map { ($0 - rangeX.lowerBound) / (maxValue ?? 1.0) }
|
||||
}
|
||||
|
||||
return values.map { $0 / (maxValue ?? 1.0) }
|
||||
}
|
||||
|
||||
var normalisedData: [(Double, Double)] {
|
||||
Array(zip(normalisedValues, normalisedPoints))
|
||||
}
|
||||
|
||||
var normalisedYRange: Double {
|
||||
if let _ = rangeY {
|
||||
return 1
|
||||
}
|
||||
|
||||
return (normalisedPoints.max() ?? 0.0) - (normalisedPoints.min() ?? 0.0)
|
||||
}
|
||||
|
||||
var normalisedXRange: Double {
|
||||
if let _ = rangeX {
|
||||
return 1
|
||||
}
|
||||
|
||||
return (normalisedValues.max() ?? 0.0) - (normalisedValues.min() ?? 0.0)
|
||||
}
|
||||
|
||||
var isInNegativeDomain: Bool {
|
||||
if let rangeY = rangeY {
|
||||
return rangeY.lowerBound < 0
|
||||
}
|
||||
|
||||
return (points.min() ?? 0.0) < 0
|
||||
}
|
||||
|
||||
/// Initialize with data array
|
||||
/// - Parameter data: Array of `Double`
|
||||
public init(_ data: [Double], rangeY: ClosedRange<FloatLiteralType>? = nil) {
|
||||
self.data = data.enumerated().map{ (index, value) in (Double(index), value) }
|
||||
self.rangeY = rangeY
|
||||
}
|
||||
|
||||
public init(_ data: [(Double, Double)], rangeY: ClosedRange<FloatLiteralType>? = nil) {
|
||||
self.data = data
|
||||
self.rangeY = rangeY
|
||||
}
|
||||
|
||||
public init() {
|
||||
self.data = []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Representation of a single data point in a chart that is being observed
|
||||
public class ChartValue: ObservableObject {
|
||||
@Published var currentValue: Double = 0
|
||||
@Published var interactionInProgress: Bool = false
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct ViewGeometry<T>: View where T: PreferenceKey {
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear
|
||||
.preference(key: T.self, value: [ViewSizeData(size: geometry.size)] as! T.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
public protocol ViewPreferenceKey: PreferenceKey {
|
||||
typealias Value = [ViewSizeData]
|
||||
}
|
||||
|
||||
public extension ViewPreferenceKey {
|
||||
static var defaultValue: [ViewSizeData] {
|
||||
[]
|
||||
}
|
||||
|
||||
static func reduce(value: inout [ViewSizeData], nextValue: () -> [ViewSizeData]) {
|
||||
value.append(contentsOf: nextValue())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct ViewSizeData: Identifiable, Equatable, Hashable {
|
||||
public let id: UUID = UUID()
|
||||
public let size: CGSize
|
||||
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
extension Array where Element == ColorGradient {
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameter index: offset in data table
|
||||
/// - Returns: <#description#>
|
||||
func rotate(for index: Int) -> ColorGradient {
|
||||
if self.isEmpty {
|
||||
return ColorGradient.orangeBright
|
||||
}
|
||||
|
||||
if self.count <= index {
|
||||
return self[index % self.count]
|
||||
}
|
||||
|
||||
return self[index]
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection {
|
||||
/// Returns the element at the specified index if it is within bounds, otherwise nil.
|
||||
subscript (safe index: Index) -> Element? {
|
||||
return indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
extension CGPoint {
|
||||
|
||||
/// Calculate X and Y delta for each data point, based on data min/max and enclosing frame.
|
||||
/// - Parameters:
|
||||
/// - frame: Rectangle of enclosing frame
|
||||
/// - data: array of `Double`
|
||||
/// - Returns: X and Y delta as a `CGPoint`
|
||||
static func getStep(frame: CGRect, data: [Double]) -> CGPoint {
|
||||
let padding: CGFloat = 0
|
||||
|
||||
// stepWidth
|
||||
var stepWidth: CGFloat = 0.0
|
||||
if data.count < 2 {
|
||||
stepWidth = 0.0
|
||||
}
|
||||
stepWidth = frame.size.width / CGFloat(data.count - 1)
|
||||
|
||||
// stepHeight
|
||||
var stepHeight: CGFloat = 0.0
|
||||
|
||||
var min: Double?
|
||||
var max: Double?
|
||||
if let minPoint = data.min(), let maxPoint = data.max(), minPoint != maxPoint {
|
||||
min = minPoint
|
||||
max = maxPoint
|
||||
} else {
|
||||
return .zero
|
||||
}
|
||||
if let min = min, let max = max, min != max {
|
||||
if min <= 0 {
|
||||
stepHeight = (frame.size.height - padding) / CGFloat(max - min)
|
||||
} else {
|
||||
stepHeight = (frame.size.height - padding) / CGFloat(max + min)
|
||||
}
|
||||
}
|
||||
|
||||
return CGPoint(x: stepWidth, y: stepHeight)
|
||||
}
|
||||
|
||||
func denormalize(with geometry: GeometryProxy) -> CGPoint {
|
||||
let width = geometry.frame(in: .local).width
|
||||
let height = geometry.frame(in: .local).height
|
||||
return CGPoint(x: self.x * width, y: self.y * height)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension CGRect {
|
||||
|
||||
/// Midpoint of rectangle
|
||||
/// - Returns: the coordinate for a rectangle center
|
||||
public var mid: CGPoint {
|
||||
return CGPoint(x: self.midX, y: self.midY)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ChartBase {
|
||||
public func data(_ data: [Double]) -> some ChartBase {
|
||||
chartData.data = data.enumerated().map{ (index, value) in (Double(index), value) }
|
||||
return self
|
||||
}
|
||||
|
||||
public func data(_ data: [(Double, Double)]) -> some ChartBase {
|
||||
chartData.data = data
|
||||
return self
|
||||
}
|
||||
|
||||
public func rangeY(_ range: ClosedRange<FloatLiteralType>) -> some ChartBase{
|
||||
chartData.rangeY = range
|
||||
return self
|
||||
}
|
||||
|
||||
public func rangeX(_ range: ClosedRange<FloatLiteralType>) -> some ChartBase{
|
||||
chartData.rangeX = range
|
||||
return self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
/// Create a `Color` from a hexadecimal representation
|
||||
/// - Parameter hexString: 3, 6, or 8-character string, with optional (ignored) punctuation such as "#"
|
||||
init(hexString: String) {
|
||||
let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int = UInt64()
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let red, green, blue: UInt64
|
||||
switch hex.count {
|
||||
case 3: // RGB (12-bit)
|
||||
(red, green, blue) = ((int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||
case 6: // RGB (24-bit)
|
||||
(red, green, blue) = (int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8: // ARGB (32-bit)
|
||||
// FIXME: I think we need an an alpha value on this one. See link below.
|
||||
// https://stackoverflow.com/a/56874327/4475605
|
||||
(red, green, blue) = (int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(red, green, blue) = (0, 0, 0)
|
||||
}
|
||||
self.init(red: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Path {
|
||||
func trimmedPath(for percent: CGFloat) -> Path {
|
||||
let boundsDistance: CGFloat = 0.001
|
||||
let completion: CGFloat = 1 - boundsDistance
|
||||
|
||||
let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
|
||||
|
||||
// Start/end points centered around given percentage, but capped if right at the very end
|
||||
let start = pct > completion ? completion : pct - boundsDistance
|
||||
let end = pct > completion ? 1 : pct + boundsDistance
|
||||
return trimmedPath(from: start, to: end)
|
||||
}
|
||||
|
||||
func point(for percent: CGFloat) -> CGPoint {
|
||||
let path = trimmedPath(for: percent)
|
||||
return CGPoint(x: path.boundingRect.midX, y: path.boundingRect.midY)
|
||||
}
|
||||
|
||||
func point(to maxX: CGFloat) -> CGPoint {
|
||||
let total = length
|
||||
let sub = length(to: maxX)
|
||||
let percent = sub / total
|
||||
return point(for: percent)
|
||||
}
|
||||
|
||||
var length: CGFloat {
|
||||
var ret: CGFloat = 0.0
|
||||
var start: CGPoint?
|
||||
var point = CGPoint.zero
|
||||
|
||||
forEach { ele in
|
||||
switch ele {
|
||||
case .move(let to):
|
||||
if start == nil {
|
||||
start = to
|
||||
}
|
||||
point = to
|
||||
case .line(let to):
|
||||
ret += point.line(to: to)
|
||||
point = to
|
||||
case .quadCurve(let to, let control):
|
||||
ret += point.quadCurve(to: to, control: control)
|
||||
point = to
|
||||
case .curve(let to, let control1, let control2):
|
||||
ret += point.curve(to: to, control1: control1, control2: control2)
|
||||
point = to
|
||||
case .closeSubpath:
|
||||
if let to = start {
|
||||
ret += point.line(to: to)
|
||||
point = to
|
||||
}
|
||||
start = nil
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func length(to maxX: CGFloat) -> CGFloat {
|
||||
var ret: CGFloat = 0.0
|
||||
var start: CGPoint?
|
||||
var point = CGPoint.zero
|
||||
var finished = false
|
||||
|
||||
forEach { ele in
|
||||
if finished {
|
||||
return
|
||||
}
|
||||
switch ele {
|
||||
case .move(let to):
|
||||
if to.x > maxX {
|
||||
finished = true
|
||||
return
|
||||
}
|
||||
if start == nil {
|
||||
start = to
|
||||
}
|
||||
point = to
|
||||
case .line(let to):
|
||||
if to.x > maxX {
|
||||
finished = true
|
||||
ret += point.line(to: to, x: maxX)
|
||||
return
|
||||
}
|
||||
ret += point.line(to: to)
|
||||
point = to
|
||||
case .quadCurve(let to, let control):
|
||||
if to.x > maxX {
|
||||
finished = true
|
||||
ret += point.quadCurve(to: to, control: control, x: maxX)
|
||||
return
|
||||
}
|
||||
ret += point.quadCurve(to: to, control: control)
|
||||
point = to
|
||||
case .curve(let to, let control1, let control2):
|
||||
if to.x > maxX {
|
||||
finished = true
|
||||
ret += point.curve(to: to, control1: control1, control2: control2, x: maxX)
|
||||
return
|
||||
}
|
||||
ret += point.curve(to: to, control1: control1, control2: control2)
|
||||
point = to
|
||||
case .closeSubpath:
|
||||
fatalError("Can't include closeSubpath")
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
static func quadCurvedPathWithPoints(points: [Double], step: CGPoint, globalOffset: Double? = nil) -> Path {
|
||||
var path = Path()
|
||||
if points.count < 2 {
|
||||
return path
|
||||
}
|
||||
let offset = globalOffset ?? points.min()!
|
||||
// guard let offset = points.min() else { return path }
|
||||
var point1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)
|
||||
path.move(to: point1)
|
||||
for pointIndex in 1..<points.count {
|
||||
let point2 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]-offset))
|
||||
let midPoint = CGPoint.midPointForPoints(firstPoint: point1, secondPoint: point2)
|
||||
path.addQuadCurve(to: midPoint, control: CGPoint.controlPointForPoints(firstPoint: midPoint, secondPoint: point1))
|
||||
path.addQuadCurve(to: point2, control: CGPoint.controlPointForPoints(firstPoint: midPoint, secondPoint: point2))
|
||||
point1 = point2
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
static func quadCurvedPathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
if data.count < 2 {
|
||||
return path
|
||||
}
|
||||
|
||||
let convertedXValues = data.map { CGFloat($0.0) * rect.width }
|
||||
let convertedYPoints = data.map { CGFloat($0.1) * rect.height }
|
||||
|
||||
var point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0])
|
||||
path.move(to: point1)
|
||||
for pointIndex in 1..<data.count {
|
||||
let point2 = CGPoint(x: CGFloat(convertedXValues[pointIndex]), y: CGFloat(convertedYPoints[pointIndex]))
|
||||
let midPoint = CGPoint.midPointForPoints(firstPoint: point1, secondPoint: point2)
|
||||
path.addQuadCurve(to: midPoint, control: CGPoint.controlPointForPoints(firstPoint: midPoint, secondPoint: point1))
|
||||
path.addQuadCurve(to: point2, control: CGPoint.controlPointForPoints(firstPoint: midPoint, secondPoint: point2))
|
||||
point1 = point2
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
static func drawChartMarkers(data: [(Double, Double)], in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let filteredData = data.filter { $0.1 <= 1 && $0.1 >= 0 }
|
||||
|
||||
if filteredData.count < 1 {
|
||||
return path
|
||||
}
|
||||
|
||||
let convertedXValues = filteredData.map { CGFloat($0.0) * rect.width }
|
||||
let convertedYPoints = filteredData.map { CGFloat($0.1) * rect.height }
|
||||
|
||||
let markerSize = CGSize(width: 8, height: 8)
|
||||
for pointIndex in 0..<filteredData.count {
|
||||
path.addRoundedRect(in: CGRect(origin: CGPoint(x: convertedXValues[pointIndex] - markerSize.width / 2,
|
||||
y: convertedYPoints[pointIndex] - markerSize.height / 2),
|
||||
size: markerSize),
|
||||
cornerSize: CGSize(width: markerSize.width / 2,
|
||||
height: markerSize.height / 2))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
static func drawGridLines(numberOfHorizontalLines: Int, numberOfVerticalLines: Int, in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
|
||||
for index in 0..<numberOfHorizontalLines {
|
||||
let normalisedSpacing = 1.0 / CGFloat(numberOfHorizontalLines - 1)
|
||||
let startPoint = CGPoint(x: 0, y: normalisedSpacing * CGFloat(index) * rect.height)
|
||||
let endPoint = CGPoint(x: rect.width, y: normalisedSpacing * CGFloat(index) * rect.height)
|
||||
path.move(to: startPoint)
|
||||
path.addLine(to: endPoint)
|
||||
}
|
||||
|
||||
for index in 0..<numberOfVerticalLines {
|
||||
let normalisedSpacing = 1.0 / CGFloat(numberOfVerticalLines - 1)
|
||||
let startPoint = CGPoint(x: normalisedSpacing * CGFloat(index) * rect.width, y: 0)
|
||||
let endPoint = CGPoint(x: normalisedSpacing * CGFloat(index) * rect.width, y: rect.height)
|
||||
path.move(to: startPoint)
|
||||
path.addLine(to: endPoint)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
static func quadClosedCurvedPathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
if data.count < 2 {
|
||||
return path
|
||||
}
|
||||
|
||||
let convertedXValues = data.map { CGFloat($0.0) * rect.width }
|
||||
let convertedYPoints = data.map { CGFloat($0.1) * rect.height }
|
||||
|
||||
path.move(to: .zero)
|
||||
var point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0])
|
||||
path.addLine(to: point1)
|
||||
for pointIndex in 1..<data.count {
|
||||
let point2 = CGPoint(x: CGFloat(convertedXValues[pointIndex]), y: CGFloat(convertedYPoints[pointIndex]))
|
||||
let midPoint = CGPoint.midPointForPoints(firstPoint: point1, secondPoint: point2)
|
||||
path.addQuadCurve(to: midPoint, control: CGPoint.controlPointForPoints(firstPoint: midPoint, secondPoint: point1))
|
||||
path.addQuadCurve(to: point2, control: CGPoint.controlPointForPoints(firstPoint: midPoint, secondPoint: point2))
|
||||
point1 = point2
|
||||
}
|
||||
path.addLine(to: CGPoint(x: point1.x, y: 0))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
|
||||
static func linePathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
if data.count < 2 {
|
||||
return path
|
||||
}
|
||||
|
||||
let convertedXValues = data.map { CGFloat($0.0) * rect.width }
|
||||
let convertedYPoints = data.map { CGFloat($0.1) * rect.height }
|
||||
|
||||
let point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0])
|
||||
path.move(to: point1)
|
||||
for pointIndex in 1..<data.count {
|
||||
let point2 = CGPoint(x: CGFloat(convertedXValues[pointIndex]), y: CGFloat(convertedYPoints[pointIndex]))
|
||||
path.addLine(to: point2)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
static func closedLinePathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
if data.count < 2 {
|
||||
return path
|
||||
}
|
||||
|
||||
let convertedXValues = data.map { CGFloat($0.0) * rect.width }
|
||||
let convertedYPoints = data.map { CGFloat($0.1) * rect.height }
|
||||
path.move(to: .zero)
|
||||
|
||||
let point1 = CGPoint(x: convertedXValues[0], y: convertedYPoints[0])
|
||||
path.addLine(to: point1)
|
||||
|
||||
for pointIndex in 1..<data.count {
|
||||
let point2 = CGPoint(x: CGFloat(convertedXValues[pointIndex]), y: CGFloat(convertedYPoints[pointIndex]))
|
||||
path.addLine(to: point2)
|
||||
}
|
||||
path.addLine(to: CGPoint(x: point1.x, y: 0))
|
||||
path.closeSubpath()
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension CGPoint {
|
||||
func point(to: CGPoint, x: CGFloat) -> CGPoint {
|
||||
let a = (to.y - self.y) / (to.x - self.x)
|
||||
let y = self.y + (x - self.x) * a
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
func line(to: CGPoint) -> CGFloat {
|
||||
dist(to: to)
|
||||
}
|
||||
|
||||
func line(to: CGPoint, x: CGFloat) -> CGFloat {
|
||||
dist(to: point(to: to, x: x))
|
||||
}
|
||||
|
||||
func quadCurve(to: CGPoint, control: CGPoint) -> CGFloat {
|
||||
var dist: CGFloat = 0
|
||||
let steps: CGFloat = 100
|
||||
|
||||
for i in 0..<Int(steps) {
|
||||
let t0 = CGFloat(i) / steps
|
||||
let t1 = CGFloat(i+1) / steps
|
||||
let a = point(to: to, t: t0, control: control)
|
||||
let b = point(to: to, t: t1, control: control)
|
||||
|
||||
dist += a.line(to: b)
|
||||
}
|
||||
return dist
|
||||
}
|
||||
|
||||
func quadCurve(to: CGPoint, control: CGPoint, x: CGFloat) -> CGFloat {
|
||||
var dist: CGFloat = 0
|
||||
let steps: CGFloat = 100
|
||||
|
||||
for i in 0..<Int(steps) {
|
||||
let t0 = CGFloat(i) / steps
|
||||
let t1 = CGFloat(i+1) / steps
|
||||
let a = point(to: to, t: t0, control: control)
|
||||
let b = point(to: to, t: t1, control: control)
|
||||
|
||||
if a.x >= x {
|
||||
return dist
|
||||
} else if b.x > x {
|
||||
dist += a.line(to: b, x: x)
|
||||
return dist
|
||||
} else if b.x == x {
|
||||
dist += a.line(to: b)
|
||||
return dist
|
||||
}
|
||||
|
||||
dist += a.line(to: b)
|
||||
}
|
||||
return dist
|
||||
}
|
||||
|
||||
func point(to: CGPoint, t: CGFloat, control: CGPoint) -> CGPoint {
|
||||
let x = CGPoint.value(x: self.x, y: to.x, t: t, c: control.x)
|
||||
let y = CGPoint.value(x: self.y, y: to.y, t: t, c: control.y)
|
||||
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
func curve(to: CGPoint, control1: CGPoint, control2: CGPoint) -> CGFloat {
|
||||
var dist: CGFloat = 0
|
||||
let steps: CGFloat = 100
|
||||
|
||||
for i in 0..<Int(steps) {
|
||||
let t0 = CGFloat(i) / steps
|
||||
let t1 = CGFloat(i+1) / steps
|
||||
|
||||
let a = point(to: to, t: t0, control1: control1, control2: control2)
|
||||
let b = point(to: to, t: t1, control1: control1, control2: control2)
|
||||
|
||||
dist += a.line(to: b)
|
||||
}
|
||||
|
||||
return dist
|
||||
}
|
||||
|
||||
func curve(to: CGPoint, control1: CGPoint, control2: CGPoint, x: CGFloat) -> CGFloat {
|
||||
var dist: CGFloat = 0
|
||||
let steps: CGFloat = 100
|
||||
|
||||
for i in 0..<Int(steps) {
|
||||
let t0 = CGFloat(i) / steps
|
||||
let t1 = CGFloat(i+1) / steps
|
||||
|
||||
let a = point(to: to, t: t0, control1: control1, control2: control2)
|
||||
let b = point(to: to, t: t1, control1: control1, control2: control2)
|
||||
|
||||
if a.x >= x {
|
||||
return dist
|
||||
} else if b.x > x {
|
||||
dist += a.line(to: b, x: x)
|
||||
return dist
|
||||
} else if b.x == x {
|
||||
dist += a.line(to: b)
|
||||
return dist
|
||||
}
|
||||
|
||||
dist += a.line(to: b)
|
||||
}
|
||||
|
||||
return dist
|
||||
}
|
||||
|
||||
func point(to: CGPoint, t: CGFloat, control1: CGPoint, control2: CGPoint) -> CGPoint {
|
||||
let x = CGPoint.value(x: self.x, y: to.x, t: t, control1: control1.x, control2: control2.x)
|
||||
let y = CGPoint.value(x: self.y, y: to.y, t: t, control1: control1.y, control2: control2.x)
|
||||
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
static func value(x: CGFloat, y: CGFloat, t: CGFloat, c: CGFloat) -> CGFloat {
|
||||
var value: CGFloat = 0.0
|
||||
// (1-t)^2 * p0 + 2 * (1-t) * t * c1 + t^2 * p1
|
||||
value += pow(1-t, 2) * x
|
||||
value += 2 * (1-t) * t * c
|
||||
value += pow(t, 2) * y
|
||||
return value
|
||||
}
|
||||
|
||||
static func value(x: CGFloat, y: CGFloat, t: CGFloat, control1: CGFloat, control2: CGFloat) -> CGFloat {
|
||||
var value: CGFloat = 0.0
|
||||
// (1-t)^3 * p0 + 3 * (1-t)^2 * t * c1 + 3 * (1-t) * t^2 * c2 + t^3 * p1
|
||||
value += pow(1-t, 3) * x
|
||||
value += 3 * pow(1-t, 2) * t * control1
|
||||
value += 3 * (1-t) * pow(t, 2) * control2
|
||||
value += pow(t, 3) * y
|
||||
return value
|
||||
}
|
||||
|
||||
static func getMidPoint(point1: CGPoint, point2: CGPoint) -> CGPoint {
|
||||
return CGPoint(
|
||||
x: point1.x + (point2.x - point1.x) / 2,
|
||||
y: point1.y + (point2.y - point1.y) / 2
|
||||
)
|
||||
}
|
||||
|
||||
func dist(to: CGPoint) -> CGFloat {
|
||||
return sqrt((pow(self.x - to.x, 2) + pow(self.y - to.y, 2)))
|
||||
}
|
||||
|
||||
static func midPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint {
|
||||
return CGPoint(
|
||||
x: (firstPoint.x + secondPoint.x) / 2,
|
||||
y: (firstPoint.y + secondPoint.y) / 2)
|
||||
}
|
||||
|
||||
static func controlPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint {
|
||||
var controlPoint = CGPoint.midPointForPoints(firstPoint: firstPoint, secondPoint: secondPoint)
|
||||
let diffY = abs(secondPoint.y - controlPoint.y)
|
||||
|
||||
if firstPoint.y < secondPoint.y {
|
||||
controlPoint.y += diffY
|
||||
} else if firstPoint.y > secondPoint.y {
|
||||
controlPoint.y -= diffY
|
||||
}
|
||||
return controlPoint
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public extension ClosedRange where Bound: AdditiveArithmetic {
|
||||
var overreach: Bound {
|
||||
self.upperBound - self.lowerBound
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Shape {
|
||||
func fill<Fill: ShapeStyle, Stroke: ShapeStyle>(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View {
|
||||
self
|
||||
.stroke(strokeStyle, lineWidth: lineWidth)
|
||||
.background(self.fill(fillStyle))
|
||||
}
|
||||
}
|
||||
|
||||
extension InsettableShape {
|
||||
func fill<Fill: ShapeStyle, Stroke: ShapeStyle>(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View {
|
||||
self
|
||||
.strokeBorder(strokeStyle, lineWidth: lineWidth)
|
||||
.background(self.fill(fillStyle))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
|
||||
/// Attach chart style to a View
|
||||
/// - Parameter style: chart style
|
||||
/// - Returns: `View` with chart style attached
|
||||
public func chartStyle(_ style: ChartStyle) -> some View {
|
||||
self.environmentObject(style)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct ChartGrid<Content: View>: View {
|
||||
let content: () -> Content
|
||||
public var gridOptions = GridOptions()
|
||||
|
||||
public init(@ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
ChartGridShape(numberOfHorizontalLines: gridOptions.numberOfHorizontalLines,
|
||||
numberOfVerticalLines: gridOptions.numberOfVerticalLines)
|
||||
.stroke(gridOptions.color, style: gridOptions.strokeStyle)
|
||||
if gridOptions.showBaseLine {
|
||||
ChartGridBaseShape()
|
||||
.stroke(gridOptions.color, style: gridOptions.baseStrokeStyle)
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
}
|
||||
self.content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChartGridBaseShape: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 0, y: 0))
|
||||
path.addLine(to: CGPoint(x: rect.width, y: 0))
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct ChartGridBaseShape_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChartGridBaseShape()
|
||||
.stroke()
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChartGridShape: Shape {
|
||||
var numberOfHorizontalLines: Int
|
||||
var numberOfVerticalLines: Int
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = Path.drawGridLines(numberOfHorizontalLines: numberOfHorizontalLines,
|
||||
numberOfVerticalLines: numberOfVerticalLines,
|
||||
in: rect)
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct ChartGridShape_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
ChartGridShape(numberOfHorizontalLines: 5, numberOfVerticalLines: 0)
|
||||
.stroke()
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
|
||||
ChartGridShape(numberOfHorizontalLines: 4, numberOfVerticalLines: 4)
|
||||
.stroke()
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ChartGrid {
|
||||
public func setNumberOfHorizontalLines(_ numberOfLines: Int) -> ChartGrid {
|
||||
self.gridOptions.numberOfHorizontalLines = numberOfLines
|
||||
return self
|
||||
}
|
||||
|
||||
public func setNumberOfVerticalLines(_ numberOfLines: Int) -> ChartGrid {
|
||||
self.gridOptions.numberOfVerticalLines = numberOfLines
|
||||
return self
|
||||
}
|
||||
|
||||
public func setStoreStyle(_ strokeStyle: StrokeStyle) -> ChartGrid {
|
||||
self.gridOptions.strokeStyle = strokeStyle
|
||||
return self
|
||||
}
|
||||
|
||||
public func setColor(_ color: Color) -> ChartGrid {
|
||||
self.gridOptions.color = color
|
||||
return self
|
||||
}
|
||||
|
||||
public func showBaseLine(_ show: Bool, with style: StrokeStyle? = nil) -> ChartGrid {
|
||||
self.gridOptions.showBaseLine = show
|
||||
if let style = style {
|
||||
self.gridOptions.baseStrokeStyle = style
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
public final class GridOptions: ObservableObject {
|
||||
@Published public var numberOfHorizontalLines: Int = 3
|
||||
@Published public var numberOfVerticalLines: Int = 3
|
||||
@Published public var strokeStyle: StrokeStyle = StrokeStyle(lineWidth: 1, lineCap: .round, dash: [5, 10])
|
||||
@Published public var color: Color = Color(white: 0.85)
|
||||
@Published public var showBaseLine: Bool = true
|
||||
@Published public var baseStrokeStyle: StrokeStyle = StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [5, 0])
|
||||
|
||||
public init() {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import SwiftUI
|
||||
|
||||
/// What kind of label - this affects color, size, position of the label
|
||||
public enum ChartLabelType {
|
||||
case title
|
||||
case subTitle
|
||||
case largeTitle
|
||||
case custom(size: CGFloat, padding: EdgeInsets, color: Color)
|
||||
case legend
|
||||
}
|
||||
|
||||
/// A chart may contain any number of labels in pre-set positions based on their `ChartLabelType`
|
||||
public struct ChartLabel: View {
|
||||
@EnvironmentObject var chartValue: ChartValue
|
||||
@State var textToDisplay:String = ""
|
||||
var format: String = "%.01f"
|
||||
|
||||
private var title: String
|
||||
|
||||
/// Label font size
|
||||
/// - Returns: the font size of the label
|
||||
private var labelSize: CGFloat {
|
||||
switch labelType {
|
||||
case .title:
|
||||
return 32.0
|
||||
case .legend:
|
||||
return 14.0
|
||||
case .subTitle:
|
||||
return 24.0
|
||||
case .largeTitle:
|
||||
return 38.0
|
||||
case .custom(let size, _, _):
|
||||
return size
|
||||
}
|
||||
}
|
||||
|
||||
/// Padding around label
|
||||
/// - Returns: the edge padding to use based on position of the label
|
||||
private var labelPadding: EdgeInsets {
|
||||
switch labelType {
|
||||
case .title:
|
||||
return EdgeInsets(top: 16.0, leading: 0, bottom: 0.0, trailing: 8.0)
|
||||
case .legend:
|
||||
return EdgeInsets(top: 4.0, leading: 0, bottom: 0.0, trailing: 8.0)
|
||||
case .subTitle:
|
||||
return EdgeInsets(top: 8.0, leading: 0, bottom: 0.0, trailing: 8.0)
|
||||
case .largeTitle:
|
||||
return EdgeInsets(top: 24.0, leading: 0, bottom: 0.0, trailing: 8.0)
|
||||
case .custom(_, let padding, _):
|
||||
return padding
|
||||
}
|
||||
}
|
||||
|
||||
/// Which type (color, size, position) for label
|
||||
private let labelType: ChartLabelType
|
||||
|
||||
/// Foreground color for this label
|
||||
/// - Returns: Color of label based on its `ChartLabelType`
|
||||
private var labelColor: Color {
|
||||
switch labelType {
|
||||
case .title:
|
||||
return Color.primary
|
||||
case .legend:
|
||||
return Color.secondary
|
||||
case .subTitle:
|
||||
return Color.primary
|
||||
case .largeTitle:
|
||||
return Color.primary
|
||||
case .custom(_, _, let color):
|
||||
return color
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize
|
||||
/// - Parameters:
|
||||
/// - title: Any `String`
|
||||
/// - type: Which `ChartLabelType` to use
|
||||
public init (_ title: String,
|
||||
type: ChartLabelType = .title,
|
||||
format: String = "%.01f") {
|
||||
self.title = title
|
||||
labelType = type
|
||||
self.format = format
|
||||
}
|
||||
|
||||
/// The content and behavior of the `ChartLabel`.
|
||||
///
|
||||
/// Displays current value if chart is currently being touched along a data point, otherwise the specified text.
|
||||
public var body: some View {
|
||||
HStack {
|
||||
Text(textToDisplay)
|
||||
.font(.system(size: labelSize))
|
||||
.bold()
|
||||
.foregroundColor(self.labelColor)
|
||||
.padding(self.labelPadding)
|
||||
.onAppear {
|
||||
self.textToDisplay = self.title
|
||||
}
|
||||
.onReceive(self.chartValue.objectWillChange) { _ in
|
||||
self.textToDisplay = self.chartValue.interactionInProgress ? String(format: format, self.chartValue.currentValue) : self.title
|
||||
}
|
||||
if !self.chartValue.interactionInProgress {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
|
||||
public class ChartStyle: ObservableObject {
|
||||
public let backgroundColor: ColorGradient
|
||||
public let foregroundColor: [ColorGradient]
|
||||
|
||||
public init(backgroundColor: Color, foregroundColor: [ColorGradient]) {
|
||||
self.backgroundColor = ColorGradient.init(backgroundColor)
|
||||
self.foregroundColor = foregroundColor
|
||||
}
|
||||
|
||||
public init(backgroundColor: Color, foregroundColor: ColorGradient) {
|
||||
self.backgroundColor = ColorGradient.init(backgroundColor)
|
||||
self.foregroundColor = [foregroundColor]
|
||||
}
|
||||
|
||||
public init(backgroundColor: ColorGradient, foregroundColor: ColorGradient) {
|
||||
self.backgroundColor = backgroundColor
|
||||
self.foregroundColor = [foregroundColor]
|
||||
}
|
||||
|
||||
public init(backgroundColor: ColorGradient, foregroundColor: [ColorGradient]) {
|
||||
self.backgroundColor = backgroundColor
|
||||
self.foregroundColor = foregroundColor
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct ColorGradient: Equatable {
|
||||
public let startColor: Color
|
||||
public let endColor: Color
|
||||
|
||||
public init(_ color: Color) {
|
||||
self.startColor = color
|
||||
self.endColor = color
|
||||
}
|
||||
|
||||
public init(_ startColor: Color, _ endColor: Color) {
|
||||
self.startColor = startColor
|
||||
self.endColor = endColor
|
||||
}
|
||||
|
||||
public var gradient: Gradient {
|
||||
return Gradient(colors: [startColor, endColor])
|
||||
}
|
||||
}
|
||||
|
||||
extension ColorGradient {
|
||||
public func linearGradient(from startPoint: UnitPoint, to endPoint: UnitPoint) -> LinearGradient {
|
||||
return LinearGradient(gradient: self.gradient, startPoint: startPoint, endPoint: endPoint)
|
||||
}
|
||||
}
|
||||
|
||||
extension ColorGradient {
|
||||
public static let orangeBright = ColorGradient(ChartColors.orangeBright)
|
||||
public static let redBlack = ColorGradient(.red, .black)
|
||||
public static let greenRed = ColorGradient(.green, .red)
|
||||
public static let whiteBlack = ColorGradient(.white, .black)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import SwiftUI
|
||||
|
||||
public enum ChartColors {
|
||||
public static let orangeBright = Color(hexString: "#FF782C")
|
||||
public static let orangeDark = Color(hexString: "#EC2301")
|
||||
|
||||
public static let legendColor: Color = Color(hexString: "#E8E7EA")
|
||||
public static let indicatorKnob: Color = Color(hexString: "#FF57A6")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct BarChart: ChartBase {
|
||||
public var chartData = ChartData()
|
||||
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
public var body: some View {
|
||||
BarChartRow(chartData: chartData, style: style)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct BarChartCell: View {
|
||||
var value: Double
|
||||
var index: Int = 0
|
||||
var gradientColor: ColorGradient
|
||||
var touchLocation: CGFloat
|
||||
|
||||
@State private var didCellAppear: Bool = false
|
||||
|
||||
public init( value: Double,
|
||||
index: Int = 0,
|
||||
gradientColor: ColorGradient,
|
||||
touchLocation: CGFloat) {
|
||||
self.value = value
|
||||
self.index = index
|
||||
self.gradientColor = gradientColor
|
||||
self.touchLocation = touchLocation
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
BarChartCellShape(value: didCellAppear ? value : 0.0)
|
||||
.fill(gradientColor.linearGradient(from: .bottom, to: .top)) .onAppear {
|
||||
self.didCellAppear = true
|
||||
}
|
||||
.onDisappear {
|
||||
self.didCellAppear = false
|
||||
}
|
||||
.transition(.slide)
|
||||
.animation(Animation.spring().delay(self.touchLocation < 0 || !didCellAppear ? Double(self.index) * 0.04 : 0))
|
||||
}
|
||||
}
|
||||
|
||||
struct BarChartCell_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
Group {
|
||||
BarChartCell(value: 0, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
|
||||
|
||||
BarChartCell(value: 0.5, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
|
||||
BarChartCell(value: 0.75, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat())
|
||||
BarChartCell(value: 1, gradientColor: ColorGradient(.purple), touchLocation: CGFloat())
|
||||
}
|
||||
|
||||
Group {
|
||||
BarChartCell(value: 1, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
|
||||
BarChartCell(value: 1, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat())
|
||||
BarChartCell(value: 1, gradientColor: ColorGradient(.purple), touchLocation: CGFloat())
|
||||
}.environment(\.colorScheme, .dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BarChartCellShape: Shape, Animatable {
|
||||
var value: Double
|
||||
var cornerRadius: CGFloat = 6.0
|
||||
|
||||
var animatableData: CGFloat {
|
||||
get { CGFloat(value) }
|
||||
set { value = Double(newValue) }
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let adjustedOriginY = rect.height - (rect.height * CGFloat(value))
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 0.0 , y: rect.height))
|
||||
path.addLine(to: CGPoint(x: 0.0, y: adjustedOriginY + cornerRadius))
|
||||
path.addArc(center: CGPoint(x: cornerRadius, y: adjustedOriginY + cornerRadius),
|
||||
radius: cornerRadius,
|
||||
startAngle: Angle(radians: Double.pi),
|
||||
endAngle: Angle(radians: value < 0 ? Double.pi/2 : -Double.pi/2),
|
||||
clockwise: value < 0 ? true : false)
|
||||
path.addLine(to: CGPoint(x: rect.width - cornerRadius, y: value < 0 ? adjustedOriginY + 2 * cornerRadius : adjustedOriginY))
|
||||
path.addArc(center: CGPoint(x: rect.width - cornerRadius, y: adjustedOriginY + cornerRadius),
|
||||
radius: cornerRadius,
|
||||
startAngle: Angle(radians: value < 0 ? Double.pi/2 : -Double.pi/2),
|
||||
endAngle: Angle(radians: 0),
|
||||
clockwise: value < 0 ? true : false)
|
||||
path.addLine(to: CGPoint(x: rect.width, y: rect.height))
|
||||
path.closeSubpath()
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct BarChartCellShape_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
BarChartCellShape(value: 0.75)
|
||||
.fill(Color.red)
|
||||
|
||||
BarChartCellShape(value: 0.3)
|
||||
.fill(Color.blue)
|
||||
|
||||
BarChartCellShape(value: -0.3)
|
||||
.fill(Color.blue)
|
||||
.offset(x: 0, y: -600)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct BarChartRow: View {
|
||||
@EnvironmentObject var chartValue: ChartValue
|
||||
@ObservedObject var chartData: ChartData
|
||||
@State private var touchLocation: CGFloat = -1.0
|
||||
|
||||
var style: ChartStyle
|
||||
|
||||
var maxValue: Double {
|
||||
guard let max = chartData.points.max() else {
|
||||
return 1
|
||||
}
|
||||
return max != 0 ? max : 1
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
HStack(alignment: .bottom,
|
||||
spacing: geometry.frame(in: .local).width / CGFloat(chartData.data.count * 3)) {
|
||||
ForEach(0..<chartData.data.count, id: \.self) { index in
|
||||
BarChartCell(value: chartData.normalisedPoints[index],
|
||||
index: index,
|
||||
gradientColor: self.style.foregroundColor.rotate(for: index),
|
||||
touchLocation: self.touchLocation)
|
||||
.scaleEffect(self.getScaleSize(touchLocation: self.touchLocation, index: index), anchor: .bottom)
|
||||
.animation(Animation.easeIn(duration: 0.2))
|
||||
}
|
||||
// .drawingGroup()
|
||||
}
|
||||
.frame(maxHeight: chartData.isInNegativeDomain ? geometry.size.height / 2 : geometry.size.height)
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
let width = geometry.frame(in: .local).width
|
||||
self.touchLocation = value.location.x/width
|
||||
if let currentValue = self.getCurrentValue(width: width) {
|
||||
self.chartValue.currentValue = currentValue
|
||||
self.chartValue.interactionInProgress = true
|
||||
}
|
||||
})
|
||||
.onEnded({ value in
|
||||
self.chartValue.interactionInProgress = false
|
||||
self.touchLocation = -1
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func getScaleSize(touchLocation: CGFloat, index: Int) -> CGSize {
|
||||
if touchLocation > CGFloat(index)/CGFloat(chartData.data.count) &&
|
||||
touchLocation < CGFloat(index+1)/CGFloat(chartData.data.count) {
|
||||
return CGSize(width: 1.4, height: 1.1)
|
||||
}
|
||||
return CGSize(width: 1, height: 1)
|
||||
}
|
||||
|
||||
func getCurrentValue(width: CGFloat) -> Double? {
|
||||
guard self.chartData.data.count > 0 else { return nil}
|
||||
let index = max(0,min(self.chartData.data.count-1,Int(floor((self.touchLocation*width)/(width/CGFloat(self.chartData.data.count))))))
|
||||
return self.chartData.points[index]
|
||||
}
|
||||
}
|
||||
|
||||
struct BarChartRow_Previews: PreviewProvider {
|
||||
static let chartData = ChartData([6, 2, 5, 8, 6])
|
||||
static let chartStyle = ChartStyle(backgroundColor: .white, foregroundColor: .orangeBright)
|
||||
static var previews: some View {
|
||||
BarChartRow(chartData: chartData, style: chartStyle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
extension LineChart {
|
||||
public func setLineWidth(width: CGFloat) -> LineChart {
|
||||
self.chartProperties.lineWidth = width
|
||||
return self
|
||||
}
|
||||
|
||||
public func setBackground(colorGradient: ColorGradient) -> LineChart {
|
||||
self.chartProperties.backgroundGradient = colorGradient
|
||||
return self
|
||||
}
|
||||
|
||||
public func showChartMarks(_ show: Bool, with color: ColorGradient? = nil) -> LineChart {
|
||||
self.chartProperties.showChartMarks = show
|
||||
self.chartProperties.customChartMarksColors = color
|
||||
return self
|
||||
}
|
||||
|
||||
public func setLineStyle(to style: LineStyle) -> LineChart {
|
||||
self.chartProperties.lineStyle = style
|
||||
return self
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,15 @@
|
||||
//
|
||||
// IndicatorPoint.swift
|
||||
// LineChart
|
||||
//
|
||||
// Created by András Samu on 2019. 09. 03..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct IndicatorPoint: View {
|
||||
var body: some View {
|
||||
ZStack{
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Colors.IndicatorKnob)
|
||||
.fill(ChartColors.indicatorKnob)
|
||||
Circle()
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 4))
|
||||
}
|
||||
.frame(width: 14, height: 14)
|
||||
.shadow(color: Colors.LegendColor, radius: 6, x: 0, y: 6)
|
||||
.shadow(color: ChartColors.legendColor, radius: 6, x: 0, y: 6)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A single line of data, a view in a `LineChart`
|
||||
public struct Line: View {
|
||||
@ObservedObject var chartData: ChartData
|
||||
@ObservedObject var chartProperties: LineChartProperties
|
||||
|
||||
var curvedLines: Bool = true
|
||||
var style: ChartStyle
|
||||
|
||||
@State private var showIndicator: Bool = false
|
||||
@State private var touchLocation: CGPoint = .zero
|
||||
@State private var didCellAppear: Bool = false
|
||||
|
||||
var path: Path {
|
||||
Path.quadCurvedPathWithPoints(points: chartData.normalisedPoints,
|
||||
step: CGPoint(x: 1.0, y: 1.0))
|
||||
}
|
||||
|
||||
public init(chartData: ChartData,
|
||||
style: ChartStyle,
|
||||
chartProperties: LineChartProperties) {
|
||||
self.chartData = chartData
|
||||
self.style = style
|
||||
self.chartProperties = chartProperties
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
if self.didCellAppear, let backgroundColor = chartProperties.backgroundGradient {
|
||||
LineBackgroundShapeView(chartData: chartData,
|
||||
geometry: geometry,
|
||||
backgroundColor: backgroundColor)
|
||||
}
|
||||
LineShapeView(chartData: chartData,
|
||||
chartProperties: chartProperties,
|
||||
geometry: geometry,
|
||||
style: style,
|
||||
trimTo: didCellAppear ? 1.0 : 0.0)
|
||||
.animation(Animation.easeIn(duration: 0.75))
|
||||
if self.showIndicator {
|
||||
IndicatorPoint()
|
||||
.position(self.getClosestPointOnPath(geometry: geometry,
|
||||
touchLocation: self.touchLocation))
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
didCellAppear = true
|
||||
}
|
||||
.onDisappear() {
|
||||
didCellAppear = false
|
||||
}
|
||||
// .gesture(DragGesture()
|
||||
// .onChanged({ value in
|
||||
// self.touchLocation = value.location
|
||||
// self.showIndicator = true
|
||||
// self.getClosestDataPoint(geometry: geometry, touchLocation: value.location)
|
||||
// })
|
||||
// .onEnded({ value in
|
||||
// self.touchLocation = .zero
|
||||
// self.showIndicator = false
|
||||
// })
|
||||
// )
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private functions
|
||||
|
||||
extension Line {
|
||||
/// Calculate point closest to where the user touched
|
||||
/// - Parameter touchLocation: location in view where touched
|
||||
/// - Returns: `CGPoint` of data point on chart
|
||||
private func getClosestPointOnPath(geometry: GeometryProxy, touchLocation: CGPoint) -> CGPoint {
|
||||
let geometryWidth = geometry.frame(in: .local).width
|
||||
let normalisedTouchLocationX = (touchLocation.x / geometryWidth) * CGFloat(chartData.normalisedPoints.count - 1)
|
||||
let closest = self.path.point(to: normalisedTouchLocationX)
|
||||
var denormClosest = closest.denormalize(with: geometry)
|
||||
denormClosest.x = denormClosest.x / CGFloat(chartData.normalisedPoints.count - 1)
|
||||
denormClosest.y = denormClosest.y / CGFloat(chartData.normalisedYRange)
|
||||
return denormClosest
|
||||
}
|
||||
|
||||
// /// Figure out where closest touch point was
|
||||
// /// - Parameter point: location of data point on graph, near touch location
|
||||
private func getClosestDataPoint(geometry: GeometryProxy, touchLocation: CGPoint) {
|
||||
let geometryWidth = geometry.frame(in: .local).width
|
||||
let index = Int(round((touchLocation.x / geometryWidth) * CGFloat(chartData.points.count - 1)))
|
||||
if (index >= 0 && index < self.chartData.data.count){
|
||||
// self.chartValue.currentValue = self.chartData.points[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Line_Previews: PreviewProvider {
|
||||
/// Predefined style, black over white, for preview
|
||||
static let blackLineStyle = ChartStyle(backgroundColor: ColorGradient(.white), foregroundColor: ColorGradient(.black))
|
||||
|
||||
/// Predefined style red over white, for preview
|
||||
static let redLineStyle = ChartStyle(backgroundColor: .whiteBlack, foregroundColor: ColorGradient(.red))
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
Line(chartData: ChartData([8, 23, 32, 7, 23, -4]),
|
||||
style: blackLineStyle,
|
||||
chartProperties: LineChartProperties())
|
||||
Line(chartData: ChartData([8, 23, 32, 7, 23, 43]),
|
||||
style: redLineStyle,
|
||||
chartProperties: LineChartProperties())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LineBackgroundShape: Shape {
|
||||
var data: [(Double, Double)]
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = Path.quadClosedCurvedPathWithPoints(data: data, in: rect)
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct LineBackgroundShape_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
GeometryReader { geometry in
|
||||
LineBackgroundShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)])
|
||||
.fill(Color.red)
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
GeometryReader { geometry in
|
||||
LineBackgroundShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)])
|
||||
.fill(Color.blue)
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LineBackgroundShapeView: View {
|
||||
var chartData: ChartData
|
||||
var geometry: GeometryProxy
|
||||
var backgroundColor: ColorGradient
|
||||
|
||||
var body: some View {
|
||||
LineBackgroundShape(data: chartData.normalisedData)
|
||||
.fill(LinearGradient(gradient: Gradient(colors: [backgroundColor.startColor,
|
||||
backgroundColor.endColor]),
|
||||
startPoint: .bottom,
|
||||
endPoint: .top))
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct LineChart: ChartBase {
|
||||
public var chartData = ChartData()
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
public var chartProperties = LineChartProperties()
|
||||
|
||||
public var body: some View {
|
||||
Line(chartData: chartData,
|
||||
style: style,
|
||||
chartProperties: chartProperties)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LineShape: Shape {
|
||||
var data: [(Double, Double)]
|
||||
var lineStyle: LineStyle = .curved
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
switch lineStyle {
|
||||
case .curved:
|
||||
path = Path.quadCurvedPathWithPoints(data: data, in: rect)
|
||||
case .straight:
|
||||
path = Path.linePathWithPoints(data: data, in: rect)
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct LineShape_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
LineShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)])
|
||||
.stroke()
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
|
||||
LineShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)], lineStyle: .straight)
|
||||
.stroke()
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LineShapeView: View, Animatable {
|
||||
var chartData: ChartData
|
||||
var chartProperties: LineChartProperties
|
||||
|
||||
var geometry: GeometryProxy
|
||||
var style: ChartStyle
|
||||
var trimTo: Double = 0
|
||||
|
||||
var animatableData: CGFloat {
|
||||
get { CGFloat(trimTo) }
|
||||
set { trimTo = Double(newValue) }
|
||||
}
|
||||
|
||||
var chartMarkColor: LinearGradient {
|
||||
if let customColor = chartProperties.customChartMarksColors {
|
||||
return customColor.linearGradient(from: .leading, to: .trailing)
|
||||
}
|
||||
|
||||
return LinearGradient(gradient: style.foregroundColor.first?.gradient ?? ColorGradient.orangeBright.gradient,
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LineShape(data: chartData.normalisedData, lineStyle: chartProperties.lineStyle)
|
||||
.trim(from: 0, to: CGFloat(trimTo))
|
||||
.stroke(LinearGradient(gradient: style.foregroundColor.first?.gradient ?? ColorGradient.orangeBright.gradient,
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing),
|
||||
style: StrokeStyle(lineWidth: chartProperties.lineWidth, lineJoin: .round))
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
.clipped()
|
||||
if chartProperties.showChartMarks {
|
||||
MarkerShape(data: chartData.normalisedData)
|
||||
.trim(from: 0, to: CGFloat(trimTo))
|
||||
.fill(.white,
|
||||
strokeBorder: chartMarkColor,
|
||||
lineWidth: chartProperties.lineWidth)
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LineShapeView_Previews: PreviewProvider {
|
||||
static let chartData = ChartData([6, 8, 6], rangeY: 6...10)
|
||||
static let chartDataOutOfRange = ChartData([-1, 8, 6, 12, 3], rangeY: -5...15)
|
||||
|
||||
static let chartDataOutOfRange2 = ChartData([6,6,8,5], rangeY: 5...10)
|
||||
|
||||
static let chartStyle = ChartStyle(backgroundColor: Color.white,
|
||||
foregroundColor: [ColorGradient(Color.orange, Color.red)])
|
||||
static var previews: some View {
|
||||
Group {
|
||||
GeometryReader { geometry in
|
||||
LineShapeView(chartData: chartData,
|
||||
chartProperties: LineChartProperties(),
|
||||
geometry: geometry,
|
||||
style: chartStyle,
|
||||
trimTo: 1.0)
|
||||
}
|
||||
GeometryReader { geometry in
|
||||
LineShapeView(chartData: chartDataOutOfRange,
|
||||
chartProperties: LineChartProperties(),
|
||||
geometry: geometry,
|
||||
style: chartStyle,
|
||||
trimTo: 1.0)
|
||||
}
|
||||
GeometryReader { geometry in
|
||||
LineShapeView(chartData: chartDataOutOfRange2,
|
||||
chartProperties: LineChartProperties(),
|
||||
geometry: geometry,
|
||||
style: chartStyle,
|
||||
trimTo: 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MarkerShape: Shape {
|
||||
var data: [(Double, Double)]
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = Path.drawChartMarkers(data: data, in: rect)
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct MarkerShape_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
MarkerShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)])
|
||||
.stroke()
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
|
||||
MarkerShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)])
|
||||
.stroke()
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
public class LineChartProperties: ObservableObject {
|
||||
@Published var lineWidth: CGFloat = 2.0
|
||||
@Published var backgroundGradient: ColorGradient?
|
||||
@Published var showChartMarks: Bool = true
|
||||
@Published var customChartMarksColors: ColorGradient?
|
||||
@Published var lineStyle: LineStyle = .curved
|
||||
|
||||
public init() {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public enum LineStyle {
|
||||
case curved
|
||||
case straight
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A type of chart that displays a slice of "pie" for each data point
|
||||
public struct PieChart: ChartBase {
|
||||
public var chartData = ChartData()
|
||||
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
/// The content and behavior of the `PieChart`.
|
||||
///
|
||||
///
|
||||
public var body: some View {
|
||||
PieChartRow(chartData: chartData, style: style)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import SwiftUI
|
||||
|
||||
/// One slice of a `PieChartRow`
|
||||
struct PieSlice: Identifiable {
|
||||
var id = UUID()
|
||||
var startDeg: Double
|
||||
var endDeg: Double
|
||||
var value: Double
|
||||
}
|
||||
|
||||
/// A single row of data, a view in a `PieChart`
|
||||
public struct PieChartCell: View {
|
||||
@State private var show: Bool = false
|
||||
var rect: CGRect
|
||||
var radius: CGFloat {
|
||||
return min(rect.width, rect.height)/2
|
||||
}
|
||||
var startDeg: Double
|
||||
var endDeg: Double
|
||||
|
||||
/// Path representing this slice
|
||||
var path: Path {
|
||||
var path = Path()
|
||||
path.addArc(
|
||||
center: rect.mid,
|
||||
radius: self.radius,
|
||||
startAngle: Angle(degrees: self.startDeg),
|
||||
endAngle: Angle(degrees: self.endDeg),
|
||||
clockwise: false)
|
||||
path.addLine(to: rect.mid)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
var index: Int
|
||||
|
||||
// Section line border color
|
||||
var backgroundColor: Color
|
||||
|
||||
// Section color
|
||||
var accentColor: ColorGradient
|
||||
|
||||
/// The content and behavior of the `PieChartCell`.
|
||||
///
|
||||
/// Fills and strokes with 2-pixel line (unless start/end degrees not yet set). Animates by scaling up to 100% when first appears.
|
||||
public var body: some View {
|
||||
Group {
|
||||
path
|
||||
.fill(self.accentColor.linearGradient(from: .bottom, to: .top))
|
||||
.overlay(path.stroke(self.backgroundColor, lineWidth: (startDeg == 0 && endDeg == 0 ? 0 : 2)))
|
||||
.scaleEffect(self.show ? 1 : 0)
|
||||
.animation(Animation.spring().delay(Double(self.index) * 0.04))
|
||||
.onAppear {
|
||||
self.show = true
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PieChartCell_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
|
||||
GeometryReader { geometry in
|
||||
PieChartCell(
|
||||
rect: geometry.frame(in: .local),
|
||||
startDeg: 00.0,
|
||||
endDeg: 90.0,
|
||||
index: 0,
|
||||
backgroundColor: Color.red,
|
||||
accentColor: ColorGradient.greenRed)
|
||||
}.frame(width: 100, height: 100)
|
||||
|
||||
GeometryReader { geometry in
|
||||
PieChartCell(
|
||||
rect: geometry.frame(in: .local),
|
||||
startDeg: 0.0,
|
||||
endDeg: 90.0,
|
||||
index: 0,
|
||||
backgroundColor: Color.green,
|
||||
accentColor: ColorGradient.redBlack)
|
||||
}.frame(width: 100, height: 100)
|
||||
|
||||
GeometryReader { geometry in
|
||||
PieChartCell(
|
||||
rect: geometry.frame(in: .local),
|
||||
startDeg: 100.0,
|
||||
endDeg: 135.0,
|
||||
index: 0,
|
||||
backgroundColor: Color.black,
|
||||
accentColor: ColorGradient.whiteBlack)
|
||||
}.frame(width: 100, height: 100)
|
||||
|
||||
GeometryReader { geometry in
|
||||
PieChartCell(
|
||||
rect: geometry.frame(in: .local),
|
||||
startDeg: 185.0,
|
||||
endDeg: 290.0,
|
||||
index: 1,
|
||||
backgroundColor: Color.purple,
|
||||
accentColor: ColorGradient(.purple))
|
||||
}.frame(width: 100, height: 100)
|
||||
|
||||
GeometryReader { geometry in
|
||||
PieChartCell(
|
||||
rect: geometry.frame(in: .local),
|
||||
startDeg: 0,
|
||||
endDeg: 0,
|
||||
index: 0,
|
||||
backgroundColor: Color.purple,
|
||||
accentColor: ColorGradient(.purple))
|
||||
}.frame(width: 100, height: 100)
|
||||
|
||||
}.previewLayout(.fixed(width: 125, height: 125))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import SwiftUI
|
||||
|
||||
func isPointInCircle(point: CGPoint, circleRect: CGRect) -> Bool {
|
||||
let r = min(circleRect.width, circleRect.height) / 2
|
||||
let center = CGPoint(x: circleRect.midX, y: circleRect.midY)
|
||||
let dx = point.x - center.x
|
||||
let dy = point.y - center.y
|
||||
let distance = sqrt(dx * dx + dy * dy)
|
||||
return distance <= r
|
||||
}
|
||||
|
||||
func degree(for point: CGPoint, inCircleRect circleRect: CGRect) -> Double {
|
||||
let center = CGPoint(x: circleRect.midX, y: circleRect.midY)
|
||||
let dx = point.x - center.x
|
||||
let dy = point.y - center.y
|
||||
let acuteDegree = Double(atan(dy / dx)) * (180 / .pi)
|
||||
|
||||
let isInBottomRight = dx >= 0 && dy >= 0
|
||||
let isInBottomLeft = dx <= 0 && dy >= 0
|
||||
let isInTopLeft = dx <= 0 && dy <= 0
|
||||
let isInTopRight = dx >= 0 && dy <= 0
|
||||
|
||||
if isInBottomRight {
|
||||
return acuteDegree
|
||||
} else if isInBottomLeft {
|
||||
return 180 - abs(acuteDegree)
|
||||
} else if isInTopLeft {
|
||||
return 180 + abs(acuteDegree)
|
||||
} else if isInTopRight {
|
||||
return 360 - abs(acuteDegree)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A single "row" (slice) of data, a view in a `PieChart`
|
||||
public struct PieChartRow: View {
|
||||
@ObservedObject var chartData: ChartData
|
||||
@EnvironmentObject var chartValue: ChartValue
|
||||
|
||||
var style: ChartStyle
|
||||
|
||||
var slices: [PieSlice] {
|
||||
var tempSlices: [PieSlice] = []
|
||||
var lastEndDeg: Double = 0
|
||||
let maxValue: Double = chartData.points.reduce(0, +)
|
||||
|
||||
for slice in chartData.points {
|
||||
let normalized: Double = Double(slice) / (maxValue == 0 ? 1 : maxValue)
|
||||
let startDeg = lastEndDeg
|
||||
let endDeg = lastEndDeg + (normalized * 360)
|
||||
lastEndDeg = endDeg
|
||||
tempSlices.append(PieSlice(startDeg: startDeg, endDeg: endDeg, value: slice))
|
||||
}
|
||||
|
||||
return tempSlices
|
||||
}
|
||||
|
||||
@State private var currentTouchedIndex = -1 {
|
||||
didSet {
|
||||
if oldValue != currentTouchedIndex {
|
||||
chartValue.interactionInProgress = currentTouchedIndex != -1
|
||||
guard currentTouchedIndex != -1 else { return }
|
||||
chartValue.currentValue = slices[currentTouchedIndex].value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
ForEach(0..<self.slices.count) { index in
|
||||
PieChartCell(
|
||||
rect: geometry.frame(in: .local),
|
||||
startDeg: self.slices[index].startDeg,
|
||||
endDeg: self.slices[index].endDeg,
|
||||
index: index,
|
||||
backgroundColor: self.style.backgroundColor.startColor,
|
||||
accentColor: self.style.foregroundColor.rotate(for: index)
|
||||
)
|
||||
.scaleEffect(currentTouchedIndex == index ? 1.1 : 1)
|
||||
.animation(Animation.spring())
|
||||
}
|
||||
}
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
let rect = geometry.frame(in: .local)
|
||||
let isTouchInPie = isPointInCircle(point: value.location, circleRect: rect)
|
||||
if isTouchInPie {
|
||||
let touchDegree = degree(for: value.location, inCircleRect: rect)
|
||||
currentTouchedIndex = slices.firstIndex(where: { $0.startDeg < touchDegree && $0.endDeg > touchDegree }) ?? -1
|
||||
} else {
|
||||
currentTouchedIndex = -1
|
||||
}
|
||||
})
|
||||
.onEnded({ value in
|
||||
currentTouchedIndex = -1
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//
|
||||
// Ring.swift
|
||||
// ChartViewV2Demo
|
||||
//
|
||||
// Created by Dan Wood on 8/20/20.
|
||||
// Based on article and playground code by Frank Jia
|
||||
// https://medium.com/@frankjia/creating-activity-rings-in-swiftui-11ef7d336676
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
extension Double {
|
||||
func toRadians() -> Double {
|
||||
return self * Double.pi / 180
|
||||
}
|
||||
func toCGFloat() -> CGFloat {
|
||||
return CGFloat(self)
|
||||
}
|
||||
}
|
||||
|
||||
struct RingShape: Shape {
|
||||
/// Helper function to convert percent values to angles in degrees
|
||||
/// - Parameters:
|
||||
/// - percent: percent, greater than 100 is OK
|
||||
/// - startAngle: angle to add after converting
|
||||
/// - Returns: angle in degrees
|
||||
static func percentToAngle(percent: Double, startAngle: Double) -> Double {
|
||||
(percent / 100 * 360) + startAngle
|
||||
}
|
||||
private var percent: Double
|
||||
private var startAngle: Double
|
||||
private let drawnClockwise: Bool
|
||||
|
||||
// This allows animations to run smoothly for percent values
|
||||
var animatableData: Double {
|
||||
get {
|
||||
return percent
|
||||
}
|
||||
set {
|
||||
percent = newValue
|
||||
}
|
||||
}
|
||||
|
||||
init(percent: Double = 100, startAngle: Double = -90, drawnClockwise: Bool = false) {
|
||||
self.percent = percent
|
||||
self.startAngle = startAngle
|
||||
self.drawnClockwise = drawnClockwise
|
||||
}
|
||||
|
||||
/// This draws a simple arc from the start angle to the end angle
|
||||
///
|
||||
/// - Parameter rect: The frame of reference for describing this shape.
|
||||
/// - Returns: A path that describes this shape.
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let width = rect.width
|
||||
let height = rect.height
|
||||
let radius = min(width, height) / 2
|
||||
let center = CGPoint(x: width / 2, y: height / 2)
|
||||
let endAngle = Angle(degrees: RingShape.percentToAngle(percent: self.percent, startAngle: self.startAngle))
|
||||
return Path { path in
|
||||
path.addArc(center: center, radius: radius, startAngle: Angle(degrees: startAngle), endAngle: endAngle, clockwise: drawnClockwise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Ring: View {
|
||||
|
||||
private static let ShadowColor: Color = Color.black.opacity(0.2)
|
||||
private static let ShadowRadius: CGFloat = 5
|
||||
private static let ShadowOffsetMultiplier: CGFloat = ShadowRadius + 2
|
||||
|
||||
private let ringWidth: CGFloat
|
||||
private let percent: Double
|
||||
private let foregroundColor: ColorGradient
|
||||
private let startAngle: Double = -90
|
||||
|
||||
private let touchLocation: CGFloat
|
||||
|
||||
|
||||
|
||||
private var gradientStartAngle: Double {
|
||||
self.percent >= 100 ? relativePercentageAngle - 360 : startAngle
|
||||
}
|
||||
private var absolutePercentageAngle: Double {
|
||||
RingShape.percentToAngle(percent: self.percent, startAngle: 0)
|
||||
}
|
||||
private var relativePercentageAngle: Double {
|
||||
// Take into account the startAngle
|
||||
absolutePercentageAngle + startAngle
|
||||
}
|
||||
private var lastGradientColor: Color {
|
||||
self.foregroundColor.endColor
|
||||
}
|
||||
|
||||
private var ringGradient: AngularGradient {
|
||||
AngularGradient(
|
||||
gradient: self.foregroundColor.gradient,
|
||||
center: .center,
|
||||
startAngle: Angle(degrees: self.gradientStartAngle),
|
||||
endAngle: Angle(degrees: relativePercentageAngle)
|
||||
)
|
||||
}
|
||||
|
||||
init(ringWidth: CGFloat, percent: Double, foregroundColor: ColorGradient, touchLocation:CGFloat) {
|
||||
self.ringWidth = ringWidth
|
||||
self.percent = percent
|
||||
self.foregroundColor = foregroundColor
|
||||
self.touchLocation = touchLocation
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Background for the ring. Use the final color with reduced opacity
|
||||
RingShape()
|
||||
.stroke(style: StrokeStyle(lineWidth: self.ringWidth))
|
||||
.fill(lastGradientColor.opacity(0.142857))
|
||||
// Foreground
|
||||
RingShape(percent: self.percent, startAngle: self.startAngle)
|
||||
.stroke(style: StrokeStyle(lineWidth: self.ringWidth, lineCap: .round))
|
||||
.fill(self.ringGradient)
|
||||
// End of ring with drop shadow
|
||||
if self.getShowShadow(frame: geometry.size) {
|
||||
Circle()
|
||||
.fill(self.lastGradientColor)
|
||||
.frame(width: self.ringWidth, height: self.ringWidth, alignment: .center)
|
||||
.offset(x: self.getEndCircleLocation(frame: geometry.size).0,
|
||||
y: self.getEndCircleLocation(frame: geometry.size).1)
|
||||
.shadow(color: Ring.ShadowColor,
|
||||
radius: Ring.ShadowRadius,
|
||||
x: self.getEndCircleShadowOffset().0,
|
||||
y: self.getEndCircleShadowOffset().1)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Padding to ensure that the entire ring fits within the view size allocated
|
||||
.padding(self.ringWidth / 2)
|
||||
}
|
||||
|
||||
private func getEndCircleLocation(frame: CGSize) -> (CGFloat, CGFloat) {
|
||||
// Get angle of the end circle with respect to the start angle
|
||||
let angleOfEndInRadians: Double = relativePercentageAngle.toRadians()
|
||||
let offsetRadius = min(frame.width, frame.height) / 2
|
||||
return (offsetRadius * cos(angleOfEndInRadians).toCGFloat(), offsetRadius * sin(angleOfEndInRadians).toCGFloat())
|
||||
}
|
||||
|
||||
private func getEndCircleShadowOffset() -> (CGFloat, CGFloat) {
|
||||
let angleForOffset = absolutePercentageAngle + (self.startAngle + 90)
|
||||
let angleForOffsetInRadians = angleForOffset.toRadians()
|
||||
let relativeXOffset = cos(angleForOffsetInRadians)
|
||||
let relativeYOffset = sin(angleForOffsetInRadians)
|
||||
let xOffset = relativeXOffset.toCGFloat() * Ring.ShadowOffsetMultiplier
|
||||
let yOffset = relativeYOffset.toCGFloat() * Ring.ShadowOffsetMultiplier
|
||||
return (xOffset, yOffset)
|
||||
}
|
||||
|
||||
private func getShowShadow(frame: CGSize) -> Bool {
|
||||
if self.percent >= 100 {
|
||||
return true
|
||||
}
|
||||
let circleRadius = min(frame.width, frame.height) / 2
|
||||
let remainingAngleInRadians = (360 - absolutePercentageAngle).toRadians().toCGFloat()
|
||||
|
||||
return circleRadius * remainingAngleInRadians <= self.ringWidth
|
||||
}
|
||||
}
|
||||
|
||||
struct Ring_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
Ring(
|
||||
ringWidth: 50, percent: 5 ,
|
||||
foregroundColor: ColorGradient(.green, .blue), touchLocation: -1.0
|
||||
)
|
||||
.frame(width: 200, height: 200)
|
||||
|
||||
Ring(
|
||||
ringWidth: 20, percent: 110 ,
|
||||
foregroundColor: ColorGradient(.red, .blue), touchLocation: -1.0
|
||||
)
|
||||
.frame(width: 200, height: 200)
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct RingsChart: ChartBase {
|
||||
public var chartData = ChartData()
|
||||
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
// TODO - should put background opacity, ring width & spacing as chart style values
|
||||
|
||||
public var body: some View {
|
||||
RingsChartRow(width:10.0, spacing:5.0, chartData: chartData, style: style)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//
|
||||
// RingsChartRow.swift
|
||||
// ChartViewV2Demo
|
||||
//
|
||||
// Created by Dan Wood on 8/20/20.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct RingsChartRow: View {
|
||||
|
||||
var width : CGFloat
|
||||
var spacing : CGFloat
|
||||
|
||||
@EnvironmentObject var chartValue: ChartValue
|
||||
@ObservedObject var chartData: ChartData
|
||||
@State var touchRadius: CGFloat = -1.0
|
||||
|
||||
var style: ChartStyle
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
|
||||
ZStack {
|
||||
|
||||
// FIXME: Why is background circle offset strangely when frame isn't specified? See Preview below. Related to the .animation somehow ????
|
||||
Circle()
|
||||
.fill(RadialGradient(gradient: self.style.backgroundColor.gradient, center: .center, startRadius: min(geometry.size.width, geometry.size.height)/2.0, endRadius: 1.0))
|
||||
|
||||
ForEach(0..<self.chartData.data.count, id: \.self) { index in
|
||||
|
||||
let scaleUp = isRingScaled(size:geometry.size, touchRadius: self.touchRadius, index: index)
|
||||
let scaledWidth = scaleUp ? self.width * 2.0 : self.width
|
||||
|
||||
let normalPadding = (width + spacing) * CGFloat(index)
|
||||
let scaledDiff = (scaledWidth - width) / 2.0
|
||||
let padding = min(normalPadding - scaledDiff,
|
||||
min(geometry.size.width, geometry.size.height)/2.0 - width
|
||||
// make sure it doesn't get to crazy value
|
||||
)
|
||||
|
||||
Ring(ringWidth:scaledWidth, percent: self.chartData.points[index], foregroundColor:self.style.foregroundColor.rotate(for: index),
|
||||
touchLocation: self.touchRadius)
|
||||
|
||||
|
||||
.zIndex(scaleUp ? 1 : 0) // make sure zoomed one is on top
|
||||
.padding(padding)
|
||||
|
||||
.animation(Animation.easeIn(duration: 0.5))
|
||||
}
|
||||
// .drawingGroup()
|
||||
}
|
||||
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
let frame = geometry.frame(in: .local)
|
||||
let radius = min(frame.width, frame.height) / 2.0
|
||||
let deltaX = value.location.x - frame.midX
|
||||
let deltaY = value.location.y - frame.midY
|
||||
self.touchRadius = sqrt(deltaX*deltaX + deltaY*deltaY) // Pythagorean equation
|
||||
|
||||
if let currentValue = self.getCurrentValue(maxRadius: radius) {
|
||||
self.chartValue.currentValue = currentValue
|
||||
self.chartValue.interactionInProgress = true
|
||||
}
|
||||
})
|
||||
.onEnded({ value in
|
||||
self.chartValue.interactionInProgress = false
|
||||
self.touchRadius = -1
|
||||
})
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/// should we scale up the touched ring?
|
||||
/// - Parameters:
|
||||
/// - size: size of the view
|
||||
/// - touchRadius: distance from center where touched
|
||||
/// - index: which ring is being drawn
|
||||
/// - Returns: size to scale up or just scale of 1 if not scaled up
|
||||
func isRingScaled(size: CGSize, touchRadius: CGFloat, index: Int) -> Bool {
|
||||
let radius = min(size.width, size.height) / 2.0
|
||||
return index == self.touchedCircleIndex(maxRadius: radius)
|
||||
}
|
||||
|
||||
/// Find which circle has been touched
|
||||
/// - Parameter maxRadius: radius of overall view circle
|
||||
/// - Returns: which circle index was touched, if found. 0 = outer, 1 = next one in, etc.
|
||||
func touchedCircleIndex(maxRadius: CGFloat) -> Int? {
|
||||
guard self.chartData.data.count > 0 else { return nil } // no data
|
||||
|
||||
// Pretend actual circle goes ½ the inter-ring spacing out, so that a touch
|
||||
// is registered on either side of each ring
|
||||
let radialDistanceFromEdge = (maxRadius + spacing/2) - self.touchRadius;
|
||||
guard radialDistanceFromEdge >= 0 else { return nil } // touched outside of ring
|
||||
|
||||
let touchIndex = Int(floor(radialDistanceFromEdge / (width + spacing)))
|
||||
|
||||
if touchIndex >= self.chartData.data.count { return nil } // too far from outside, no ring
|
||||
|
||||
return touchIndex
|
||||
}
|
||||
|
||||
/// Description
|
||||
/// - Parameter maxRadius: radius of overall view circle
|
||||
/// - Returns: percentage value of the touched circle, based on `touchRadius` if found
|
||||
func getCurrentValue(maxRadius: CGFloat) -> Double? {
|
||||
|
||||
guard let index = self.touchedCircleIndex(maxRadius: maxRadius) else { return nil }
|
||||
return self.chartData.points[index]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct RingsChartRow_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
let multiStyle = ChartStyle(backgroundColor: ColorGradient(Color.black.opacity(0.05), Color.white),
|
||||
foregroundColor:
|
||||
[ColorGradient(.purple, .blue),
|
||||
ColorGradient(.orange, .red),
|
||||
ColorGradient(.green, .yellow),
|
||||
])
|
||||
|
||||
return RingsChartRow(width:20.0, spacing:10.0, chartData: ChartData([25,50,75,100,125]), style: multiStyle)
|
||||
|
||||
// and why does this not get centered when frame isn't specified?
|
||||
.frame(width:300, height:400)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by András Samu on 2019. 07. 19..
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public struct Colors {
|
||||
public static let color1:Color = Color(hexString: "#E2FAE7")
|
||||
public static let color1Accent:Color = Color(hexString: "#72BF82")
|
||||
public static let color2:Color = Color(hexString: "#EEF1FF")
|
||||
public static let color2Accent:Color = Color(hexString: "#4266E8")
|
||||
public static let color3:Color = Color(hexString: "#FCECEA")
|
||||
public static let color3Accent:Color = Color(hexString: "#E1614C")
|
||||
public static let OrangeStart:Color = Color(hexString: "#FF782C")
|
||||
public static let OrangeEnd:Color = Color(hexString: "#EC2301")
|
||||
public static let LegendText:Color = Color(hexString: "#A7A6A8")
|
||||
public static let LegendColor:Color = Color(hexString: "#E8E7EA")
|
||||
public static let IndicatorKnob:Color = Color(hexString: "#FF57A6")
|
||||
public static let GradientUpperBlue:Color = Color(hexString: "#C2E8FF")
|
||||
public static let GradinetUpperBlue1:Color = Color(hexString: "#A8E1FF")
|
||||
public static let GradientPurple:Color = Color(hexString: "#7B75FF")
|
||||
public static let GradientNeonBlue:Color = Color(hexString: "#6FEAFF")
|
||||
public static let GradientLowerBlue:Color = Color(hexString: "#F1F9FF")
|
||||
public static let DarkPurple:Color = Color(hexString: "#1B205E")
|
||||
public static let BorderBlue:Color = Color(hexString: "#4EBCFF")
|
||||
|
||||
|
||||
}
|
||||
|
||||
public struct Styles {
|
||||
public static let lineChartStyleOne = ChartStyle(
|
||||
backgroundColor: Color.white,
|
||||
accentColor: Colors.OrangeStart,
|
||||
secondGradientColor: Colors.OrangeEnd,
|
||||
chartFormSize: Form.medium,
|
||||
textColor: Color.black,
|
||||
legendTextColor: Color.gray)
|
||||
|
||||
public static let barChartStyleOne = ChartStyle(
|
||||
backgroundColor: Color.white,
|
||||
accentColor: Colors.OrangeStart,
|
||||
secondGradientColor: Colors.OrangeEnd,
|
||||
chartFormSize: Form.medium,
|
||||
textColor: Color.black,
|
||||
legendTextColor: Color.gray)
|
||||
|
||||
public static let barChartStyleTwo = ChartStyle(
|
||||
backgroundColor: Color.white,
|
||||
accentColor: Colors.GradientNeonBlue,
|
||||
secondGradientColor: Colors.GradientPurple,
|
||||
chartFormSize: Form.medium,
|
||||
textColor: Color.black,
|
||||
legendTextColor: Color.gray)
|
||||
|
||||
public static let barChartStyleThree = ChartStyle(
|
||||
backgroundColor: Color.black,
|
||||
accentColor: Colors.GradientNeonBlue,
|
||||
secondGradientColor: Colors.GradientPurple,
|
||||
chartFormSize: Form.medium,
|
||||
textColor: Color.white,
|
||||
legendTextColor: Color.gray)
|
||||
|
||||
public static let pieChartStyleOne = ChartStyle(
|
||||
backgroundColor: Color.white,
|
||||
accentColor: Colors.OrangeStart,
|
||||
secondGradientColor: Colors.OrangeEnd,
|
||||
chartFormSize: Form.medium,
|
||||
textColor: Color.black,
|
||||
legendTextColor: Color.gray)
|
||||
}
|
||||
|
||||
public struct Form {
|
||||
public static let small = CGSize(width:180, height:120)
|
||||
public static let medium = CGSize(width:180, height:240)
|
||||
public static let large = CGSize(width:360, height:120)
|
||||
public static let detail = CGSize(width:180, height:120)
|
||||
}
|
||||
|
||||
public struct ChartStyle {
|
||||
public var backgroundColor: Color
|
||||
public var accentColor: Color
|
||||
public var secondGradientColor: Color
|
||||
public var chartFormSize: CGSize
|
||||
public var textColor: Color
|
||||
public var legendTextColor: Color
|
||||
|
||||
public init(backgroundColor: Color, accentColor: Color, secondGradientColor: Color, chartFormSize: CGSize, textColor: Color, legendTextColor: Color){
|
||||
self.backgroundColor = backgroundColor
|
||||
self.accentColor = accentColor
|
||||
self.secondGradientColor = secondGradientColor
|
||||
self.chartFormSize = chartFormSize
|
||||
self.textColor = textColor
|
||||
self.legendTextColor = legendTextColor
|
||||
}
|
||||
|
||||
public init(formSize: CGSize){
|
||||
self.backgroundColor = Color.white
|
||||
self.accentColor = Colors.OrangeStart
|
||||
self.secondGradientColor = Colors.OrangeEnd
|
||||
self.chartFormSize = formSize
|
||||
self.legendTextColor = Color.gray
|
||||
self.textColor = Color.black
|
||||
}
|
||||
}
|
||||
|
||||
class ChartData: ObservableObject {
|
||||
@Published var points: [Int] = [Int]()
|
||||
@Published var currentPoint: Int? = nil
|
||||
|
||||
init(points:[Int]) {
|
||||
self.points = points
|
||||
}
|
||||
}
|
||||
|
||||
class TestData{
|
||||
static public var data:ChartData = ChartData(points: [37,72,51,22,39,47,66,85,50])
|
||||
}
|
||||
|
||||
extension Color {
|
||||
init(hexString: String) {
|
||||
let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int = UInt32()
|
||||
Scanner(string: hex).scanHexInt32(&int)
|
||||
let r, g, b: UInt32
|
||||
switch hex.count {
|
||||
case 3: // RGB (12-bit)
|
||||
(r, g, b) = ((int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||
case 6: // RGB (24-bit)
|
||||
(r, g, b) = (int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8: // ARGB (32-bit)
|
||||
(r, g, b) = (int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(r, g, b) = (0, 0, 0)
|
||||
}
|
||||
self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255)
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
//
|
||||
// Legend.swift
|
||||
// LineChart
|
||||
//
|
||||
// Created by András Samu on 2019. 09. 02..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct Legend: View {
|
||||
@ObservedObject var data: ChartData
|
||||
@Binding var frame: CGRect
|
||||
@Binding var hideHorizontalLines: Bool
|
||||
|
||||
var stepWidth: CGFloat {
|
||||
return frame.size.width / CGFloat(data.points.count-1)
|
||||
}
|
||||
var stepHeight: CGFloat {
|
||||
return frame.size.height / CGFloat(data.points.max()! + data.points.min()!)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading){
|
||||
ForEach((0...4), id: \.self) { height in
|
||||
HStack(alignment: .center){
|
||||
Text("\(self.getYLegend()![height])").offset(x: 0, y: (self.frame.height-CGFloat(self.getYLegend()![height])*self.stepHeight)-(self.frame.height/2))
|
||||
.foregroundColor(Colors.LegendText)
|
||||
.font(.caption)
|
||||
self.line(atHeight: CGFloat(self.getYLegend()![height]), width: self.frame.width)
|
||||
.stroke(Colors.LegendColor, style: StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [5,height == 0 ? 0 : 10]))
|
||||
.opacity((self.hideHorizontalLines && height != 0) ? 0 : 1)
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
.animation(.easeOut(duration: 0.2))
|
||||
.clipped()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func line(atHeight: CGFloat, width: CGFloat) -> Path {
|
||||
var hLine = Path()
|
||||
hLine.move(to: CGPoint(x:5, y: atHeight*stepHeight))
|
||||
hLine.addLine(to: CGPoint(x: width, y: atHeight*stepHeight))
|
||||
return hLine
|
||||
}
|
||||
|
||||
func getYLegend() -> [Int]? {
|
||||
guard let max = data.points.max() else { return nil }
|
||||
guard let min = data.points.min() else { return nil }
|
||||
if(min > 0){
|
||||
let upperBound = ((max/10)+1) * 10
|
||||
let step = upperBound/4
|
||||
return [step * 0,step * 1, step * 2, step * 3, step * 4]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct Legend_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GeometryReader{ geometry in
|
||||
Legend(data: TestData.data, frame: .constant(geometry.frame(in: .local)), hideHorizontalLines: .constant(false))
|
||||
}.frame(width: 320, height: 200)
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
//
|
||||
// Line.swift
|
||||
// LineChart
|
||||
//
|
||||
// Created by András Samu on 2019. 08. 30..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct Line: View {
|
||||
@ObservedObject var data: ChartData
|
||||
@Binding var frame: CGRect
|
||||
@Binding var touchLocation: CGPoint
|
||||
@Binding var showIndicator: Bool
|
||||
@State private var showFull: Bool = false
|
||||
@State var showBackground: Bool = true
|
||||
|
||||
var stepWidth: CGFloat {
|
||||
return frame.size.width / CGFloat(data.points.count-1)
|
||||
}
|
||||
var stepHeight: CGFloat {
|
||||
return frame.size.height / CGFloat(data.points.max()! + data.points.min()!)
|
||||
}
|
||||
var path: Path {
|
||||
return Path.quadCurvedPathWithPoints(points: data.points, step: CGPoint(x: stepWidth, y: stepHeight))
|
||||
}
|
||||
var closedPath: Path {
|
||||
return Path.quadClosedCurvedPathWithPoints(points: data.points, step: CGPoint(x: stepWidth, y: stepHeight))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if(self.showFull && self.showBackground){
|
||||
self.closedPath
|
||||
.fill(LinearGradient(gradient: Gradient(colors: [Colors.GradientUpperBlue, .white]), startPoint: .bottom, endPoint: .top))
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
.transition(.opacity)
|
||||
.animation(.easeIn(duration: 1.6))
|
||||
}
|
||||
self.path
|
||||
.trim(from: 0, to: self.showFull ? 1:0)
|
||||
.stroke(LinearGradient(gradient: Gradient(colors: [Colors.GradientPurple, Colors.GradientNeonBlue]), startPoint: .leading, endPoint: .trailing) ,style: StrokeStyle(lineWidth: 3))
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
.animation(.easeOut(duration: 1.2))
|
||||
.onAppear(){
|
||||
self.showFull.toggle()
|
||||
}.drawingGroup()
|
||||
if(self.showIndicator) {
|
||||
IndicatorPoint()
|
||||
.position(self.getClosestPointOnPath(touchLocation: self.touchLocation))
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getClosestPointOnPath(touchLocation: CGPoint) -> CGPoint {
|
||||
let percentage:CGFloat = min(max(touchLocation.x,0)/self.frame.width,1)
|
||||
let closest = self.path.percentPoint(percentage)
|
||||
return closest
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension CGPoint {
|
||||
static func getMidPoint(point1: CGPoint, point2: CGPoint) -> CGPoint {
|
||||
return CGPoint(
|
||||
x: point1.x + (point2.x - point1.x) / 2,
|
||||
y: point1.y + (point2.y - point1.y) / 2
|
||||
)
|
||||
}
|
||||
|
||||
func dist(to: CGPoint) -> CGFloat {
|
||||
return sqrt((pow(self.x - to.x, 2) + pow(self.y - to.y, 2)))
|
||||
}
|
||||
|
||||
static func midPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint {
|
||||
return CGPoint(x:(p1.x + p2.x) / 2,y: (p1.y + p2.y) / 2)
|
||||
}
|
||||
|
||||
static func controlPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint {
|
||||
var controlPoint = CGPoint.midPointForPoints(p1:p1, p2:p2)
|
||||
let diffY = abs(p2.y - controlPoint.y)
|
||||
|
||||
if (p1.y < p2.y){
|
||||
controlPoint.y += diffY
|
||||
} else if (p1.y > p2.y) {
|
||||
controlPoint.y -= diffY
|
||||
}
|
||||
return controlPoint
|
||||
}
|
||||
}
|
||||
extension Path {
|
||||
static func quadCurvedPathWithPoints(points:[Int], step:CGPoint) -> Path {
|
||||
var path = Path()
|
||||
var p1 = CGPoint(x: 0, y: CGFloat(points[0])*step.y)
|
||||
path.move(to: p1)
|
||||
if(points.count < 2){
|
||||
path.addLine(to: CGPoint(x: step.x, y: step.y*CGFloat(points[1])))
|
||||
return path
|
||||
}
|
||||
for pointIndex in 1..<points.count {
|
||||
let p2 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]))
|
||||
let midPoint = CGPoint.midPointForPoints(p1: p1, p2: p2)
|
||||
path.addQuadCurve(to: midPoint, control: CGPoint.controlPointForPoints(p1: midPoint, p2: p1))
|
||||
path.addQuadCurve(to: p2, control: CGPoint.controlPointForPoints(p1: midPoint, p2: p2))
|
||||
p1 = p2
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
static func quadClosedCurvedPathWithPoints(points:[Int], step:CGPoint) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: .zero)
|
||||
var p1 = CGPoint(x: 0, y: CGFloat(points[0])*step.y)
|
||||
path.addLine(to: p1)
|
||||
if(points.count < 2){
|
||||
path.addLine(to: CGPoint(x: step.x, y: step.y*CGFloat(points[1])))
|
||||
return path
|
||||
}
|
||||
for pointIndex in 1..<points.count {
|
||||
let p2 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]))
|
||||
let midPoint = CGPoint.midPointForPoints(p1: p1, p2: p2)
|
||||
path.addQuadCurve(to: midPoint, control: CGPoint.controlPointForPoints(p1: midPoint, p2: p1))
|
||||
path.addQuadCurve(to: p2, control: CGPoint.controlPointForPoints(p1: midPoint, p2: p2))
|
||||
p1 = p2
|
||||
}
|
||||
path.addLine(to: CGPoint(x: p1.x, y: 0))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
|
||||
func percentPoint(_ percent: CGFloat) -> CGPoint {
|
||||
// percent difference between points
|
||||
let diff: CGFloat = 0.001
|
||||
let comp: CGFloat = 1 - diff
|
||||
|
||||
// handle limits
|
||||
let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
|
||||
|
||||
let f = pct > comp ? comp : pct
|
||||
let t = pct > comp ? 1 : pct + diff
|
||||
let tp = self.trimmedPath(from: f, to: t)
|
||||
|
||||
return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct Line_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GeometryReader{ geometry in
|
||||
Line(data: TestData.data, frame: .constant(geometry.frame(in: .local)), touchLocation: .constant(CGPoint(x: 300, y: 12)), showIndicator: .constant(true))
|
||||
}.frame(width: 320, height: 160)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
//
|
||||
// LineCard.swift
|
||||
// LineChart
|
||||
//
|
||||
// Created by András Samu on 2019. 08. 31..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LineChartView: View {
|
||||
let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
|
||||
@ObservedObject var data:ChartData
|
||||
public var title: String
|
||||
public var legend: String?
|
||||
public var style: ChartStyle
|
||||
@State private var touchLocation:CGPoint = .zero
|
||||
@State private var showIndicatorDot: Bool = false
|
||||
@State private var currentValue: Int = 2 {
|
||||
didSet{
|
||||
if (oldValue != self.currentValue && showIndicatorDot) {
|
||||
selectionFeedbackGenerator.selectionChanged()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
let frame = CGSize(width: 180, height: 120)
|
||||
|
||||
public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.lineChartStyleOne ){
|
||||
self.data = ChartData(points: data)
|
||||
self.title = title
|
||||
self.legend = legend
|
||||
self.style = style
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .center){
|
||||
RoundedRectangle(cornerRadius: 20).fill(self.style.backgroundColor).frame(width: frame.width, height: 240, alignment: .center).shadow(radius: 8)
|
||||
VStack(alignment: .leading){
|
||||
if(!self.showIndicatorDot){
|
||||
VStack(alignment: .leading, spacing: 8){
|
||||
Text(self.title).font(.title).bold().foregroundColor(self.style.textColor)
|
||||
if (self.legend != nil){
|
||||
Text(self.legend!).font(.callout).foregroundColor(self.style.legendTextColor)
|
||||
}
|
||||
HStack {
|
||||
Image(systemName: "arrow.up")
|
||||
Text("14%")
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.animation(.easeIn(duration: 0.1))
|
||||
.padding([.leading, .top])
|
||||
}else{
|
||||
HStack{
|
||||
Spacer()
|
||||
Text("\(self.currentValue)")
|
||||
.font(.system(size: 41, weight: .bold, design: .default))
|
||||
.offset(x: 0, y: 30)
|
||||
Spacer()
|
||||
}
|
||||
.transition(.scale)
|
||||
.animation(.spring())
|
||||
|
||||
}
|
||||
Spacer()
|
||||
GeometryReader{ geometry in
|
||||
Line(data: self.data, frame: .constant(geometry.frame(in: .local)), touchLocation: self.$touchLocation, showIndicator: self.$showIndicatorDot)
|
||||
}
|
||||
.frame(width: frame.width, height: frame.height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.offset(x: 0, y: 0)
|
||||
}.frame(width: self.style.chartFormSize.width, height: self.style.chartFormSize.height)
|
||||
}
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
self.touchLocation = value.location
|
||||
self.showIndicatorDot = true
|
||||
self.getClosestDataPoint(toPoint: value.location, width:self.frame.width, height: self.frame.height)
|
||||
})
|
||||
.onEnded({ value in
|
||||
self.showIndicatorDot = false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
func getClosestDataPoint(toPoint: CGPoint, width:CGFloat, height: CGFloat) -> CGPoint {
|
||||
let stepWidth: CGFloat = width / CGFloat(data.points.count-1)
|
||||
let stepHeight: CGFloat = height / CGFloat(data.points.max()! + data.points.min()!)
|
||||
|
||||
let index:Int = Int(round((toPoint.x)/stepWidth))
|
||||
if (index >= 0 && index < data.points.count){
|
||||
self.currentValue = self.data.points[index]
|
||||
return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(self.data.points[index])*stepHeight)
|
||||
}
|
||||
return .zero
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
LineChartView(data: [8,23,54,32,12,37,7,23,43], title: "Line chart", legend: "Basic")
|
||||
.environment(\.colorScheme, .light)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
//
|
||||
// PieChartCell.swift
|
||||
// ChartView
|
||||
//
|
||||
// Created by András Samu on 2019. 06. 12..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PieSlice: Identifiable {
|
||||
var id = UUID()
|
||||
var startDeg: Double
|
||||
var endDeg: Double
|
||||
var value: Int
|
||||
var normalizedValue: Double
|
||||
}
|
||||
|
||||
public struct PieChartCell : View {
|
||||
@State private var show:Bool = false
|
||||
var rect: CGRect
|
||||
var radius: CGFloat {
|
||||
return min(rect.width, rect.height)/2
|
||||
}
|
||||
var startDeg: Double
|
||||
var endDeg: Double
|
||||
var path: Path {
|
||||
var path = Path()
|
||||
path.addArc(center:rect.mid , radius:self.radius, startAngle: Angle(degrees: self.startDeg), endAngle: Angle(degrees: self.endDeg), clockwise: false)
|
||||
path.addLine(to: rect.mid)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
var index: Int
|
||||
var backgroundColor:Color
|
||||
var accentColor:Color
|
||||
public var body: some View {
|
||||
path
|
||||
.fill()
|
||||
.foregroundColor(self.accentColor)
|
||||
.overlay(path.stroke(self.backgroundColor, lineWidth: 2))
|
||||
.scaleEffect(self.show ? 1 : 0)
|
||||
.animation(Animation.spring().delay(Double(self.index) * 0.04))
|
||||
.onAppear(){
|
||||
self.show = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CGRect {
|
||||
var mid: CGPoint {
|
||||
return CGPoint(x:self.midX, y: self.midY)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct PieChartCell_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
GeometryReader { geometry in
|
||||
PieChartCell(rect: geometry.frame(in: .local),startDeg: 0.0,endDeg: 90.0, index: 0, backgroundColor: Color(red: 252.0/255.0, green: 236.0/255.0, blue: 234.0/255.0), accentColor: Color(red: 225.0/255.0, green: 97.0/255.0, blue: 76.0/255.0))
|
||||
}.frame(width:100, height:100)
|
||||
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,46 +0,0 @@
|
||||
//
|
||||
// PieChartRow.swift
|
||||
// ChartView
|
||||
//
|
||||
// Created by András Samu on 2019. 06. 12..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct PieChartRow : View {
|
||||
var data: [Int]
|
||||
var backgroundColor: Color
|
||||
var accentColor: Color
|
||||
var slices: [PieSlice] {
|
||||
var tempSlices:[PieSlice] = []
|
||||
var lastEndDeg:Double = 0
|
||||
let maxValue = data.reduce(0, +)
|
||||
for slice in data {
|
||||
let normalized:Double = Double(slice)/Double(maxValue)
|
||||
let startDeg = lastEndDeg
|
||||
let endDeg = lastEndDeg + (normalized * 360)
|
||||
lastEndDeg = endDeg
|
||||
tempSlices.append(PieSlice(startDeg: startDeg, endDeg: endDeg, value: slice, normalizedValue: normalized))
|
||||
}
|
||||
return tempSlices
|
||||
}
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack{
|
||||
ForEach(0..<self.slices.count){ i in
|
||||
PieChartCell(rect: geometry.frame(in: .local), startDeg: self.slices[i].startDeg, endDeg: self.slices[i].endDeg, index: i, backgroundColor: self.backgroundColor,accentColor: self.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct PieChartRow_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
PieChartRow(data:[8,23,54,32,12,37,7,23,43], backgroundColor: Color(red: 252.0/255.0, green: 236.0/255.0, blue: 234.0/255.0), accentColor: Color(red: 225.0/255.0, green: 97.0/255.0, blue: 76.0/255.0)).frame(width: 100, height: 100)
|
||||
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,59 +0,0 @@
|
||||
//
|
||||
// PieChartView.swift
|
||||
// ChartView
|
||||
//
|
||||
// Created by András Samu on 2019. 06. 12..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct PieChartView : View {
|
||||
public var data: [Int]
|
||||
public var title: String
|
||||
public var legend: String?
|
||||
public var style: ChartStyle
|
||||
public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.pieChartStyleOne ){
|
||||
self.data = data
|
||||
self.title = title
|
||||
self.legend = legend
|
||||
self.style = style
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack{
|
||||
Rectangle()
|
||||
.fill(self.style.backgroundColor)
|
||||
.cornerRadius(20)
|
||||
.shadow(color: Color.gray, radius: 12)
|
||||
VStack(alignment: .leading){
|
||||
HStack{
|
||||
Text(self.title)
|
||||
.font(.headline)
|
||||
.foregroundColor(self.style.textColor)
|
||||
Spacer()
|
||||
Image(systemName: "chart.pie.fill")
|
||||
.imageScale(.large)
|
||||
.foregroundColor(self.style.legendTextColor)
|
||||
}.padding()
|
||||
PieChartRow(data: data, backgroundColor: self.style.backgroundColor, accentColor: self.style.accentColor)
|
||||
.foregroundColor(self.style.accentColor).padding(self.legend != nil ? 0 : 12).offset(y:self.legend != nil ? 0 : -10)
|
||||
if(self.legend != nil) {
|
||||
Text(self.legend!)
|
||||
.font(.headline)
|
||||
.foregroundColor(self.style.legendTextColor)
|
||||
.padding()
|
||||
}
|
||||
|
||||
}
|
||||
}.frame(width: self.style.chartFormSize.width, height: self.style.chartFormSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct PieChartView_Previews : PreviewProvider {
|
||||
static var previews: some View {
|
||||
PieChartView(data:[56,78,53,65,54], title: "Title", legend: "Legend")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,37 @@
|
||||
@testable import SwiftUICharts
|
||||
import XCTest
|
||||
|
||||
class ArrayExtensionTests: XCTestCase {
|
||||
|
||||
func testArrayRotatingIndexEmpty() {
|
||||
let colors = [ColorGradient]()
|
||||
XCTAssertEqual(colors.rotate(for: 0), ColorGradient.orangeBright)
|
||||
}
|
||||
|
||||
func testArrayRotatingIndexOneValue() {
|
||||
let colors = [ColorGradient.greenRed]
|
||||
|
||||
XCTAssertEqual(colors.rotate(for: 0), ColorGradient.greenRed)
|
||||
XCTAssertEqual(colors.rotate(for: 1), ColorGradient.greenRed)
|
||||
XCTAssertEqual(colors.rotate(for: 2), ColorGradient.greenRed)
|
||||
}
|
||||
|
||||
func testArrayRotatingIndexLessValues() {
|
||||
let colors = [ColorGradient.greenRed, ColorGradient.whiteBlack]
|
||||
|
||||
XCTAssertEqual(colors.rotate(for: 0), ColorGradient.greenRed)
|
||||
XCTAssertEqual(colors.rotate(for: 1), ColorGradient.whiteBlack)
|
||||
XCTAssertEqual(colors.rotate(for: 2), ColorGradient.greenRed)
|
||||
XCTAssertEqual(colors.rotate(for: 3), ColorGradient.whiteBlack)
|
||||
XCTAssertEqual(colors.rotate(for: 4), ColorGradient.greenRed)
|
||||
}
|
||||
|
||||
func testArrayRotatingIndexMoreValues() {
|
||||
let colors = [ColorGradient.greenRed, ColorGradient.whiteBlack, ColorGradient.orangeBright]
|
||||
|
||||
XCTAssertEqual(colors.rotate(for: 0), ColorGradient.greenRed)
|
||||
XCTAssertEqual(colors.rotate(for: 1), ColorGradient.whiteBlack)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// CGPointExtensionTests.swift
|
||||
// SwiftUIChartsTests
|
||||
//
|
||||
// Created by Adrian Bolinger on 5/24/20.
|
||||
//
|
||||
|
||||
@testable import SwiftUICharts
|
||||
import XCTest
|
||||
|
||||
class CGPointExtensionTests: XCTestCase {
|
||||
static let twentyElementArray: [Double] = Array(repeating: Double.random(in: 1...100), count: 20)
|
||||
|
||||
func testGetStepWithOneElementArray() {
|
||||
let frame = CGRect(x: 0, y: 0, width: 300, height: 300)
|
||||
let oneElementArray: [Double] = [0.0]
|
||||
|
||||
XCTAssertEqual(CGPoint.getStep(frame: frame, data: oneElementArray), .zero)
|
||||
}
|
||||
|
||||
func testGetStepWithMultiElementArrayWithNegativeValues() {
|
||||
let frame = CGRect(x: 0, y: 0, width: 300, height: 300)
|
||||
let multiElementArray: [Double] = [-5.0, 0.0, 5.0]
|
||||
XCTAssertEqual(CGPoint.getStep(frame: frame, data: multiElementArray), CGPoint(x: 150.0, y: 27.0))
|
||||
}
|
||||
|
||||
func testGetStepWithMultiElementArrayWithPositiveValues() {
|
||||
let frame = CGRect(x: 0, y: 0, width: 300, height: 300)
|
||||
let multiElementArray: [Double] = [5.0, 10.0, 15.0]
|
||||
XCTAssertEqual(CGPoint.getStep(frame: frame, data: multiElementArray), CGPoint(x: 150.0, y: 13.5))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// ColorExtensionTests.swift
|
||||
// SwiftUIChartsTests
|
||||
//
|
||||
// Created by Adrian Bolinger on 5/24/20.
|
||||
//
|
||||
|
||||
@testable import SwiftUICharts
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
|
||||
class ColorExtensionTests: XCTestCase {
|
||||
func testTwentyFourBitRGBColors() {
|
||||
let actualWhite = Color(hexString: "FFFFFF")
|
||||
let expectedWhite = Color(red: 1, green: 1, blue: 1)
|
||||
XCTAssertEqual(actualWhite, expectedWhite)
|
||||
|
||||
let actualBlack = Color(hexString: "000000")
|
||||
let expectedBlack = Color(red: 0, green: 0, blue: 0)
|
||||
XCTAssertEqual(actualBlack, expectedBlack)
|
||||
|
||||
let actualRed = Color(hexString: "FF0000")
|
||||
let expectedRed = Color(red: 255/255, green: 0, blue: 0)
|
||||
XCTAssertEqual(actualRed, expectedRed)
|
||||
|
||||
let actualGreen = Color(hexString: "00FF00")
|
||||
let expectedGreen = Color(red: 0, green: 1, blue: 0)
|
||||
XCTAssertEqual(actualGreen, expectedGreen)
|
||||
|
||||
let actualBlue = Color(hexString: "0000FF")
|
||||
let expectedBlue = Color(red: 0, green: 0, blue: 1)
|
||||
XCTAssertEqual(actualBlue, expectedBlue)
|
||||
}
|
||||
|
||||
func testTwelveBitRGBColors() {
|
||||
let actualWhite = Color(hexString: "FFF")
|
||||
let expectedWhite = Color(red: 1, green: 1, blue: 1)
|
||||
XCTAssertEqual(actualWhite, expectedWhite)
|
||||
|
||||
let actualBlack = Color(hexString: "000")
|
||||
let expectedBlack = Color(red: 0, green: 0, blue: 0)
|
||||
XCTAssertEqual(actualBlack, expectedBlack)
|
||||
|
||||
let actualRed = Color(hexString: "F00")
|
||||
let expectedRed = Color(red: 255/255, green: 0, blue: 0)
|
||||
XCTAssertEqual(actualRed, expectedRed)
|
||||
|
||||
let actualGreen = Color(hexString: "0F0")
|
||||
let expectedGreen = Color(red: 0, green: 1, blue: 0)
|
||||
XCTAssertEqual(actualGreen, expectedGreen)
|
||||
|
||||
let actualBlue = Color(hexString: "00F")
|
||||
let expectedBlue = Color(red: 0, green: 0, blue: 1)
|
||||
XCTAssertEqual(actualBlue, expectedBlue)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,6 @@ final class SwiftUIChartsTests: XCTestCase {
|
||||
}
|
||||
|
||||
static var allTests = [
|
||||
("testExample", testExample),
|
||||
("testExample", testExample)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import XCTest
|
||||
#if !canImport(ObjectiveC)
|
||||
public func allTests() -> [XCTestCaseEntry] {
|
||||
return [
|
||||
testCase(SwiftUIChartsTests.allTests),
|
||||
testCase(SwiftUIChartsTests.allTests)
|
||||
]
|
||||
}
|
||||
#endif
|
||||
|
||||
|
Before Width: | Height: | Size: 445 KiB |