Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d298e31fc2 | |||
| 7140b8b6fd | |||
| 7fd5b185f8 | |||
| ebaaf81d19 | |||
| 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 |
@@ -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),.watchOS(.v6)
|
||||
.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"])
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,116 +1,180 @@
|
||||
# SwiftUICharts
|
||||
|
||||
Swift package for displaying charts effortlessly.
|
||||
### SwiftUICharts is here with a new version, giving more controll and customization over the charts and full ios 13 backwards compatibility.
|
||||
|
||||

|
||||
SwiftUICharts is an open source library for creating charts in iOS apps with SwiftUI. Built natively with SwiftUI in mind and it is fully compatible with the latest versions of iOS, also backwards **compatible up to iOS 13.**
|
||||
|
||||
It supports:
|
||||
* Line charts
|
||||
* Bar charts
|
||||
* Pie charts
|
||||
With SwiftUICharts, you can easily create a variety of chart types, including line charts, bar charts, ring charts and pie charts. It also provides additional chart types and customization options, giving you the flexibility to create charts that perfectly fit your needs.
|
||||
|
||||
### Installation:
|
||||
In addition to its native SwiftUI integration, backwards compatibility and wide range of chart types, SwiftUICharts is also easy to use, making it a great choice for creating stunning and intuitive charts in your applications.
|
||||
|
||||
It requires iOS 13 and xCode 11!
|
||||
<p align="center">
|
||||
<img src="Resources/linevid2.gif" width="30%"/> <img src="Resources/barvid2.gif" width="30%"/> <img src="Resources/pievid2.gif" width="30%"/>
|
||||
</p>
|
||||
|
||||
In xCode got to `File -> Swift Packages -> Add Package Dependency` and paste inthe repo's url: `https://github.com/AppPear/ChartView`
|
||||
### Composable
|
||||
|
||||
### Usage:
|
||||
You can now build your custom chart from prebuilt layers. Use a simple chart, add background grids, add chart labels to the axes, add tcks to the line, etc.
|
||||
|
||||
import the package in the file you would like to use it: `import SwiftUICharts`
|
||||
### Works great with native SwiftUI elements
|
||||
|
||||
You can display a Chart by adding a chart view to your parent view:
|
||||
[Example codes here](./example.md)
|
||||
<p align="left">
|
||||
<img src="Resources/linechartcard.png" width="40%"/> <img src="Resources/barchartcard.png" width="40%"/>
|
||||
<img src="Resources/piechartcard.png" width="40%"/>
|
||||
</p>
|
||||
|
||||
### Demo
|
||||
### Installation
|
||||
|
||||
Added an example project, with **iOS, watchOS** target: https://github.com/AppPear/ChartViewDemo
|
||||
It is a SPM package, in Xcode go to File -> Swift Packages -> Add Package Dependency and paste in the repo's url: https://github.com/AppPear/ChartView
|
||||
|
||||
## Line charts
|
||||

