Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d298e31fc2 | ||
|
|
7140b8b6fd | ||
|
|
7fd5b185f8 | ||
|
|
ebaaf81d19 | ||
|
|
d7e9802deb | ||
|
|
bd29afc4c9 | ||
|
|
caa75ecbc0 | ||
|
|
7861bbcad1 | ||
|
|
84578d2f6f | ||
|
|
9210d01137 |
@@ -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>
|
||||
@@ -7,7 +7,20 @@
|
||||
<key>SwiftUICharts.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
<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>
|
||||
|
||||
@@ -1,192 +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
|
||||
|
||||
**LineChartView with multiple lines!**
|
||||
First release of this feature, interaction is disabled for now, I'll figure it out how could be the best to interact with multiple lines with a single touch.
|
||||

|
||||
### Slack
|
||||
|
||||
Usage:
|
||||
```swift
|
||||
MultiLineChartView(data: [([8,32,11,23,40,28], GradientColors.green), ([90,99,78,111,70,60,77], GradientColors.purple), ([34,56,72,38,43,100,50], GradientColors.orngPink)], title: "Title")
|
||||
```
|
||||
Gradient colors are now under the `GradientColor` struct you can create your own gradient by `GradientColor(start: Color, end: Color)`
|
||||
Join our Slack channel for day to day conversation and more insights:
|
||||
|
||||
Available preset gradients:
|
||||
* orange
|
||||
* blue
|
||||
* green
|
||||
* blu
|
||||
* bluPurpl
|
||||
* purple
|
||||
* prplPink
|
||||
* prplNeon
|
||||
* orngPink
|
||||
https://join.slack.com/t/swiftuichartview/shared_invite/zt-g6mxioq8-j3iUTF1YKX7D23ML3qcc4g
|
||||
|
||||
**Full screen view called LineView!!!**
|
||||
## Quick start guide:
|
||||
|
||||

|
||||
**Create a simple chart:**
|
||||
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic1.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
LineView(data: [8,23,54,32,12,37,7,23,43], title: "Line chart", legend: "Full screen") // legend is optional, use optional .padding()
|
||||
LineChart()
|
||||
.data([3, 5, 4, 1, 0, 2, 4, 1, 0, 2, 8])
|
||||
.chartStyle(ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient(.orange, .red)))
|
||||
```
|
||||
|
||||
Adopts to dark mode automatically
|
||||
**Add a background grid to the chart:**
|
||||
|
||||

|
||||
|
||||
You can add your custom darkmode style by specifying:
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic2.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
let myCustomStyle = ChartStyle(...)
|
||||
let myCutsomDarkModeStyle = ChartStyle(...)
|
||||
myCustomStyle.darkModeStyle = myCutsomDarkModeStyle
|
||||
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)
|
||||
```
|
||||
|
||||
**Line chart is interactive, so you can drag across to reveal the data points**
|
||||
**Add a axis label to the chart:**
|
||||
|
||||
You can add a line chart with the following code:
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic3.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
LineChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", legend: "Legendary") // legend is optional
|
||||
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)
|
||||
```
|
||||
|
||||
**Turn drop shadow off by adding to the Initialiser: `dropShadow: false`**
|
||||
**Show chart marks on the line:**
|
||||
|
||||
|
||||
## Bar charts
|
||||

|
||||
|
||||
**[New feature] you can display labels also along values and points for each bar to descirbe your data better!**
|
||||
**Bar chart is interactive, so you can drag across to reveal the data points**
|
||||
|
||||
You can add a bar chart with the following code:
|
||||
|
||||
Labels and points:
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic4.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
BarChartView(data: ChartData(values: [("2018 Q4",63150), ("2019 Q1",50900), ("2019 Q2",77550), ("2019 Q3",79600), ("2019 Q4",92550)]), title: "Sales", legend: "Quarterly") // legend is optional
|
||||
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)
|
||||
```
|
||||
Only points:
|
||||
|
||||
**Apply custom ranges on chart**
|
||||
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic5.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
BarChartView(data: ChartData(points: [8,23,54,32,12,37,7,23,43]), 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)
|
||||
```
|
||||
|
||||
**ChartData** structure
|
||||
Stores values in data pairs (actually tuple): `(String,Double)`
|
||||
* you can have duplicate values
|
||||
* keeps the data order
|
||||
**Add multiple charts in the same frame**
|
||||
|
||||
You can initialise ChartData multiple ways:
|
||||
* For integer values: `ChartData(points: [8,23,54,32,12,37,7,23,43])`
|
||||
* For floating point values: `ChartData(points: [2.34,3.14,4.56])`
|
||||
* For label,value pairs: `ChartData(values: [("2018 Q4",63150), ("2019 Q1",50900)])`
|
||||
|
||||
|
||||
You can add different formats:
|
||||
* Small `ChartForm.small`
|
||||
* Medium `ChartForm.medium`
|
||||
* Large `ChartForm.large`
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic6.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
BarChartView(data: ChartData(points: [8,23,54,32,12,37,7,23,43]), title: "Title", form: ChartForm.small)
|
||||
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)
|
||||
```
|
||||
|
||||
For floating point numbers, you can set a custom specifier:
|
||||
**Combine chart types in the same frame**
|
||||
|
||||
<p align="left">
|
||||
<img src="Resources/chartpic7.png" width="350px"/>
|
||||
</p>
|
||||
|
||||
```swift
|
||||
BarChartView(data: ChartData(points:[1.23,2.43,3.37]) ,title: "A", valueSpecifier: "%.2f")
|
||||
```
|
||||
For integers you can disable by passing: `valueSpecifier: "%.0f"`
|
||||
|
||||
|
||||
You can set your custom image in the upper right corner by passing in the initialiser: `cornerImage:Image(systemName: "waveform.path.ecg")`
|
||||
|
||||
|
||||
**Turn drop shadow off by adding to the Initialiser: `dropShadow: false`**
|
||||
|
||||
### You can customize styling of the chart with a ChartStyle object:
|
||||
|
||||
Customizable:
|
||||
* background color
|
||||
* accent color
|
||||
* second gradient color
|
||||
* text color
|
||||
* legend text color
|
||||
|
||||
```swift
|
||||
let chartStyle = ChartStyle(backgroundColor: Color.black, accentColor: Colors.OrangeStart, secondGradientColor: Colors.OrangeEnd, chartFormSize: ChartForm.medium, textColor: Color.white, legendTextColor: Color.white )
|
||||
...
|
||||
BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: chartStyle)
|
||||
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)
|
||||
```
|
||||
|
||||
You can access built-in styles:
|
||||
```swift
|
||||
BarChartView(data: [8,23,54,32,12,37,7,23,43], title: "Title", style: Styles.barChartMidnightGreen)
|
||||
```
|
||||
#### All styles available as a preset:
|
||||
* barChartStyleOrangeLight
|
||||
* barChartStyleOrangeDark
|
||||
* barChartStyleNeonBlueLight
|
||||
* barChartStyleNeonBlueDark
|
||||
* barChartMidnightGreenLight
|
||||
* barChartMidnightGreenDark
|
||||
|
||||