|
||||
if you want to add previous releases you can find release tags https://github.com/AppPear/ChartView/releases
|
||||
|
||||
**Line chart is interactive, so you can drag across to reveal the data points**
|
||||
### Slack
|
||||
|
||||
You can add a line chart with the following code:
|
||||
Join our Slack channel for day to day conversation and more insights:
|
||||
|
||||
https://join.slack.com/t/swiftuichartview/shared_invite/zt-g6mxioq8-j3iUTF1YKX7D23ML3qcc4g
|
||||
|
||||
## Quick start guide:
|
||||
|
||||
**Create a simple chart:**
|
||||
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic1.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
LineChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") // legend is optional
|
||||
LineChart()
|
||||
.data([3, 5, 4, 1, 0, 2, 4, 1, 0, 2, 8])
|
||||
.chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
|
||||
```
|
||||
|
||||
**Add a background grid to the chart:**
|
||||
|
||||
## Bar charts
|
||||

|
||||
|
||||
**Bar chart is interactive, so you can drag across to reveal the data points**
|
||||
|
||||
You can add a bar chart with the following code:
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic2.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") // legend is optional
|
||||
ChartGrid {
|
||||
LineChart()
|
||||
.data([3, 5, 4, 1, 0, 2, 4, 1, 0, 2, 8])
|
||||
.chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
|
||||
}
|
||||
.setNumberOfHorizontalLines(5)
|
||||
.setNumberOfVerticalLines(4)
|
||||
```
|
||||
|
||||
You can add different formats:
|
||||
* Small `Form.small`
|
||||
* Medium `Form.medium`
|
||||
* Large `Form.large`
|
||||
**Add a axis label to the chart:**
|
||||
|
||||
```swift
|
||||
BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", form: Form.small)
|
||||
```
|
||||
|
||||
### You can customize styling of the chart with a ChartStyle object:
|
||||
|
||||
Customizable:
|
||||
* background color
|
||||
* accent color
|
||||
* second gradient color
|
||||
* text color
|
||||
* legend text color
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic3.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
let chartStyle = ChartStyle(backgroundColor: Color.black, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, chartFormSize: Form.medium, textColor: Color.white, legendTextColor: Color.white )
|
||||
...
|
||||
BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: chartStyle)
|
||||
AxisLabels {
|
||||
ChartGrid {
|
||||
LineChart()
|
||||
.data([3, 5, 4, 1, 0, 2, 4, 1, 0, 2, 8])
|
||||
.chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
|
||||
}
|
||||
.setNumberOfHorizontalLines(5)
|
||||
.setNumberOfVerticalLines(4)
|
||||
}
|
||||
.setAxisXLabels([(1, "Nov"), (2, "Dec"), (3, "Jan")], range: 1...3)
|
||||
```
|
||||
|
||||
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
|
||||
**Show chart marks on the line:**
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
### You can customize the size of the chart with a Form object:
|
||||
|
||||
**Form**
|
||||
* `.small`
|
||||
* `.medium`
|
||||
* `.large`
|
||||
* `.detail`
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic4.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", form: Form.small)
|
||||
AxisLabels {
|
||||
ChartGrid {
|
||||
LineChart()
|
||||
.showChartMarks(true)
|
||||
.data([3, 5, 4, 1, 0, 2, 4, 1, 0, 2, 8])
|
||||
.chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
|
||||
}
|
||||
.setNumberOfHorizontalLines(5)
|
||||
.setNumberOfVerticalLines(4)
|
||||
}
|
||||
.setAxisXLabels([(1, "Nov"), (2, "Dec"), (3, "Jan")], range: 1...3)
|
||||
```
|
||||
|
||||
### WatchOS support for Bar charts:
|
||||
**Apply custom ranges on chart**
|
||||
|
||||

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

|
||||
|
||||
You can add a line chart with the following code:
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic5.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
PieChartView(data: [8,23,54,32], title: "Title", legend: "Legendary") // legend is optional
|
||||
AxisLabels {
|
||||
ChartGrid {
|
||||
LineChart()
|
||||
.showChartMarks(true)
|
||||
.data([3, 5, 4, 1, 0, 2, 4, 1, 0, 2, 8])
|
||||
.rangeY(0...10)
|
||||
.rangeX(0...5)
|
||||
.chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
|
||||
}
|
||||
.setNumberOfHorizontalLines(5)
|
||||
.setNumberOfVerticalLines(4)
|
||||
}
|
||||
.setAxisXLabels([(1, "Nov"), (2, "Dec"), (3, "Jan")], range: 1...3)
|
||||
```
|
||||
|
||||
**Add multiple charts in the same frame**
|
||||
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic6.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
AxisLabels {
|
||||
ChartGrid {
|
||||
LineChart()
|
||||
.showChartMarks(true)
|
||||
.data([3, 5, 4, 1, 0, 2, 4, 1, 0, 2, 8])
|
||||
.rangeY(0...12)
|
||||
.rangeX(0...5)
|
||||
.chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
|
||||
LineChart()
|
||||
.showChartMarks(true)
|
||||
.data([4, 1, 0, 2, 8, 3, 6, 1, 4])
|
||||
.rangeY(0...12)
|
||||
.rangeX(0...5)
|
||||
.chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.blue, .purple)))
|
||||
}
|
||||
.setNumberOfHorizontalLines(5)
|
||||
.setNumberOfVerticalLines(4)
|
||||
}
|
||||
.setAxisXLabels([(1, "Nov"), (2, "Dec"), (3, "Jan")], range: 1...3)
|
||||
```
|
||||
|
||||
**Combine chart types in the same frame**
|
||||
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic7.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
AxisLabels {
|
||||
ChartGrid {
|
||||
BarChart()
|
||||
.data([2, 4, 1, 3])
|
||||
.chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
|
||||
LineChart()
|
||||
.showChartMarks(true)
|
||||
.data([2, 4, 1, 3])
|
||||
.chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.blue, .purple)))
|
||||
}
|
||||
.setNumberOfHorizontalLines(5)
|
||||
.setNumberOfVerticalLines(4)
|
||||
}
|
||||
.setAxisXLabels([(1, "Nov"), (2, "Dec"), (3, "Jan")], range: 1...3)
|
||||
```
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 195 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,43 +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: self.normalizedValue(index: i), 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)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedValue(index: Int) -> Double {
|
||||
return Double(self.data[index])/Double(self.maxValue)
|
||||
}
|
||||
}
|
||||
|
||||
#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,109 +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
|
||||
public var formSize:CGSize
|
||||
public var dropShadow: Bool
|
||||
|
||||
// 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()
|
||||
HapticFeedback.playSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
var isFullWidth:Bool {
|
||||
return self.formSize == Form.large
|
||||
}
|
||||
public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.barChartStyleOrangeLight, form: CGSize? = Form.medium, dropShadow: Bool? = true){
|
||||
self.data = data
|
||||
self.title = title
|
||||
self.legend = legend
|
||||
self.style = style
|
||||
self.formSize = form!
|
||||
self.dropShadow = dropShadow!
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack{
|
||||
Rectangle()
|
||||
.fill(self.style.backgroundColor)
|
||||
.cornerRadius(20)
|
||||
.shadow(color: Color.gray, radius: self.dropShadow ? 8 : 0)
|
||||
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.formSize == 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.formSize == Form.medium {
|
||||
Text(self.legend!)
|
||||
.font(.headline)
|
||||
.foregroundColor(self.style.legendTextColor)
|
||||
.padding()
|
||||
}
|
||||
|
||||
}
|
||||
}.frame(minWidth:self.formSize.width, maxWidth: self.isFullWidth ? .infinity : self.formSize.width, minHeight:self.formSize.height, maxHeight:self.formSize.height)
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
self.touchLocation = value.location.x/self.formSize.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.formSize.width)/(self.formSize.width/CGFloat(self.data.count))))))
|
||||
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: 4.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,74 @@
|
||||
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 {
|
||||
return rangeY == nil ? (normalisedPoints.max() ?? 0.0) - (normalisedPoints.min() ?? 0.0) : 1
|
||||
}
|
||||
|
||||
var normalisedXRange: Double {
|
||||
return rangeX == nil ? (normalisedValues.max() ?? 0.0) - (normalisedValues.min() ?? 0.0) : 1
|
||||
}
|
||||
|
||||
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: CGPoint(x: convertedXValues[0], y: 0))
|
||||
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,17 @@
|
||||
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)
|
||||
}
|
||||
|
||||
public func toStandardCoordinateSystem() -> some View {
|
||||
self
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
}
|
||||
@@ -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,28 @@
|
||||
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()
|
||||
.toStandardCoordinateSystem()
|
||||
|
||||
ChartGridShape(numberOfHorizontalLines: 4, numberOfVerticalLines: 4)
|
||||
.stroke()
|
||||
.toStandardCoordinateSystem()
|
||||
}
|
||||
.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,53 @@
|
||||
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())
|
||||
.padding(50)
|
||||
|
||||
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,56 @@
|
||||
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()
|
||||
guard value != 0 else {
|
||||
return 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)
|
||||
.fill(Color.blue)
|
||||
.padding(50)
|
||||
|
||||
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, 0, 8, 6])
|
||||
static let chartStyle = ChartStyle(backgroundColor: .white, foregroundColor: .orangeBright)
|
||||
static var previews: some View {
|
||||
BarChartRow(chartData: chartData, style: chartStyle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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
|
||||
}
|
||||
|
||||
public func withAnimation(_ enabled: Bool) -> LineChart {
|
||||
self.chartProperties.animationEnabled = enabled
|
||||
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,111 @@
|
||||
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(geometry: geometry)
|
||||
}
|
||||
.onAppear {
|
||||
didCellAppear = true
|
||||
}
|
||||
.onDisappear() {
|
||||
didCellAppear = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func lineShapeView(geometry: GeometryProxy) -> some View {
|
||||
if chartProperties.animationEnabled {
|
||||
LineShapeView(chartData: chartData,
|
||||
chartProperties: chartProperties,
|
||||
geometry: geometry,
|
||||
style: style,
|
||||
trimTo: didCellAppear ? 1.0 : 0.0)
|
||||
.animation(Animation.easeIn(duration: 0.75))
|
||||
} else {
|
||||
LineShapeView(chartData: chartData,
|
||||
chartProperties: chartProperties,
|
||||
geometry: geometry,
|
||||
style: style,
|
||||
trimTo: 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,27 @@
|
||||
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)
|
||||
.toStandardCoordinateSystem()
|
||||
}
|
||||
GeometryReader { geometry in
|
||||
LineBackgroundShape(data: [(0, 0), (0.25, 0.5), (0.5,0.8), (0.75, 0.6), (1, 1)])
|
||||
.fill(Color.blue)
|
||||
.toStandardCoordinateSystem()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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))
|
||||
.toStandardCoordinateSystem()
|
||||
}
|
||||
}
|
||||
@@ -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,30 @@
|
||||
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()
|
||||
.toStandardCoordinateSystem()
|
||||
|
||||
LineShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)], lineStyle: .straight)
|
||||
.stroke()
|
||||
.toStandardCoordinateSystem()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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))
|
||||
.toStandardCoordinateSystem()
|
||||
.clipped()
|
||||
if chartProperties.showChartMarks {
|
||||
MarkerShape(data: chartData.normalisedData)
|
||||
.trim(from: 0, to: CGFloat(trimTo))
|
||||
.fill(.white,
|
||||
strokeBorder: chartMarkColor,
|
||||
lineWidth: chartProperties.lineWidth)
|
||||
.toStandardCoordinateSystem()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,23 @@
|
||||
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()
|
||||
.toStandardCoordinateSystem()
|
||||
|
||||
MarkerShape(data: [(0, -0.5), (0.25, 0.8), (0.5,-0.6), (0.75,0.6), (1, 1)])
|
||||
.stroke()
|
||||
.toStandardCoordinateSystem()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
public class LineChartProperties: ObservableObject {
|
||||
@Published var lineWidth: CGFloat = 2.0
|
||||
@Published var backgroundGradient: ColorGradient?
|
||||
@Published var showChartMarks: Bool = false
|
||||
@Published var customChartMarksColors: ColorGradient?
|
||||
@Published var lineStyle: LineStyle = .curved
|
||||
@Published var animationEnabled: Bool = true
|
||||
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,176 +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,
|
||||
textColor: Color.black,
|
||||
legendTextColor: Color.gray)
|
||||
|
||||
public static let barChartStyleOrangeLight = ChartStyle(
|
||||
backgroundColor: Color.white,
|
||||
accentColor: Colors.OrangeStart,
|
||||
secondGradientColor: Colors.OrangeEnd,
|
||||
textColor: Color.black,
|
||||
legendTextColor: Color.gray)
|
||||
|
||||
public static let barChartStyleOrangeDark = ChartStyle(
|
||||
backgroundColor: Color.black,
|
||||
accentColor: Colors.OrangeStart,
|
||||
secondGradientColor: Colors.OrangeEnd,
|
||||
textColor: Color.white,
|
||||
legendTextColor: Color.gray)
|
||||
|
||||
public static let barChartStyleNeonBlueLight = ChartStyle(
|
||||
backgroundColor: Color.white,
|
||||
accentColor: Colors.GradientNeonBlue,
|
||||
secondGradientColor: Colors.GradientPurple,
|
||||
textColor: Color.black,
|
||||
legendTextColor: Color.gray)
|
||||
|
||||
public static let barChartStyleNeonBlueDark = ChartStyle(
|
||||
backgroundColor: Color.black,
|
||||
accentColor: Colors.GradientNeonBlue,
|
||||
secondGradientColor: Colors.GradientPurple,
|
||||
textColor: Color.white,
|
||||
legendTextColor: Color.gray)
|
||||
|
||||
public static let barChartMidnightGreenDark = ChartStyle(
|
||||
backgroundColor: Color(hexString: "#36534D"), //3B5147, 313D34
|
||||
accentColor: Color(hexString: "#FFD603"),
|
||||
secondGradientColor: Color(hexString: "#FFCA04"),
|
||||
textColor: Color.white,
|
||||
legendTextColor: Color(hexString: "#D2E5E1"))
|
||||
|
||||
public static let barChartMidnightGreenLight = ChartStyle(
|
||||
backgroundColor: Color.white,
|
||||
accentColor: Color(hexString: "#84A094"), //84A094 , 698378
|
||||
secondGradientColor: Color(hexString: "#50675D"),
|
||||
textColor: Color.black,
|
||||
legendTextColor:Color.gray)
|
||||
|
||||
public static let pieChartStyleOne = ChartStyle(
|
||||
backgroundColor: Color.white,
|
||||
accentColor: Colors.OrangeStart,
|
||||
secondGradientColor: Colors.OrangeEnd,
|
||||
textColor: Color.black,
|
||||
legendTextColor: Color.gray)
|
||||
}
|
||||
|
||||
public struct Form {
|
||||
#if os(watchOS)
|
||||
public static let small = CGSize(width:120, height:90)
|
||||
public static let medium = CGSize(width:120, height:160)
|
||||
public static let large = CGSize(width:180, height:90)
|
||||
public static let detail = CGSize(width:180, height:160)
|
||||
#else
|
||||
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)
|
||||
#endif
|
||||
|
||||
|
||||
}
|
||||
|
||||
public struct ChartStyle {
|
||||
public var backgroundColor: Color
|
||||
public var accentColor: Color
|
||||
public var secondGradientColor: Color
|
||||
public var textColor: Color
|
||||
public var legendTextColor: Color
|
||||
|
||||
public init(backgroundColor: Color, accentColor: Color, secondGradientColor: Color, textColor: Color, legendTextColor: Color){
|
||||
self.backgroundColor = backgroundColor
|
||||
self.accentColor = accentColor
|
||||
self.secondGradientColor = secondGradientColor
|
||||
self.textColor = textColor
|
||||
self.legendTextColor = legendTextColor
|
||||
}
|
||||
|
||||
public init(formSize: CGSize){
|
||||
self.backgroundColor = Color.white
|
||||
self.accentColor = Colors.OrangeStart
|
||||
self.secondGradientColor = Colors.OrangeEnd
|
||||
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 = UInt64()
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let r, g, b: UInt64
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
class HapticFeedback {
|
||||
#if os(watchOS)
|
||||
//watchOS implementation
|
||||
static func playSelection() -> Void {
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
}
|
||||
#else
|
||||
//iOS implementation
|
||||
let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
|
||||
static func playSelection() -> Void {
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -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,119 +0,0 @@
|
||||
//
|
||||
// LineCard.swift
|
||||
// LineChart
|
||||
//
|
||||
// Created by András Samu on 2019. 08. 31..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct LineChartView: View {
|
||||
// let selectionFeedbackGenerator = UISelectionFeedbackGenerator()
|
||||
@ObservedObject var data:ChartData
|
||||
public var title: String
|
||||
public var legend: String?
|
||||
public var style: ChartStyle
|
||||
public var formSize:CGSize
|
||||
public var dropShadow: Bool
|
||||
|
||||
@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()
|
||||
HapticFeedback.playSelection()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
let frame = CGSize(width: 180, height: 120)
|
||||
private var rateValue: Int
|
||||
|
||||
public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.lineChartStyleOne, form: CGSize? = Form.medium ,rateValue: Int? = 14, dropShadow: Bool? = true){
|
||||
self.data = ChartData(points: data)
|
||||
self.title = title
|
||||
self.legend = legend
|
||||
self.style = style
|
||||
self.formSize = form!
|
||||
self.rateValue = rateValue!
|
||||
self.dropShadow = dropShadow!
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack(alignment: .center){
|
||||
RoundedRectangle(cornerRadius: 20).fill(self.style.backgroundColor).frame(width: frame.width, height: 240, alignment: .center).shadow(radius: self.dropShadow ? 8 : 0)
|
||||
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 {
|
||||
if (self.rateValue >= 0){
|
||||
Image(systemName: "arrow.up")
|
||||
}else{
|
||||
Image(systemName: "arrow.down")
|
||||
}
|
||||
Text("\(self.rateValue)%")
|
||||
}
|
||||
}
|
||||
.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.formSize.width, height: self.formSize.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,125 +0,0 @@
|
||||
//
|
||||
// LineView.swift
|
||||
// LineChart
|
||||
//
|
||||
// Created by András Samu on 2019. 09. 02..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct LineView: View {
|
||||
@ObservedObject var data: ChartData
|
||||
public var title: String?
|
||||
public var legend: String?
|
||||
public var style: ChartStyle
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
@State private var showLegend = false
|
||||
@State private var dragLocation:CGPoint = .zero
|
||||
@State private var indicatorLocation:CGPoint = .zero
|
||||
@State private var closestPoint: CGPoint = .zero
|
||||
@State private var opacity:Double = 0
|
||||
@State private var currentDataNumber: Int = 0
|
||||
@State private var hideHorizontalLines: Bool = false
|
||||
|
||||
public init(data: [Int], title: String? = nil, legend: String? = nil, style: ChartStyle? = Styles.lineChartStyleOne){
|
||||
self.data = ChartData(points: data)
|
||||
self.title = title
|
||||
self.legend = legend
|
||||
self.style = style!
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader{ geometry in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Group{
|
||||
if (self.title != nil){
|
||||
Text(self.title!).font(.title).bold().foregroundColor(self.colorScheme == .dark ? Color.white : self.style.textColor)
|
||||
}
|
||||
if (self.legend != nil){
|
||||
Text(self.legend!).font(.callout).foregroundColor(self.colorScheme == .dark ? Color.white : self.style.legendTextColor)
|
||||
}
|
||||
}.offset(x: 0, y: 46)
|
||||
ZStack{
|
||||
GeometryReader{ reader in
|
||||
Rectangle().foregroundColor(self.colorScheme == .dark ? Color.black : self.style.backgroundColor)
|
||||
if(self.showLegend){
|
||||
Legend(data: self.data, frame: .constant(reader.frame(in: .local)), hideHorizontalLines: self.$hideHorizontalLines)
|
||||
.transition(.opacity)
|
||||
.animation(Animation.easeOut(duration: 1).delay(1))
|
||||
}
|
||||
Line(data: self.data, frame: .constant(CGRect(x: 0, y: 0, width: reader.frame(in: .local).width - 30, height: reader.frame(in: .local).height)), touchLocation: self.$indicatorLocation,showIndicator: self.$hideHorizontalLines ,showBackground: false).offset(x: 30, y: 0)
|
||||
.onAppear(){
|
||||
self.showLegend.toggle()
|
||||
}
|
||||
}.frame(width: geometry.frame(in: .local).size.width, height: 240).offset(x: 0, y: 40 )
|
||||
MagnifierRect(currentNumber: self.$currentDataNumber)
|
||||
.opacity(self.opacity)
|
||||
.offset(x: self.dragLocation.x - geometry.frame(in: .local).size.width/2, y: 50)
|
||||
}
|
||||
.frame(width: geometry.frame(in: .local).size.width, height: 240)
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
self.dragLocation = value.location
|
||||
self.indicatorLocation = CGPoint(x: max(value.location.x-30,0), y: 32)
|
||||
self.opacity = 1
|
||||
self.closestPoint = self.getClosestDataPoint(toPoint: value.location, width: geometry.frame(in: .local).size.width-30, height: 240)
|
||||
self.hideHorizontalLines = true
|
||||
})
|
||||
.onEnded({ value in
|
||||
self.opacity = 0
|
||||
self.hideHorizontalLines = 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(floor((toPoint.x-15)/stepWidth))
|
||||
if (index >= 0 && index < data.points.count){
|
||||
self.currentDataNumber = self.data.points[index]
|
||||
return CGPoint(x: CGFloat(index)*stepWidth, y: CGFloat(self.data.points[index])*stepHeight)
|
||||
}
|
||||
return .zero
|
||||
}
|
||||
}
|
||||
|
||||
struct LineView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
LineView(data: [37,72,51,22,39,47,66,85,50], title: "Full chart", style: Styles.lineChartStyleOne).environment(\.colorScheme, .dark)
|
||||
}
|
||||
}
|
||||
|
||||
struct IndicatorCircle: View {
|
||||
var body: some View {
|
||||
Circle()
|
||||
.size(width: 12, height: 12)
|
||||
.fill(Colors.BorderBlue)
|
||||
}
|
||||
}
|
||||
|
||||
struct MagnifierRect: View {
|
||||
@Binding var currentNumber:Int
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
var body: some View {
|
||||
ZStack{
|
||||
Text("\(self.currentNumber)")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.offset(x: 0, y:-96)
|
||||
.animation(.spring())
|
||||
.foregroundColor(self.colorScheme == .dark ? Color.white : Color.black)
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(Color.white, lineWidth: self.colorScheme == .dark ? 2 : 0)
|
||||
.frame(width: 60, height: 240)
|
||||
.foregroundColor(self.colorScheme == .dark ? Color.black : Color.white)
|
||||
.shadow(color: Colors.LegendText, radius: 12, x: 0, y: 6 )
|
||||
.blendMode(self.colorScheme == .dark ? .normal : .multiply)
|
||||
|
||||
}.animation(.linear)
|
||||
}
|
||||
}
|
||||
@@ -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,64 +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 var formSize:CGSize
|
||||
public var dropShadow: Bool
|
||||
|
||||
public init(data: [Int], title: String, legend: String? = nil, style: ChartStyle = Styles.pieChartStyleOne, form: CGSize? = Form.medium, dropShadow: Bool? = true ){
|
||||
self.data = data
|
||||
self.title = title
|
||||
self.legend = legend
|
||||
self.style = style
|
||||
self.formSize = form!
|
||||
self.dropShadow = dropShadow!
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack{
|
||||
Rectangle()
|
||||
.fill(self.style.backgroundColor)
|
||||
.cornerRadius(20)
|
||||
.shadow(color: Color.gray, radius: self.dropShadow ? 12 : 0)
|
||||
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.formSize.width, height: self.formSize.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
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# SwiftUICharts
|
||||
|
||||
### Example codes
|
||||
|
||||
<p align="left">
|
||||
<img src="Resources/linechartcard.png" width="400px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import SwiftUICharts
|
||||
|
||||
struct DemoView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Sneakers sold")
|
||||
.font(.title)
|
||||
Text("Last week")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.gray)
|
||||
.padding(.bottom, 8.0)
|
||||
HStack {
|
||||
AxisLabels {
|
||||
ChartGrid {
|
||||
LineChart()
|
||||
.showChartMarks(true)
|
||||
.rangeY(10...40)
|
||||
.data([12, 34, 23, 18, 36, 22, 26])
|
||||
.chartStyle(ChartStyle(backgroundColor: .white,
|
||||
foregroundColor: ColorGradient(.blue, .purple)))
|
||||
}
|
||||
.setNumberOfHorizontalLines(5)
|
||||
.setNumberOfVerticalLines(0)
|
||||
}
|
||||
.setAxisXLabels([(1, "M"), (2, "T"), (3, "W"),(4, "T"), (5, "F"), (6, "S"), (7, "S")],
|
||||
range: 1...7)
|
||||
.setColor(.gray)
|
||||
.setFont(.caption2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8.0) {
|
||||
Text("Highest revenue:")
|
||||
.font(.callout)
|
||||
Text("Tuesday")
|
||||
.font(.subheadline)
|
||||
.bold()
|
||||
|
||||
Text("Most sales:")
|
||||
.font(.callout)
|
||||
Text("Friday")
|
||||
.font(.subheadline)
|
||||
.bold()
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(16.0)
|
||||
.background(RoundedRectangle(cornerRadius: 20)
|
||||
.fill(.white)
|
||||
.shadow(radius: 8.0))
|
||||
.padding(32)
|
||||
.frame(width: 450, height: 350)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<p align="left">
|
||||
<img src="Resources/barchartcard.png" width="400px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import SwiftUICharts
|
||||
|
||||
struct DemoView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Sneaker brands")
|
||||
.font(.title)
|
||||
Text("By popularity")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.gray)
|
||||
.padding(.bottom, 8.0)
|
||||
HStack {
|
||||
AxisLabels {
|
||||
ChartGrid {
|
||||
BarChart()
|
||||
.data([34, 23, 12])
|
||||
.chartStyle(ChartStyle(backgroundColor: .white,
|
||||
foregroundColor: [ColorGradient(.red, .orange),
|
||||
ColorGradient(.blue, .purple),
|
||||
ColorGradient(.green, .yellow)]))
|
||||
}
|
||||
.setNumberOfHorizontalLines(5)
|
||||
.setNumberOfVerticalLines(0)
|
||||
}
|
||||
.setAxisYLabels([(1, "0"), (2, "100"), (3, "200")],
|
||||
range: 1...3)
|
||||
.setColor(.gray)
|
||||
.setFont(.caption2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8.0) {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(ColorGradient(.red, .orange).linearGradient(from: .bottom, to: .top))
|
||||
.frame(width: 24.0, height: 24.0)
|
||||
Text("Sneaker brand 1")
|
||||
}
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(ColorGradient(.blue, .purple).linearGradient(from: .bottom, to: .top))
|
||||
.frame(width: 24.0, height: 24.0)
|
||||
Text("Sneaker brand 2")
|
||||
}
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(ColorGradient(.green, .yellow).linearGradient(from: .bottom, to: .top))
|
||||
.frame(width: 24.0, height: 24.0)
|
||||
Text("Sneaker brand 3")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(16.0)
|
||||
.background(RoundedRectangle(cornerRadius: 20)
|
||||
.fill(.white)
|
||||
.shadow(radius: 8.0))
|
||||
.padding(32)
|
||||
.frame(width: 450, height: 350)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<p align="left">
|
||||
<img src="Resources/piechartcard.png" width="400px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import SwiftUICharts
|
||||
|
||||
struct DemoView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Sneaker brands")
|
||||
.font(.title)
|
||||
Text("By popularity")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.gray)
|
||||
.padding(.bottom, 8.0)
|
||||
HStack {
|
||||
PieChart()
|
||||
.data([34, 23, 12])
|
||||
.chartStyle(ChartStyle(backgroundColor: .white,
|
||||
foregroundColor: [ColorGradient(.red, .orange),
|
||||
ColorGradient(.blue, .purple),
|
||||
ColorGradient(.yellow, .green),]))
|
||||
|
||||
VStack(alignment: .leading, spacing: 8.0) {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(ColorGradient(.red, .orange).linearGradient(from: .bottom, to: .top))
|
||||
.frame(width: 24.0, height: 24.0)
|
||||
Text("Sneaker brand 1")
|
||||
}
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(ColorGradient(.blue, .purple).linearGradient(from: .bottom, to: .top))
|
||||
.frame(width: 24.0, height: 24.0)
|
||||
Text("Sneaker brand 2")
|
||||
}
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(ColorGradient(.green, .yellow).linearGradient(from: .bottom, to: .top))
|
||||
.frame(width: 24.0, height: 24.0)
|
||||
Text("Sneaker brand 3")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(16.0)
|
||||
.background(RoundedRectangle(cornerRadius: 20)
|
||||
.fill(.white)
|
||||
.shadow(radius: 8.0))
|
||||
.padding(32)
|
||||
.frame(width: 450, height: 350)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
Before Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 73 KiB |