|
||||
|
||||

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

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

|
||||
|
||||
You can add a pie chart with the following code:
|
||||
|
||||
```swift
|
||||
PieChartView(data: [8,23,54,32], title: "Title", legend: "Legendary") // legend is optional
|
||||
```
|
||||
|
||||
**Turn drop shadow off by adding to the Initialiser: `dropShadow: false`**
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 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 |
|
Before Width: | Height: | Size: 514 KiB |
|
Before Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 343 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 |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,9 @@ public struct CardView<Content: View>: View, ChartBase {
|
||||
if showShadow {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color.white)
|
||||
.shadow(color: Color.gray, radius: 8)
|
||||
.shadow(color: Color(white: 0.9, opacity: 1), radius: 8)
|
||||
}
|
||||
VStack {
|
||||
VStack (alignment: .leading) {
|
||||
self.content()
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: showShadow ? 20 : 0))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Protocol for any type of chart, to get access to underlying data
|
||||
public protocol ChartBase {
|
||||
public protocol ChartBase: View {
|
||||
var chartData: ChartData { get }
|
||||
}
|
||||
|
||||
@@ -2,12 +2,70 @@ import SwiftUI
|
||||
|
||||
/// An observable wrapper for an array of data for use in any chart
|
||||
public class ChartData: ObservableObject {
|
||||
@Published public var data: [Double] = []
|
||||
@Published public var data: [(Double, Double)] = []
|
||||
public var rangeY: ClosedRange<Double>?
|
||||
public var rangeX: ClosedRange<Double>?
|
||||
|
||||
/// Initialize with data array
|
||||
/// - Parameter data: Array of `Double`
|
||||
public init(_ data: [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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,10 @@ extension Array where Element == ColorGradient {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ extension CGPoint {
|
||||
/// - data: array of `Double`
|
||||
/// - Returns: X and Y delta as a `CGPoint`
|
||||
static func getStep(frame: CGRect, data: [Double]) -> CGPoint {
|
||||
let padding: CGFloat = 30.0
|
||||
let padding: CGFloat = 0
|
||||
|
||||
// stepWidth
|
||||
var stepWidth: CGFloat = 0.0
|
||||
@@ -38,4 +38,10 @@ extension CGPoint {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
extension View where Self: ChartBase {
|
||||
|
||||
/// Set data for a chart
|
||||
/// - Parameter data: array of `Double`
|
||||
/// - Returns: modified `View` with data attached
|
||||
public func data(_ data: [Double]) -> some View {
|
||||
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
|
||||
.environmentObject(chartData)
|
||||
.environmentObject(ChartValue())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Path {
|
||||
|
||||
/// Returns a tiny segment of path based on percentage along the path
|
||||
///
|
||||
/// TODO: Explain why more than 1 gets 0 and why less than 0 gets 1
|
||||
/// - Parameter percent: fraction along data set, between 0.0 and 1.0 (underflow and overflow are handled)
|
||||
/// - Returns: tiny path right around the requested fraction
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// Find the `CGPoint` for the given fraction along the path.
|
||||
///
|
||||
/// This works by requesting a very tiny trimmed section of the path, then getting the center of the bounds rectangle
|
||||
/// - Parameter percent: fraction along data set, between 0.0 and 1.0 (underflow and overflow are handled)
|
||||
/// - Returns: a `CGPoint` representing the location of that section of the path
|
||||
func point(for percent: CGFloat) -> CGPoint {
|
||||
let path = trimmedPath(for: percent)
|
||||
return CGPoint(x: path.boundingRect.midX, y: path.boundingRect.midY)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameter maxX: <#maxX description#>
|
||||
/// - Returns: <#description#>
|
||||
func point(to maxX: CGFloat) -> CGPoint {
|
||||
let total = length
|
||||
let sub = length(to: maxX)
|
||||
let percent = sub / total
|
||||
return point(for: percent)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Returns: <#description#>
|
||||
var length: CGFloat {
|
||||
|
||||
var length: CGFloat {
|
||||
var ret: CGFloat = 0.0
|
||||
var start: CGPoint?
|
||||
var point = CGPoint.zero
|
||||
@@ -73,9 +57,6 @@ extension Path {
|
||||
return ret
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameter maxX: <#maxX description#>
|
||||
/// - Returns: <#description#>
|
||||
func length(to maxX: CGFloat) -> CGFloat {
|
||||
var ret: CGFloat = 0.0
|
||||
var start: CGPoint?
|
||||
@@ -127,19 +108,13 @@ extension Path {
|
||||
return ret
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - points: <#points description#>
|
||||
/// - step: <#step description#>
|
||||
/// - globalOffset: <#globalOffset description#>
|
||||
/// - Returns: <#description#>
|
||||
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 }
|
||||
// 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 {
|
||||
@@ -152,25 +127,85 @@ extension Path {
|
||||
return path
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - points: <#points description#>
|
||||
/// - step: <#step description#>
|
||||
/// - globalOffset: <#globalOffset description#>
|
||||
/// - Returns: <#description#>
|
||||
static func quadClosedCurvedPathWithPoints(points: [Double], step: CGPoint, globalOffset: Double? = nil) -> Path {
|
||||
static func quadCurvedPathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
if points.count < 2 {
|
||||
if data.count < 2 {
|
||||
return path
|
||||
}
|
||||
let offset = globalOffset ?? points.min()!
|
||||
|
||||
// guard let offset = points.min() else { return path }
|
||||
path.move(to: .zero)
|
||||
var point1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)
|
||||
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..<points.count {
|
||||
let point2 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]-offset))
|
||||
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))
|
||||
@@ -181,88 +216,64 @@ extension Path {
|
||||
return path
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - points: <#points description#>
|
||||
/// - step: <#step description#>
|
||||
/// - Returns: <#description#>
|
||||
static func linePathWithPoints(points: [Double], step: CGPoint) -> Path {
|
||||
static func linePathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
if points.count < 2 {
|
||||
if data.count < 2 {
|
||||
return path
|
||||
}
|
||||
guard let offset = points.min() else {
|
||||
return path
|
||||
}
|
||||
let point1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y)
|
||||
|
||||
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..<points.count {
|
||||
let point2 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]-offset))
|
||||
for pointIndex in 1..<data.count {
|
||||
let point2 = CGPoint(x: CGFloat(convertedXValues[pointIndex]), y: CGFloat(convertedYPoints[pointIndex]))
|
||||
path.addLine(to: point2)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - points: <#points description#>
|
||||
/// - step: <#step description#>
|
||||
/// - Returns: <#description#>
|
||||
static func closedLinePathWithPoints(points: [Double], step: CGPoint) -> Path {
|
||||
static func closedLinePathWithPoints(data: [(Double, Double)], in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
if points.count < 2 {
|
||||
if data.count < 2 {
|
||||
return path
|
||||
}
|
||||
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 {
|
||||
point1 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]-offset))
|
||||
path.addLine(to: point1)
|
||||
|
||||
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 {
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - x: <#x description#>
|
||||
/// - Returns: <#description#>
|
||||
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)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameter to: <#to description#>
|
||||
/// - Returns: <#description#>
|
||||
func line(to: CGPoint) -> CGFloat {
|
||||
dist(to: to)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - x: <#x description#>
|
||||
/// - Returns: <#description#>
|
||||
func line(to: CGPoint, x: CGFloat) -> CGFloat {
|
||||
dist(to: point(to: to, x: x))
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - control: <#control description#>
|
||||
/// - Returns: <#description#>
|
||||
func quadCurve(to: CGPoint, control: CGPoint) -> CGFloat {
|
||||
var dist: CGFloat = 0
|
||||
let steps: CGFloat = 100
|
||||
@@ -278,12 +289,6 @@ extension CGPoint {
|
||||
return dist
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - control: <#control description#>
|
||||
/// - x: <#x description#>
|
||||
/// - Returns: <#description#>
|
||||
func quadCurve(to: CGPoint, control: CGPoint, x: CGFloat) -> CGFloat {
|
||||
var dist: CGFloat = 0
|
||||
let steps: CGFloat = 100
|
||||
@@ -309,12 +314,6 @@ extension CGPoint {
|
||||
return dist
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - t: <#t description#>
|
||||
/// - control: <#control description#>
|
||||
/// - Returns: <#description#>
|
||||
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)
|
||||
@@ -322,12 +321,6 @@ extension CGPoint {
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - control1: <#control1 description#>
|
||||
/// - control2: <#control2 description#>
|
||||
/// - Returns: <#description#>
|
||||
func curve(to: CGPoint, control1: CGPoint, control2: CGPoint) -> CGFloat {
|
||||
var dist: CGFloat = 0
|
||||
let steps: CGFloat = 100
|
||||
@@ -345,13 +338,6 @@ extension CGPoint {
|
||||
return dist
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - control1: <#control1 description#>
|
||||
/// - control2: <#control2 description#>
|
||||
/// - x: <#x description#>
|
||||
/// - Returns: <#description#>
|
||||
func curve(to: CGPoint, control1: CGPoint, control2: CGPoint, x: CGFloat) -> CGFloat {
|
||||
var dist: CGFloat = 0
|
||||
let steps: CGFloat = 100
|
||||
@@ -379,13 +365,6 @@ extension CGPoint {
|
||||
return dist
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - to: <#to description#>
|
||||
/// - t: <#t description#>
|
||||
/// - control1: <#control1 description#>
|
||||
/// - control2: <#control2 description#>
|
||||
/// - Returns: <#description#>
|
||||
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)
|
||||
@@ -393,13 +372,6 @@ extension CGPoint {
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - x: <#x description#>
|
||||
/// - y: <#y description#>
|
||||
/// - t: <#t description#>
|
||||
/// - c: <#c description#>
|
||||
/// - Returns: <#description#>
|
||||
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
|
||||
@@ -409,14 +381,6 @@ extension CGPoint {
|
||||
return value
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - x: <#x description#>
|
||||
/// - y: <#y description#>
|
||||
/// - t: <#t description#>
|
||||
/// - control1: <#control1 description#>
|
||||
/// - control2: <#control2 description#>
|
||||
/// - Returns: <#description#>
|
||||
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
|
||||
@@ -427,11 +391,6 @@ extension CGPoint {
|
||||
return value
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - point1: <#point1 description#>
|
||||
/// - point2: <#point2 description#>
|
||||
/// - Returns: <#description#>
|
||||
static func getMidPoint(point1: CGPoint, point2: CGPoint) -> CGPoint {
|
||||
return CGPoint(
|
||||
x: point1.x + (point2.x - point1.x) / 2,
|
||||
@@ -439,29 +398,16 @@ extension CGPoint {
|
||||
)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameter to: <#to description#>
|
||||
/// - Returns: <#description#>
|
||||
func dist(to: CGPoint) -> CGFloat {
|
||||
return sqrt((pow(self.x - to.x, 2) + pow(self.y - to.y, 2)))
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - firstPoint: <#firstPoint description#>
|
||||
/// - secondPoint: <#secondPoint description#>
|
||||
/// - Returns: <#description#>
|
||||
static func midPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint {
|
||||
return CGPoint(
|
||||
x: (firstPoint.x + secondPoint.x) / 2,
|
||||
y: (firstPoint.y + secondPoint.y) / 2)
|
||||
}
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - firstPoint: <#firstPoint description#>
|
||||
/// - secondPoint: <#secondPoint description#>
|
||||
/// - Returns: <#description#>
|
||||
static func controlPointForPoints(firstPoint: CGPoint, secondPoint: CGPoint) -> CGPoint {
|
||||
var controlPoint = CGPoint.midPointForPoints(firstPoint: firstPoint, secondPoint: secondPoint)
|
||||
let diffY = abs(secondPoint.y - controlPoint.y)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -8,4 +8,10 @@ extension View {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
/// <#Description#>
|
||||
public struct ChartGrid<Content: View>: View, ChartBase {
|
||||
public var chartData = ChartData()
|
||||
public struct ChartGrid<Content: View>: View {
|
||||
let content: () -> Content
|
||||
public var gridOptions = GridOptions()
|
||||
|
||||
@EnvironmentObject var data: ChartData
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameter content: <#content description#>
|
||||
public init(@ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
/// The content and behavior of the `ChartGrid`.
|
||||
///
|
||||
/// TODO: Explain why this is in a `ZStack`
|
||||
public var body: some View {
|
||||
ZStack{
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ public enum ChartLabelType {
|
||||
/// 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 private var textToDisplay:String = ""
|
||||
@State var textToDisplay:String = ""
|
||||
var format: String = "%.01f"
|
||||
|
||||
private var title: String
|
||||
|
||||
@@ -38,13 +39,13 @@ public struct ChartLabel: View {
|
||||
private var labelPadding: EdgeInsets {
|
||||
switch labelType {
|
||||
case .title:
|
||||
return EdgeInsets(top: 16.0, leading: 8.0, bottom: 0.0, trailing: 8.0)
|
||||
return EdgeInsets(top: 16.0, leading: 0, bottom: 0.0, trailing: 8.0)
|
||||
case .legend:
|
||||
return EdgeInsets(top: 4.0, leading: 8.0, bottom: 0.0, trailing: 8.0)
|
||||
return EdgeInsets(top: 4.0, leading: 0, bottom: 0.0, trailing: 8.0)
|
||||
case .subTitle:
|
||||
return EdgeInsets(top: 8.0, leading: 8.0, bottom: 0.0, trailing: 8.0)
|
||||
return EdgeInsets(top: 8.0, leading: 0, bottom: 0.0, trailing: 8.0)
|
||||
case .largeTitle:
|
||||
return EdgeInsets(top: 24.0, leading: 8.0, bottom: 0.0, trailing: 8.0)
|
||||
return EdgeInsets(top: 24.0, leading: 0, bottom: 0.0, trailing: 8.0)
|
||||
case .custom(_, let padding, _):
|
||||
return padding
|
||||
}
|
||||
@@ -58,13 +59,13 @@ public struct ChartLabel: View {
|
||||
private var labelColor: Color {
|
||||
switch labelType {
|
||||
case .title:
|
||||
return Color(UIColor.label)
|
||||
return Color.primary
|
||||
case .legend:
|
||||
return Color(UIColor.secondaryLabel)
|
||||
return Color.secondary
|
||||
case .subTitle:
|
||||
return Color(UIColor.label)
|
||||
return Color.primary
|
||||
case .largeTitle:
|
||||
return Color(UIColor.label)
|
||||
return Color.primary
|
||||
case .custom(_, _, let color):
|
||||
return color
|
||||
}
|
||||
@@ -74,10 +75,12 @@ public struct ChartLabel: View {
|
||||
/// - Parameters:
|
||||
/// - title: Any `String`
|
||||
/// - type: Which `ChartLabelType` to use
|
||||
public init(_ title: String,
|
||||
type: ChartLabelType = .title) {
|
||||
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`.
|
||||
@@ -94,7 +97,7 @@ public struct ChartLabel: View {
|
||||
self.textToDisplay = self.title
|
||||
}
|
||||
.onReceive(self.chartValue.objectWillChange) { _ in
|
||||
self.textToDisplay = self.chartValue.interactionInProgress ? String(format: "%.01f", self.chartValue.currentValue) : self.title
|
||||
self.textToDisplay = self.chartValue.interactionInProgress ? String(format: format, self.chartValue.currentValue) : self.title
|
||||
}
|
||||
if !self.chartValue.interactionInProgress {
|
||||
Spacer()
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Descripton of colors/styles for any kind of chart
|
||||
public class ChartStyle: ObservableObject {
|
||||
|
||||
/// colors for background are of chart
|
||||
public let backgroundColor: ColorGradient
|
||||
/// colors for foreground fill of chart
|
||||
public let foregroundColor: [ColorGradient]
|
||||
|
||||
/// Initialize with a single background color and an array of `ColorGradient` for the foreground
|
||||
/// - Parameters:
|
||||
/// - backgroundColor: a `Color`
|
||||
/// - foregroundColor: array of `ColorGradient`
|
||||
public init(backgroundColor: Color, foregroundColor: [ColorGradient]) {
|
||||
self.backgroundColor = ColorGradient.init(backgroundColor)
|
||||
self.foregroundColor = foregroundColor
|
||||
}
|
||||
|
||||
/// Initialize with a single background color and a single `ColorGradient` for the foreground
|
||||
/// - Parameters:
|
||||
/// - backgroundColor: a `Color`
|
||||
/// - foregroundColor: a `ColorGradient`
|
||||
public init(backgroundColor: Color, foregroundColor: ColorGradient) {
|
||||
self.backgroundColor = ColorGradient.init(backgroundColor)
|
||||
self.foregroundColor = [foregroundColor]
|
||||
}
|
||||
|
||||
/// Initialize with a single background `ColorGradient` and a single `ColorGradient` for the foreground
|
||||
/// - Parameters:
|
||||
/// - backgroundColor: a `ColorGradient`
|
||||
/// - foregroundColor: a `ColorGradient`
|
||||
public init(backgroundColor: ColorGradient, foregroundColor: ColorGradient) {
|
||||
self.backgroundColor = backgroundColor
|
||||
self.foregroundColor = [foregroundColor]
|
||||
}
|
||||
|
||||
/// Initialize with a single background `ColorGradient` and an array of `ColorGradient` for the foreground
|
||||
/// - Parameters:
|
||||
/// - backgroundColor: a `ColorGradient`
|
||||
/// - foregroundColor: array of `ColorGradient`
|
||||
public init(backgroundColor: ColorGradient, foregroundColor: [ColorGradient]) {
|
||||
self.backgroundColor = backgroundColor
|
||||
self.foregroundColor = foregroundColor
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
/// An encapsulation of a simple gradient between one color and another
|
||||
public struct ColorGradient: Equatable {
|
||||
public let startColor: Color
|
||||
public let endColor: Color
|
||||
|
||||
/// Initialize as a solid color
|
||||
/// - Parameter color: a single `Color` (no gradient effect visible)
|
||||
public init(_ color: Color) {
|
||||
self.startColor = color
|
||||
self.endColor = color
|
||||
}
|
||||
|
||||
/// Initialize a color gradient from two specified colors
|
||||
/// - Parameters:
|
||||
/// - startColor: starting color
|
||||
/// - endColor: ending color
|
||||
public init(_ startColor: Color, _ endColor: Color) {
|
||||
self.startColor = startColor
|
||||
self.endColor = endColor
|
||||
}
|
||||
|
||||
/// Convert to a `Gradient` object (more complicated than just two colors)
|
||||
/// - Returns: a `Gradient` between the specified start and end colors
|
||||
public var gradient: Gradient {
|
||||
return Gradient(colors: [startColor, endColor])
|
||||
}
|
||||
}
|
||||
|
||||
extension ColorGradient {
|
||||
/// Convenience method to return a SwiftUI LinearGradient view from the ColorGradient
|
||||
/// - Parameters:
|
||||
/// - startPoint: starting point
|
||||
/// - endPoint: ending point
|
||||
/// - Returns: a Linear gradient
|
||||
public func linearGradient(from startPoint: UnitPoint, to endPoint: UnitPoint) -> LinearGradient {
|
||||
return LinearGradient(gradient: self.gradient, startPoint: startPoint, endPoint: endPoint)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Some predefined colors, used for demos, defaults if color is missing, and data indicator point
|
||||
public enum ChartColors {
|
||||
// Orange
|
||||
public static let orangeBright = Color(hexString: "#FF782C")
|
||||
public static let orangeDark = Color(hexString: "#EC2301")
|
||||
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A type of chart that displays vertical bars for each data point
|
||||
public struct BarChart: View, ChartBase {
|
||||
public struct BarChart: ChartBase {
|
||||
public var chartData = ChartData()
|
||||
|
||||
@EnvironmentObject var data: ChartData
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
/// The content and behavior of the `BarChart`.
|
||||
///
|
||||
///
|
||||
public var body: some View {
|
||||
BarChartRow(chartData: data, style: style)
|
||||
BarChartRow(chartData: chartData, style: style)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -1,52 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A single vertical bar in a `BarChart`
|
||||
public struct BarChartCell: View {
|
||||
var value: Double
|
||||
var index: Int = 0
|
||||
var width: Float
|
||||
var numberOfDataPoints: Int
|
||||
var gradientColor: ColorGradient
|
||||
var touchLocation: CGFloat
|
||||
|
||||
var cellWidth: Double {
|
||||
return Double(width)/(Double(numberOfDataPoints) * 1.5)
|
||||
}
|
||||
|
||||
@State private var firstDisplay: Bool = true
|
||||
@State private var didCellAppear: Bool = false
|
||||
|
||||
public init( value: Double,
|
||||
index: Int = 0,
|
||||
width: Float,
|
||||
numberOfDataPoints: Int,
|
||||
gradientColor: ColorGradient,
|
||||
touchLocation: CGFloat) {
|
||||
self.value = value
|
||||
self.index = index
|
||||
self.width = width
|
||||
self.numberOfDataPoints = numberOfDataPoints
|
||||
self.gradientColor = gradientColor
|
||||
self.touchLocation = touchLocation
|
||||
}
|
||||
|
||||
/// The content and behavior of the `BarChartCell`.
|
||||
///
|
||||
/// Animated when first displayed, using the `firstDisplay` variable, with an increasing delay through the data set.
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(gradientColor.linearGradient(from: .bottom, to: .top))
|
||||
}
|
||||
.frame(width: CGFloat(self.cellWidth))
|
||||
.scaleEffect(CGSize(width: 1, height: self.firstDisplay ? 0.0 : self.value), anchor: .bottom)
|
||||
.onAppear {
|
||||
self.firstDisplay = false
|
||||
BarChartCellShape(value: didCellAppear ? value : 0.0)
|
||||
.fill(gradientColor.linearGradient(from: .bottom, to: .top)) .onAppear {
|
||||
self.didCellAppear = true
|
||||
}
|
||||
.onDisappear {
|
||||
self.firstDisplay = true
|
||||
self.didCellAppear = false
|
||||
}
|
||||
.transition(.slide)
|
||||
.animation(Animation.spring().delay(self.touchLocation < 0 || !firstDisplay ? Double(self.index) * 0.04 : 0))
|
||||
.animation(Animation.spring().delay(self.touchLocation < 0 || !didCellAppear ? Double(self.index) * 0.04 : 0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,17 +35,18 @@ struct BarChartCell_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
Group {
|
||||
BarChartCell(value: 0, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
|
||||
BarChartCell(value: 0, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
|
||||
.padding(50)
|
||||
|
||||
BarChartCell(value: 1, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
|
||||
BarChartCell(value: 1, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat())
|
||||
BarChartCell(value: 1, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient(.purple), touchLocation: CGFloat())
|
||||
BarChartCell(value: 0.5, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
|
||||
BarChartCell(value: 0.75, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat())
|
||||
BarChartCell(value: 1, gradientColor: ColorGradient(.purple), touchLocation: CGFloat())
|
||||
}
|
||||
|
||||
Group {
|
||||
BarChartCell(value: 1, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient.greenRed, touchLocation: CGFloat())
|
||||
BarChartCell(value: 1, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient.whiteBlack, touchLocation: CGFloat())
|
||||
BarChartCell(value: 1, width: 50, numberOfDataPoints: 1, gradientColor: ColorGradient(.purple), touchLocation: CGFloat())
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,34 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A single row of data, a view in a `BarChart`
|
||||
public struct BarChartRow: View {
|
||||
@EnvironmentObject var chartValue: ChartValue
|
||||
@ObservedObject var chartData: ChartData
|
||||
@State private var touchLocation: CGFloat = -1.0
|
||||
|
||||
enum Constant {
|
||||
static let spacing: CGFloat = 16.0
|
||||
}
|
||||
|
||||
var style: ChartStyle
|
||||
|
||||
var maxValue: Double {
|
||||
guard let max = chartData.data.max() else {
|
||||
guard let max = chartData.points.max() else {
|
||||
return 1
|
||||
}
|
||||
return max != 0 ? max : 1
|
||||
}
|
||||
|
||||
/// The content and behavior of the `BarChartRow`.
|
||||
///
|
||||
/// Shows each `BarChartCell` in an `HStack`; may be scaled up if it's the one currently being touched.
|
||||
/// Not using a drawing group for optimizing animation.
|
||||
/// As touched (dragged) the `touchLocation` is updated and the current value is highlighted.
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
HStack(alignment: .bottom,
|
||||
spacing: (geometry.frame(in: .local).width - Constant.spacing) / CGFloat(self.chartData.data.count * 3)) {
|
||||
ForEach(0..<self.chartData.data.count, id: \.self) { index in
|
||||
BarChartCell(value: self.normalizedValue(index: index),
|
||||
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,
|
||||
width: Float(geometry.frame(in: .local).width - Constant.spacing),
|
||||
numberOfDataPoints: self.chartData.data.count,
|
||||
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()
|
||||
// .drawingGroup()
|
||||
}
|
||||
.padding([.top, .leading, .trailing], 10)
|
||||
.frame(maxHeight: chartData.isInNegativeDomain ? geometry.size.height / 2 : geometry.size.height)
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
let width = geometry.frame(in: .local).width
|
||||
@@ -58,18 +46,6 @@ public struct BarChartRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Value relative to maximum value
|
||||
/// - Parameter index: index into array of data
|
||||
/// - Returns: data value at given index, divided by data maximum
|
||||
func normalizedValue(index: Int) -> Double {
|
||||
return Double(chartData.data[index])/Double(maxValue)
|
||||
}
|
||||
|
||||
/// Size to scale the touch indicator
|
||||
/// - Parameters:
|
||||
/// - touchLocation: fraction of width where touch is happening
|
||||
/// - index: index into data array
|
||||
/// - Returns: a scale larger than 1.0 if in bounds; 1.0 (unscaled) if not in bounds
|
||||
func getScaleSize(touchLocation: CGFloat, index: Int) -> CGSize {
|
||||
if touchLocation > CGFloat(index)/CGFloat(chartData.data.count) &&
|
||||
touchLocation < CGFloat(index+1)/CGFloat(chartData.data.count) {
|
||||
@@ -78,12 +54,17 @@ public struct BarChartRow: View {
|
||||
return CGSize(width: 1, height: 1)
|
||||
}
|
||||
|
||||
/// Get data value where touch happened
|
||||
/// - Parameter width: width of chart
|
||||
/// - Returns: value as `Double` if chart has data
|
||||
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.data[index]
|
||||
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,18 +1,6 @@
|
||||
//
|
||||
// IndicatorPoint.swift
|
||||
// LineChart
|
||||
//
|
||||
// Created by András Samu on 2019. 09. 03..
|
||||
// Copyright © 2019. András Samu. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A dot representing a single data point as user moves finger over line in `LineChart`
|
||||
struct IndicatorPoint: View {
|
||||
/// The content and behavior of the `IndicatorPoint`.
|
||||
///
|
||||
/// A filled circle with a thick white outline and a shadow
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
|
||||
@@ -2,103 +2,63 @@ import SwiftUI
|
||||
|
||||
/// A single line of data, a view in a `LineChart`
|
||||
public struct Line: View {
|
||||
@EnvironmentObject var chartValue: ChartValue
|
||||
@State private var frame: CGRect = .zero
|
||||
@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 showFull: Bool = false
|
||||
@State private var showBackground: Bool = true
|
||||
var curvedLines: Bool = true
|
||||
@State private var didCellAppear: Bool = false
|
||||
|
||||
/// Step for plotting through data
|
||||
/// - Returns: X and Y delta between each data point based on data and view's frame
|
||||
var step: CGPoint {
|
||||
return CGPoint.getStep(frame: frame, data: chartData.data)
|
||||
}
|
||||
|
||||
/// Path of line graph
|
||||
/// - Returns: A path for stroking representing the data, either curved or jagged.
|
||||
var path: Path {
|
||||
let points = chartData.data
|
||||
Path.quadCurvedPathWithPoints(points: chartData.normalisedPoints,
|
||||
step: CGPoint(x: 1.0, y: 1.0))
|
||||
}
|
||||
|
||||
if curvedLines {
|
||||
return Path.quadCurvedPathWithPoints(points: points,
|
||||
step: step,
|
||||
globalOffset: nil)
|
||||
}
|
||||
|
||||
return Path.linePathWithPoints(points: points, step: step)
|
||||
public init(chartData: ChartData,
|
||||
style: ChartStyle,
|
||||
chartProperties: LineChartProperties) {
|
||||
self.chartData = chartData
|
||||
self.style = style
|
||||
self.chartProperties = chartProperties
|
||||
}
|
||||
|
||||
/// Path of linegraph, but also closed at the bottom side
|
||||
/// - Returns: A path for filling representing the data, either curved or jagged
|
||||
var closedPath: Path {
|
||||
let points = chartData.data
|
||||
|
||||
if curvedLines {
|
||||
return Path.quadClosedCurvedPathWithPoints(points: points,
|
||||
step: step,
|
||||
globalOffset: nil)
|
||||
}
|
||||
|
||||
return Path.closedLinePathWithPoints(points: points, step: step)
|
||||
}
|
||||
|
||||
/// The content and behavior of the `Line`.
|
||||
///
|
||||
/// Draw the background if showing the full line (?) and the `showBackground` option is set. Above that draw the line, and then the data indicator if the graph is currently being touched.
|
||||
/// On appear, set the frame so that the data graph metrics can be calculated. On a drag (touch) gesture, highlight the closest touched data point.
|
||||
/// TODO: explain rotation
|
||||
public var body: some View {
|
||||
|
||||
let orientationChanged = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
|
||||
.makeConnectable()
|
||||
.autoconnect() // see https://stackoverflow.com/a/62370919
|
||||
// This lets geometry be recalculated when device rotates. However it doesn't cover issue of app changing
|
||||
// from full screen to split view. Not possible in SwiftUI? Feedback submitted to apple FB8451194.
|
||||
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
if self.showFull && self.showBackground {
|
||||
self.getBackgroundPathView()
|
||||
}
|
||||
self.getLinePathView()
|
||||
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))
|
||||
if self.didCellAppear, let backgroundColor = chartProperties.backgroundGradient {
|
||||
LineBackgroundShapeView(chartData: chartData,
|
||||
geometry: geometry,
|
||||
backgroundColor: backgroundColor)
|
||||
}
|
||||
lineShapeView(geometry: geometry)
|
||||
}
|
||||
.onAppear {
|
||||
self.frame = geometry.frame(in: .local)
|
||||
|
||||
didCellAppear = true
|
||||
}
|
||||
.onReceive(orientationChanged) { _ in
|
||||
// When we receive notification here, the geometry is still the old value
|
||||
// so delay evaluation to get the new frame!
|
||||
DispatchQueue.main.async {
|
||||
self.frame = geometry.frame(in: .local) // recalculate layout with new frame
|
||||
}
|
||||
}
|
||||
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
self.touchLocation = value.location
|
||||
self.showIndicator = true
|
||||
self.getClosestDataPoint(point: self.getClosestPointOnPath(touchLocation: value.location))
|
||||
self.chartValue.interactionInProgress = true
|
||||
})
|
||||
.onEnded({ value in
|
||||
self.touchLocation = .zero
|
||||
self.showIndicator = false
|
||||
self.chartValue.interactionInProgress = false
|
||||
})
|
||||
)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,79 +66,46 @@ public struct Line: View {
|
||||
// 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(touchLocation: CGPoint) -> CGPoint {
|
||||
let closest = self.path.point(to: touchLocation.x)
|
||||
return closest
|
||||
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(point: CGPoint) {
|
||||
let index = Int(round((point.x)/step.x))
|
||||
// /// 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.data[index]
|
||||
// self.chartValue.currentValue = self.chartData.points[index]
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the view representing the filled in background below the chart, filled with the foreground color's gradient
|
||||
///
|
||||
/// TODO: explain rotations
|
||||
/// - Returns: SwiftUI `View`
|
||||
private func getBackgroundPathView() -> some View {
|
||||
self.closedPath
|
||||
.fill(LinearGradient(gradient: Gradient(colors: [
|
||||
style.foregroundColor.first?.startColor ?? .white,
|
||||
style.foregroundColor.first?.endColor ?? .white,
|
||||
.clear]),
|
||||
startPoint: .bottom,
|
||||
endPoint: .top))
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
.opacity(0.2)
|
||||
.transition(.opacity)
|
||||
.animation(.easeIn(duration: 1.6))
|
||||
}
|
||||
|
||||
/// Get the view representing the line stroked in the `foregroundColor`
|
||||
///
|
||||
/// TODO: Explain how `showFull` works
|
||||
/// TODO: explain rotations
|
||||
/// - Returns: SwiftUI `View`
|
||||
private func getLinePathView() -> some View {
|
||||
self.path
|
||||
.trim(from: 0, to: self.showFull ? 1:0)
|
||||
.stroke(LinearGradient(gradient: style.foregroundColor.first?.gradient ?? ColorGradient.orangeBright.gradient,
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing),
|
||||
style: StrokeStyle(lineWidth: 3, lineJoin: .round))
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
.animation(Animation.easeOut(duration: 1.2))
|
||||
.onAppear {
|
||||
self.showFull = true
|
||||
}
|
||||
.onDisappear {
|
||||
self.showFull = false
|
||||
}
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
|
||||
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, 43]), style: blackLineStyle)
|
||||
Line(chartData: ChartData([8, 23, 32, 7, 23, 43]), style: redLineStyle)
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Predefined style, black over white, for preview
|
||||
private let blackLineStyle = ChartStyle(backgroundColor: ColorGradient(.white), foregroundColor: ColorGradient(.black))
|
||||
|
||||
/// Predefined stylem red over white, for preview
|
||||
private let redLineStyle = ChartStyle(backgroundColor: .whiteBlack, foregroundColor: ColorGradient(.red))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A type of chart that displays a line connecting the data points
|
||||
public struct LineChart: View, ChartBase {
|
||||
public struct LineChart: ChartBase {
|
||||
public var chartData = ChartData()
|
||||
|
||||
@EnvironmentObject var data: ChartData
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
public var chartProperties = LineChartProperties()
|
||||
|
||||
/// The content and behavior of the `LineChart`.
|
||||
///
|
||||
///
|
||||
public var body: some View {
|
||||
Line(chartData: data, style: style)
|
||||
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
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A type of chart that displays a slice of "pie" for each data point
|
||||
public struct PieChart: View, ChartBase {
|
||||
public struct PieChart: ChartBase {
|
||||
public var chartData = ChartData()
|
||||
|
||||
@EnvironmentObject var data: ChartData
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
/// The content and behavior of the `PieChart`.
|
||||
///
|
||||
///
|
||||
public var body: some View {
|
||||
PieChartRow(chartData: data, style: style)
|
||||
PieChartRow(chartData: chartData, style: style)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
//
|
||||
// PieChartCell.swift
|
||||
// SwiftUICharts
|
||||
//
|
||||
// Created by Nicolas Savoini on 2020-05-24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// One slice of a `PieChartRow`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
//
|
||||
// PieChartRow.swift
|
||||
// SwiftUICharts
|
||||
//
|
||||
// Created by Nicolas Savoini on 2020-05-24.
|
||||
//
|
||||
|
||||
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.data.reduce(0, +)
|
||||
let maxValue: Double = chartData.points.reduce(0, +)
|
||||
|
||||
for slice in chartData.data {
|
||||
for slice in chartData.points {
|
||||
let normalized: Double = Double(slice) / (maxValue == 0 ? 1 : maxValue)
|
||||
let startDeg = lastEndDeg
|
||||
let endDeg = lastEndDeg + (normalized * 360)
|
||||
@@ -29,59 +23,47 @@ public struct PieChartRow: View {
|
||||
return tempSlices
|
||||
}
|
||||
|
||||
/// The content and behavior of the `PieChartRow`.
|
||||
///
|
||||
///
|
||||
@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)
|
||||
)
|
||||
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
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct PieChartRow_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
//Empty Array - Default Colors.OrangeStart
|
||||
PieChartRow(
|
||||
chartData: ChartData([8, 23, 32, 7, 23, 43]),
|
||||
style: defaultMultiColorChartStyle)
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
PieChartRow(
|
||||
chartData: ChartData([8, 23, 32, 7, 23, 43]),
|
||||
style: multiColorChartStyle)
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
PieChartRow(
|
||||
chartData: ChartData([8, 23, 32, 7, 23, 43]),
|
||||
style: multiColorChartStyle)
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
}.previewLayout(.fixed(width: 125, height: 125))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/// Predefined color style, for preview
|
||||
private let defaultMultiColorChartStyle = ChartStyle(
|
||||
backgroundColor: Color.white,
|
||||
foregroundColor: [ColorGradient]())
|
||||
|
||||
/// Predefined color style, for preview
|
||||
private let multiColorChartStyle = ChartStyle(
|
||||
backgroundColor: Color.purple,
|
||||
foregroundColor: [ColorGradient.greenRed, ColorGradient.whiteBlack])
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
//
|
||||
// RingsChart.swift
|
||||
// ChartViewV2Demo
|
||||
//
|
||||
// Created by Dan Wood on 8/20/20.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct RingsChart: View, ChartBase {
|
||||
public struct RingsChart: ChartBase {
|
||||
public var chartData = ChartData()
|
||||
|
||||
@EnvironmentObject var data: 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: data, style: style)
|
||||
RingsChartRow(width:10.0, spacing:5.0, chartData: chartData, style: style)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public struct RingsChartRow: View {
|
||||
// make sure it doesn't get to crazy value
|
||||
)
|
||||
|
||||
Ring(ringWidth:scaledWidth, percent: self.chartData.data[index], foregroundColor:self.style.foregroundColor.rotate(for: index),
|
||||
Ring(ringWidth:scaledWidth, percent: self.chartData.points[index], foregroundColor:self.style.foregroundColor.rotate(for: index),
|
||||
touchLocation: self.touchRadius)
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ public struct RingsChartRow: View {
|
||||
func getCurrentValue(maxRadius: CGFloat) -> Double? {
|
||||
|
||||
guard let index = self.touchedCircleIndex(maxRadius: maxRadius) else { return nil }
|
||||
return self.chartData.data[index]
|
||||
return self.chartData.points[index]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Nicolas Savoini on 2020-05-25.
|
||||
//
|
||||
|
||||
@testable import SwiftUICharts
|
||||
import XCTest
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
```
|
||||