Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4108f7f9be | |||
| 71b8e45862 | |||
| 7861bbcad1 | |||
| 84578d2f6f | |||
| 9210d01137 | |||
| 8ee353c93a | |||
| 51db5a067a | |||
| ed01f5305d | |||
| 2ef73c84e2 | |||
| 7fb2a0013c | |||
| 3265d3e16b | |||
| c46902dab8 |
Generated
BIN
Binary file not shown.
@@ -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>
|
||||
+14
-1
@@ -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,24 +1,37 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct CardView<Content: View>: View {
|
||||
@Environment(\.chartStyle) private var chartStyle
|
||||
|
||||
/// View containing data and some kind of chart content
|
||||
public struct CardView<Content: View>: View, ChartBase {
|
||||
public var chartData = ChartData()
|
||||
let content: () -> Content
|
||||
|
||||
public init(@ViewBuilder content: @escaping () -> Content) {
|
||||
private var showShadow: Bool
|
||||
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
/// Initialize with view options and a nested `ViewBuilder`
|
||||
/// - Parameters:
|
||||
/// - showShadow: should card have a rounded-rectangle shadow around it
|
||||
/// - content: <#content description#>
|
||||
public init(showShadow: Bool = true, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.showShadow = showShadow
|
||||
self.content = content
|
||||
}
|
||||
|
||||
/// The content and behavior of the `CardView`.
|
||||
///
|
||||
///
|
||||
public var body: some View {
|
||||
ZStack{
|
||||
Rectangle()
|
||||
.fill(Color.white)
|
||||
.cornerRadius(20)
|
||||
.shadow(color: Color.gray, radius: 8)
|
||||
VStack {
|
||||
if showShadow {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color.white)
|
||||
.shadow(color: Color(white: 0.9, opacity: 1), radius: 8)
|
||||
}
|
||||
VStack (alignment: .leading) {
|
||||
self.content()
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.clipShape(RoundedRectangle(cornerRadius: showShadow ? 20 : 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AnyChartType: ChartType {
|
||||
private let chartMaker: (ChartType.Data, ChartType.Style) -> AnyView
|
||||
|
||||
init<S: ChartType>(_ type: S) {
|
||||
self.chartMaker = type.makeTypeErasedBody
|
||||
}
|
||||
|
||||
func makeChart(data: ChartType.Data, style: ChartType.Style) -> AnyView {
|
||||
self.chartMaker(data, style)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension ChartType {
|
||||
func makeTypeErasedBody(data: ChartType.Data, style: ChartType.Style) -> AnyView {
|
||||
AnyView(makeChart(data: data, style: style))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Protocol for any type of chart, to get access to underlying data
|
||||
public protocol ChartBase {
|
||||
var chartData: ChartData { get }
|
||||
}
|
||||
@@ -1,9 +1,41 @@
|
||||
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: [(String, Double)] = []
|
||||
|
||||
var points: [Double] {
|
||||
data.map { $0.1 }
|
||||
}
|
||||
|
||||
var values: [String] {
|
||||
data.map { $0.0 }
|
||||
}
|
||||
|
||||
var normalisedPoints: [Double] {
|
||||
let absolutePoints = points.map { abs($0) }
|
||||
return points.map { $0 / (absolutePoints.max() ?? 1.0) }
|
||||
}
|
||||
|
||||
var normalisedRange: Double {
|
||||
(normalisedPoints.max() ?? 0.0) - (normalisedPoints.min() ?? 0.0)
|
||||
}
|
||||
|
||||
var isInNegativeDomain: Bool {
|
||||
(points.min() ?? 0.0) < 0
|
||||
}
|
||||
|
||||
/// Initialize with data array
|
||||
/// - Parameter data: Array of `Double`
|
||||
public init(_ data: [Double]) {
|
||||
self.data = data.map { ("", $0) }
|
||||
}
|
||||
|
||||
public init(_ data: [(String, Double)]) {
|
||||
self.data = data
|
||||
}
|
||||
|
||||
public init() {
|
||||
self.data = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
|
||||
public protocol ChartType {
|
||||
associatedtype Body: View
|
||||
|
||||
func makeChart(data: Self.Data, style: Self.Style) -> Self.Body
|
||||
|
||||
typealias Data = ChartData
|
||||
typealias Style = ChartStyle
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Representation of a single data point in a chart that is being observed
|
||||
public class ChartValue: ObservableObject {
|
||||
@Published var currentValue: Double = 0
|
||||
@Published var interactionInProgress: Bool = false
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct ChartView: View {
|
||||
@Environment(\.chartType) private var chartType
|
||||
@Environment(\.chartStyle) private var chartStyle
|
||||
|
||||
private var data: ChartData
|
||||
|
||||
public init(data: ChartData) {
|
||||
self.data = data
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
self.chartType.makeChart(data: data, style: chartStyle)
|
||||
}
|
||||
}
|
||||
|
||||
extension ChartView {
|
||||
public init(points: [Double]) {
|
||||
self.data = ChartData(points)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Nicolas Savoini on 2020-05-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Array where Element == ColorGradient {
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameter index: offset in data table
|
||||
/// - Returns: <#description#>
|
||||
func rotate(for index: Int) -> ColorGradient {
|
||||
if self.isEmpty {
|
||||
return ColorGradient.orangeBright
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
extension CGPoint {
|
||||
|
||||
/// Calculate X and Y delta for each data point, based on data min/max and enclosing frame.
|
||||
/// - Parameters:
|
||||
/// - frame: Rectangle of enclosing frame
|
||||
/// - data: array of `Double`
|
||||
/// - Returns: X and Y delta as a `CGPoint`
|
||||
static func getStep(frame: CGRect, data: [Double]) -> CGPoint {
|
||||
let padding: CGFloat = 30.0
|
||||
let padding: CGFloat = 0
|
||||
|
||||
// stepWidth
|
||||
var stepWidth: CGFloat = 0.0
|
||||
@@ -32,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,15 +1,10 @@
|
||||
//
|
||||
// CGRect+Extension.swift
|
||||
// SwiftUICharts
|
||||
//
|
||||
// Created by Nicolas Savoini on 2020-05-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension CGRect {
|
||||
// Return the coordinate for a rectangle center
|
||||
|
||||
/// Midpoint of rectangle
|
||||
/// - Returns: the coordinate for a rectangle center
|
||||
public var mid: CGPoint {
|
||||
return CGPoint(x: self.midX, y: self.midY)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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 {
|
||||
chartData.data = data.map { ("", $0) }
|
||||
return self
|
||||
.environmentObject(chartData)
|
||||
.environmentObject(ChartValue())
|
||||
}
|
||||
|
||||
public func data(_ data: [(String, Double)]) -> some View {
|
||||
chartData.data = data
|
||||
return self
|
||||
.environmentObject(chartData)
|
||||
.environmentObject(ChartValue())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
/// Create a `Color` from a hexadecimal representation
|
||||
/// - Parameter hexString: 3, 6, or 8-character string, with optional (ignored) punctuation such as "#"
|
||||
init(hexString: String) {
|
||||
let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int = UInt64()
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
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 {
|
||||
// percent difference between points
|
||||
let boundsDistance: CGFloat = 0.001
|
||||
let completion: CGFloat = 1 - boundsDistance
|
||||
|
||||
let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
|
||||
|
||||
|
||||
// Start/end points centered around given percentage, but capped if right at the very end
|
||||
let start = pct > completion ? completion : pct - boundsDistance
|
||||
let end = pct > completion ? 1 : pct + boundsDistance
|
||||
return trimmedPath(from: start, to: end)
|
||||
}
|
||||
|
||||
|
||||
/// 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)
|
||||
@@ -25,7 +39,9 @@ extension Path {
|
||||
return point(for: percent)
|
||||
}
|
||||
|
||||
var length: CGFloat {
|
||||
/// <#Description#>
|
||||
/// - Returns: <#description#>
|
||||
var length: CGFloat {
|
||||
var ret: CGFloat = 0.0
|
||||
var start: CGPoint?
|
||||
var point = CGPoint.zero
|
||||
@@ -56,7 +72,10 @@ 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?
|
||||
@@ -107,7 +126,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 {
|
||||
@@ -126,7 +151,13 @@ 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 {
|
||||
var path = Path()
|
||||
if points.count < 2 {
|
||||
@@ -149,7 +180,12 @@ extension Path {
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - points: <#points description#>
|
||||
/// - step: <#step description#>
|
||||
/// - Returns: <#description#>
|
||||
static func linePathWithPoints(points: [Double], step: CGPoint) -> Path {
|
||||
var path = Path()
|
||||
if points.count < 2 {
|
||||
@@ -166,7 +202,12 @@ extension Path {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
|
||||
/// <#Description#>
|
||||
/// - Parameters:
|
||||
/// - points: <#points description#>
|
||||
/// - step: <#step description#>
|
||||
/// - Returns: <#description#>
|
||||
static func closedLinePathWithPoints(points: [Double], step: CGPoint) -> Path {
|
||||
var path = Path()
|
||||
if points.count < 2 {
|
||||
@@ -189,20 +230,39 @@ extension 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
|
||||
@@ -217,7 +277,13 @@ 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
|
||||
@@ -242,14 +308,26 @@ 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)
|
||||
|
||||
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
|
||||
@@ -266,7 +344,14 @@ 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
|
||||
@@ -293,14 +378,28 @@ 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)
|
||||
|
||||
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
|
||||
@@ -309,7 +408,15 @@ extension CGPoint {
|
||||
value += pow(t, 2) * y
|
||||
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
|
||||
@@ -319,24 +426,42 @@ extension CGPoint {
|
||||
value += pow(t, 3) * y
|
||||
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,
|
||||
y: point1.y + (point2.y - point1.y) / 2
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/// <#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)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
public func type<S>(_ type: S) -> some View where S: ChartType {
|
||||
self.environment(\.chartType, AnyChartType(type))
|
||||
}
|
||||
|
||||
public func style(_ style: ChartStyle) -> some View {
|
||||
self.environment(\.chartStyle, style)
|
||||
/// Attach chart style to a View
|
||||
/// - Parameter style: chart style
|
||||
/// - Returns: `View` with chart style attached
|
||||
public func chartStyle(_ style: ChartStyle) -> some View {
|
||||
self.environmentObject(style)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,52 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct ChartGrid<Content: View>: View {
|
||||
public struct ChartGrid<Content: View>: View, ChartBase {
|
||||
public var chartData = ChartData()
|
||||
let content: () -> Content
|
||||
let numberOfHorizontalLines = 4
|
||||
|
||||
@EnvironmentObject var data: ChartData
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
public init(@ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack{
|
||||
self.content()
|
||||
HStack {
|
||||
ZStack {
|
||||
VStack {
|
||||
ForEach(0..<numberOfHorizontalLines) { _ in
|
||||
GridElement()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
self.content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GridElement: View {
|
||||
var body: some View {
|
||||
DashedLine()
|
||||
.frame(maxHeight: 2, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
struct DashedLine: View {
|
||||
func line(frame: CGRect) -> Path {
|
||||
let baseLine: CGFloat = CGFloat(frame.height / 2)
|
||||
var hLine = Path()
|
||||
hLine.move(to: CGPoint(x:0, y: baseLine))
|
||||
hLine.addLine(to: CGPoint(x: frame.width, y: baseLine))
|
||||
return hLine
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
line(frame: geometry.frame(in: .local))
|
||||
.stroke(Color(white: 0.85), style: StrokeStyle(lineWidth: 1, lineCap: .round, dash: [5, 10]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
/// What kind of label - this affects color, size, position of the label
|
||||
public enum ChartLabelType {
|
||||
case title
|
||||
case subTitle
|
||||
case largeTitle
|
||||
case custom(size: CGFloat)
|
||||
case custom(size: CGFloat, padding: EdgeInsets, color: Color)
|
||||
case legend
|
||||
}
|
||||
|
||||
/// A chart may contain any number of labels in pre-set positions based on their `ChartLabelType`
|
||||
public struct ChartLabel: View {
|
||||
@Environment(\.chartValue) private var chartValue: ChartValue
|
||||
|
||||
@State var textToDisplay = ""
|
||||
@State var isInteractionInProgress: Bool = false
|
||||
@EnvironmentObject var chartValue: ChartValue
|
||||
@State var textToDisplay:String = ""
|
||||
var format: String = "%.01f"
|
||||
|
||||
private var title: String
|
||||
|
||||
/// Label font size
|
||||
/// - Returns: the font size of the label
|
||||
private var labelSize: CGFloat {
|
||||
switch labelType {
|
||||
case .title:
|
||||
@@ -26,47 +29,79 @@ public struct ChartLabel: View {
|
||||
return 24.0
|
||||
case .largeTitle:
|
||||
return 38.0
|
||||
case .custom(let size):
|
||||
case .custom(let size, _, _):
|
||||
return size
|
||||
}
|
||||
}
|
||||
|
||||
private let labelType: ChartLabelType
|
||||
|
||||
private var labelColor: Color {
|
||||
/// Padding around label
|
||||
/// - Returns: the edge padding to use based on position of the label
|
||||
private var labelPadding: EdgeInsets {
|
||||
switch labelType {
|
||||
case .title:
|
||||
return .black
|
||||
return EdgeInsets(top: 16.0, leading: 8.0, bottom: 0.0, trailing: 8.0)
|
||||
case .legend:
|
||||
return .gray
|
||||
return EdgeInsets(top: 4.0, leading: 8.0, bottom: 0.0, trailing: 8.0)
|
||||
case .subTitle:
|
||||
return .black
|
||||
return EdgeInsets(top: 8.0, leading: 8.0, bottom: 0.0, trailing: 8.0)
|
||||
case .largeTitle:
|
||||
return .black
|
||||
case .custom(_):
|
||||
return .black
|
||||
return EdgeInsets(top: 24.0, leading: 8.0, bottom: 0.0, trailing: 8.0)
|
||||
case .custom(_, let padding, _):
|
||||
return padding
|
||||
}
|
||||
}
|
||||
|
||||
public init (_ title: String,
|
||||
type: ChartLabelType = .title) {
|
||||
self.title = title
|
||||
labelType = type
|
||||
/// Which type (color, size, position) for label
|
||||
private let labelType: ChartLabelType
|
||||
|
||||
/// Foreground color for this label
|
||||
/// - Returns: Color of label based on its `ChartLabelType`
|
||||
private var labelColor: Color {
|
||||
switch labelType {
|
||||
case .title:
|
||||
return Color(UIColor.label)
|
||||
case .legend:
|
||||
return Color(UIColor.secondaryLabel)
|
||||
case .subTitle:
|
||||
return Color(UIColor.label)
|
||||
case .largeTitle:
|
||||
return Color(UIColor.label)
|
||||
case .custom(_, _, let color):
|
||||
return color
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize
|
||||
/// - Parameters:
|
||||
/// - title: Any `String`
|
||||
/// - type: Which `ChartLabelType` to use
|
||||
public init (_ title: String,
|
||||
type: ChartLabelType = .title,
|
||||
format: String = "%.01f") {
|
||||
self.title = title
|
||||
labelType = type
|
||||
self.format = format
|
||||
}
|
||||
|
||||
/// The content and behavior of the `ChartLabel`.
|
||||
///
|
||||
/// Displays current value if chart is currently being touched along a data point, otherwise the specified text.
|
||||
public var body: some View {
|
||||
VStack (alignment: self.isInteractionInProgress ? .center : .leading) {
|
||||
HStack {
|
||||
Text(textToDisplay)
|
||||
.font(.system(size: labelSize))
|
||||
.bold()
|
||||
.foregroundColor(self.labelColor)
|
||||
.padding(self.labelPadding)
|
||||
.onAppear {
|
||||
self.textToDisplay = title
|
||||
self.textToDisplay = self.title
|
||||
}
|
||||
.onReceive(self.chartValue.objectWillChange) { _ in
|
||||
self.textToDisplay = self.chartValue.interactionInProgress ? String(format: "%.01f", self.chartValue.currentValue) : self.title
|
||||
self.isInteractionInProgress = self.chartValue.interactionInProgress
|
||||
self.textToDisplay = self.chartValue.interactionInProgress ? String(format: format, self.chartValue.currentValue) : self.title
|
||||
}
|
||||
if !self.chartValue.interactionInProgress {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct ChartStyle {
|
||||
/// Descripton of colors/styles for any kind of chart
|
||||
public class ChartStyle: ObservableObject {
|
||||
|
||||
public let backgroundColor: ColorGradient
|
||||
/// 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,26 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
/// An encapsulation of a simple gradient between one color and another
|
||||
public struct ColorGradient: Equatable {
|
||||
public let startColor: Color
|
||||
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
|
||||
}
|
||||
|
||||
public init (_ startColor: Color, _ 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 LinearGradient from the ColorGradient
|
||||
/// Convenience method to return a SwiftUI LinearGradient view from the ColorGradient
|
||||
/// - Parameters:
|
||||
/// - startPoint: starting point
|
||||
/// - endPoint: ending point
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Some predefined colors, used for demos, defaults if color is missing, and data indicator point
|
||||
public enum ChartColors {
|
||||
// Orange
|
||||
static let orangeBright = Color(hexString: "#FF782C")
|
||||
static let orangeDark = Color(hexString: "#EC2301")
|
||||
public static let orangeBright = Color(hexString: "#FF782C")
|
||||
public static let orangeDark = Color(hexString: "#EC2301")
|
||||
|
||||
static let legendColor: Color = Color(hexString: "#E8E7EA")
|
||||
static let indicatorKnob: Color = Color(hexString: "#FF57A6")
|
||||
public static let legendColor: Color = Color(hexString: "#E8E7EA")
|
||||
public static let indicatorKnob: Color = Color(hexString: "#FF57A6")
|
||||
}
|
||||
|
||||
@@ -1,31 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct BarChart: ChartType {
|
||||
public func makeChart(data: Self.Data, style: Self.Style) -> some View {
|
||||
/// A type of chart that displays vertical bars for each data point
|
||||
public struct BarChart: View, 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)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
struct BarChart_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
BarChart().makeChart(
|
||||
data: .init([0]),
|
||||
style: .init(backgroundColor: .white, foregroundColor: ColorGradient.redBlack))
|
||||
Group {
|
||||
BarChart().makeChart(
|
||||
data: .init([1, 2, 3, 5, 1]),
|
||||
style: .init(backgroundColor: .white, foregroundColor: ColorGradient.redBlack))
|
||||
}.environment(\.colorScheme, .light)
|
||||
|
||||
Group {
|
||||
BarChart().makeChart(
|
||||
data: .init([1, 2, 3]),
|
||||
style: .init(backgroundColor: .white, foregroundColor: ColorGradient.redBlack))
|
||||
}.environment(\.colorScheme, .dark)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,37 @@
|
||||
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 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,17 +39,17 @@ 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())
|
||||
|
||||
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,44 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BarChartCellShape: Shape, Animatable {
|
||||
var value: Double
|
||||
var cornerRadius: CGFloat = 6.0
|
||||
var animatableData: CGFloat {
|
||||
get { CGFloat(value) }
|
||||
set { value = Double(newValue) }
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let adjustedOriginY = rect.height - (rect.height * CGFloat(value))
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: 0.0 , y: rect.height))
|
||||
path.addLine(to: CGPoint(x: 0.0, y: adjustedOriginY + cornerRadius))
|
||||
path.addArc(center: CGPoint(x: cornerRadius, y: adjustedOriginY + cornerRadius),
|
||||
radius: cornerRadius,
|
||||
startAngle: Angle(radians: Double.pi),
|
||||
endAngle: Angle(radians: -Double.pi/2),
|
||||
clockwise: false)
|
||||
path.addLine(to: CGPoint(x: rect.width - cornerRadius, y: adjustedOriginY))
|
||||
path.addArc(center: CGPoint(x: rect.width - cornerRadius, y: adjustedOriginY + cornerRadius),
|
||||
radius: cornerRadius,
|
||||
startAngle: Angle(radians: -Double.pi/2),
|
||||
endAngle: Angle(radians: 0),
|
||||
clockwise: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,45 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A single row of data, a view in a `BarChart`
|
||||
public struct BarChartRow: View {
|
||||
@Environment(\.chartValue) private var chartValue: ChartValue
|
||||
@EnvironmentObject var chartValue: ChartValue
|
||||
@ObservedObject var chartData: ChartData
|
||||
@State var touchLocation: CGFloat = -1.0
|
||||
|
||||
enum Constant {
|
||||
static let spacing: CGFloat = 16.0
|
||||
}
|
||||
@State private var touchLocation: CGFloat = -1.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
|
||||
self.touchLocation = value.location.x/width
|
||||
if let currentValue = getCurrentValue(width: width) {
|
||||
if let currentValue = self.getCurrentValue(width: width) {
|
||||
self.chartValue.currentValue = currentValue
|
||||
self.chartValue.interactionInProgress = true
|
||||
}
|
||||
@@ -51,11 +51,12 @@ public struct BarChartRow: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -64,31 +65,20 @@ 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 var previews: some View {
|
||||
// Group {
|
||||
// BarChartRow(data: [0], style: styleGreenRed)
|
||||
// Group {
|
||||
// BarChartRow(data: [1, 2, 3], style: styleGreenRed)
|
||||
// BarChartRow(data: [1, 2, 3], style: styleGreenRedWhiteBlack)
|
||||
// }
|
||||
// Group {
|
||||
// BarChartRow(data: [1, 2, 3], style: styleGreenRed)
|
||||
// BarChartRow(data: [1, 2, 3], style: styleGreenRedWhiteBlack)
|
||||
// }.environment(\.colorScheme, .dark)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//private let styleGreenRed = ChartStyle(backgroundColor: .white, foregroundColor: .greenRed)
|
||||
//
|
||||
//private let styleGreenRedWhiteBlack = ChartStyle(
|
||||
// backgroundColor: ColorGradient.init(.white),
|
||||
// foregroundColor: [ColorGradient.redBlack, ColorGradient.whiteBlack])
|
||||
struct BarChartRow_Previews: PreviewProvider {
|
||||
static let chartData = ChartData([6, 2, 5, 8, 6])
|
||||
static let chartStyle = ChartStyle(backgroundColor: .white, foregroundColor: .orangeBright)
|
||||
static var previews: some View {
|
||||
BarChartRow(chartData: chartData, style: chartStyle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
//
|
||||
// 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 {
|
||||
var body: some 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()
|
||||
.fill(ChartColors.indicatorKnob)
|
||||
|
||||
@@ -1,67 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A single line of data, a view in a `LineChart`
|
||||
public struct Line: View {
|
||||
@Environment(\.chartValue) private var chartValue: ChartValue
|
||||
@State var frame: CGRect = .zero
|
||||
@EnvironmentObject var chartValue: ChartValue
|
||||
@ObservedObject var chartData: ChartData
|
||||
|
||||
var style: ChartStyle
|
||||
|
||||
@State var showIndicator: Bool = false
|
||||
@State var touchLocation: CGPoint = .zero
|
||||
@State private var showFull: Bool = false
|
||||
@State var showBackground: Bool = true
|
||||
@State private var showIndicator: Bool = false
|
||||
@State private var touchLocation: CGPoint = .zero
|
||||
@State private var showBackground: Bool = true
|
||||
@State private var didCellAppear: Bool = false
|
||||
|
||||
var curvedLines: Bool = true
|
||||
var step: CGPoint {
|
||||
return CGPoint.getStep(frame: frame, data: chartData.data)
|
||||
}
|
||||
|
||||
var path: Path {
|
||||
let points = chartData.data
|
||||
|
||||
if curvedLines {
|
||||
return Path.quadCurvedPathWithPoints(points: points,
|
||||
step: step,
|
||||
globalOffset: nil)
|
||||
}
|
||||
|
||||
return Path.linePathWithPoints(points: points, step: step)
|
||||
}
|
||||
|
||||
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)
|
||||
Path.quadCurvedPathWithPoints(points: chartData.normalisedPoints,
|
||||
step: CGPoint(x: 1.0, y: 1.0))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
if self.showFull && self.showBackground {
|
||||
self.getBackgroundPathView()
|
||||
if self.didCellAppear && self.showBackground {
|
||||
LineBackgroundShapeView(chartData: chartData,
|
||||
geometry: geometry,
|
||||
style: style)
|
||||
}
|
||||
self.getLinePathView()
|
||||
LineShapeView(chartData: chartData,
|
||||
geometry: geometry,
|
||||
style: style,
|
||||
trimTo: didCellAppear ? 1.0 : 0.0)
|
||||
.animation(.easeIn)
|
||||
if self.showIndicator {
|
||||
IndicatorPoint()
|
||||
.position(self.getClosestPointOnPath(touchLocation: self.touchLocation))
|
||||
.position(self.getClosestPointOnPath(geometry: geometry,
|
||||
touchLocation: self.touchLocation))
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.frame = geometry.frame(in: .local)
|
||||
didCellAppear = true
|
||||
}
|
||||
.onDisappear() {
|
||||
didCellAppear = false
|
||||
}
|
||||
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
self.touchLocation = value.location
|
||||
self.showIndicator = true
|
||||
self.getClosestDataPoint(point: self.getClosestPointOnPath(touchLocation: value.location))
|
||||
self.getClosestDataPoint(geometry: geometry, touchLocation: value.location)
|
||||
self.chartValue.interactionInProgress = true
|
||||
})
|
||||
.onEnded({ value in
|
||||
@@ -77,55 +70,42 @@ public struct Line: View {
|
||||
// MARK: - Private functions
|
||||
|
||||
extension Line {
|
||||
private func getClosestPointOnPath(touchLocation: CGPoint) -> CGPoint {
|
||||
let closest = self.path.point(to: touchLocation.x)
|
||||
return closest
|
||||
/// Calculate point closest to where the user touched
|
||||
/// - Parameter touchLocation: location in view where touched
|
||||
/// - Returns: `CGPoint` of data point on chart
|
||||
private func getClosestPointOnPath(geometry: GeometryProxy, touchLocation: CGPoint) -> CGPoint {
|
||||
let geometryWidth = geometry.frame(in: .local).width
|
||||
let normalisedTouchLocationX = (touchLocation.x / geometryWidth) * CGFloat(chartData.normalisedPoints.count - 1)
|
||||
let closest = self.path.point(to: normalisedTouchLocationX)
|
||||
var denormClosest = closest.denormalize(with: geometry)
|
||||
denormClosest.x = denormClosest.x / CGFloat(chartData.normalisedPoints.count - 1)
|
||||
denormClosest.y = denormClosest.y / CGFloat(chartData.normalisedRange)
|
||||
return denormClosest
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
private func getBackgroundPathView() -> some View {
|
||||
self.closedPath
|
||||
.fill(style.backgroundColor.linearGradient(from: .bottom, to: .top))
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
.transition(.opacity)
|
||||
.animation(.easeIn(duration: 1.6))
|
||||
}
|
||||
|
||||
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, -4]), style: blackLineStyle)
|
||||
Line(chartData: ChartData([8, 23, 32, 7, 23, 43]), style: redLineStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let blackLineStyle = ChartStyle(backgroundColor: ColorGradient(.white), foregroundColor: ColorGradient(.black))
|
||||
private let redLineStyle = ChartStyle(backgroundColor: .whiteBlack, foregroundColor: ColorGradient(.red))
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LineBackgroundShape: Shape {
|
||||
var data: [Double]
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = Path.quadClosedCurvedPathWithPoints(points: data, step: CGPoint(x: 1.0, y: 1.0))
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct LineBackgroundShape_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
GeometryReader { geometry in
|
||||
LineBackgroundShape(data: [0, 0.5, 0.8, 0.6, 1])
|
||||
.transform(CGAffineTransform(scaleX: geometry.size.width / 4.0, y: geometry.size.height))
|
||||
.fill(Color.red)
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
GeometryReader { geometry in
|
||||
LineBackgroundShape(data: [0, -0.5, 0.8, -0.6, 1])
|
||||
.transform(CGAffineTransform(scaleX: geometry.size.width / 4.0, y: geometry.size.height / 1.6))
|
||||
.fill(Color.blue)
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LineBackgroundShapeView: View {
|
||||
var chartData: ChartData
|
||||
var geometry: GeometryProxy
|
||||
var style: ChartStyle
|
||||
|
||||
var body: some View {
|
||||
LineBackgroundShape(data: chartData.normalisedPoints)
|
||||
.transform(CGAffineTransform(scaleX: geometry.size.width / CGFloat(chartData.normalisedPoints.count - 1),
|
||||
y: geometry.size.height / CGFloat(chartData.normalisedRange)))
|
||||
.fill(LinearGradient(gradient: Gradient(colors: [style.foregroundColor.first?.startColor ?? .white,
|
||||
style.backgroundColor.startColor]),
|
||||
startPoint: .bottom,
|
||||
endPoint: .top))
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct LineChart: ChartType {
|
||||
public func makeChart(data: Self.Data, style: Self.Style) -> some View {
|
||||
/// A type of chart that displays a line connecting the data points
|
||||
public struct LineChart: View, ChartBase {
|
||||
public var chartData = ChartData()
|
||||
|
||||
@EnvironmentObject var data: ChartData
|
||||
@EnvironmentObject var style: ChartStyle
|
||||
|
||||
/// The content and behavior of the `LineChart`.
|
||||
///
|
||||
///
|
||||
public var body: some View {
|
||||
Line(chartData: data, style: style)
|
||||
}
|
||||
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
struct LineChart_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
LineChart().makeChart(
|
||||
data: .init([0]),
|
||||
style: .init(backgroundColor: .white, foregroundColor: ColorGradient(.black)))
|
||||
Group {
|
||||
LineChart().makeChart(
|
||||
data: .init([1, 2, 3, 5, 1]),
|
||||
style: .init(backgroundColor: .white, foregroundColor: ColorGradient(.black)))
|
||||
}.environment(\.colorScheme, .light)
|
||||
|
||||
Group {
|
||||
LineChart().makeChart(
|
||||
data: .init([1, 2, 3]),
|
||||
style: .init(backgroundColor: .white, foregroundColor: ColorGradient.redBlack))
|
||||
}.environment(\.colorScheme, .dark)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LineShape: Shape {
|
||||
var data: [Double]
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = Path.quadCurvedPathWithPoints(points: data, step: CGPoint(x: 1.0, y: 1.0))
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct LineShape_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
GeometryReader { geometry in
|
||||
LineShape(data: [0, 0.5, 0.8, 0.6, 1])
|
||||
.transform(CGAffineTransform(scaleX: geometry.size.width / 4.0, y: geometry.size.height))
|
||||
.stroke(Color.red)
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
GeometryReader { geometry in
|
||||
LineShape(data: [0, -0.5, 0.8, -0.6, 1])
|
||||
.transform(CGAffineTransform(scaleX: geometry.size.width / 4.0, y: geometry.size.height / 1.6))
|
||||
.stroke(Color.blue)
|
||||
.rotationEffect(.degrees(180), anchor: .center)
|
||||
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LineShapeView: View, Animatable {
|
||||
var chartData: ChartData
|
||||
var geometry: GeometryProxy
|
||||
var style: ChartStyle
|
||||
var trimTo: Double = 0
|
||||
|
||||
var animatableData: CGFloat {
|
||||
get { CGFloat(trimTo) }
|
||||
set { trimTo = Double(newValue) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LineShape(data: chartData.normalisedPoints)
|
||||
.trim(from: 0, to: CGFloat(trimTo))
|
||||
.transform(CGAffineTransform(scaleX: geometry.size.width / CGFloat(chartData.normalisedPoints.count - 1),
|
||||
y: geometry.size.height / CGFloat(chartData.normalisedRange)))
|
||||
.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))
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,18 @@
|
||||
//
|
||||
// PieChart.swift
|
||||
// SwiftUICharts
|
||||
//
|
||||
// Created by Nicolas Savoini on 2020-05-24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct PieChart: ChartType {
|
||||
public func makeChart(data: Self.Data, style: Self.Style) -> some View {
|
||||
/// A type of chart that displays a slice of "pie" for each data point
|
||||
public struct PieChart: View, 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)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
struct PieChart_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
PieChart().makeChart(
|
||||
data: .init([0]),
|
||||
style: styleOneColor)
|
||||
|
||||
Group {
|
||||
PieChart().makeChart(
|
||||
data: .init([56, 78, 53, 65, 54]),
|
||||
style: styleOneColor)
|
||||
PieChart().makeChart(
|
||||
data: .init([56, 78, 53, 65, 54]),
|
||||
style: styleTwoColor)
|
||||
PieChart().makeChart(
|
||||
data: .init([1, 1, 1, 1, 1, 1]),
|
||||
style: trivialPursuit)
|
||||
}.environment(\.colorScheme, .light)
|
||||
|
||||
Group {
|
||||
PieChart().makeChart(
|
||||
data: .init([56, 78, 53, 65, 54]),
|
||||
style: styleOneColor)
|
||||
PieChart().makeChart(
|
||||
data: .init([56, 78, 53, 65, 54]),
|
||||
style: styleTwoColor)
|
||||
PieChart().makeChart(
|
||||
data: .init([1, 1, 1, 1, 1, 1]),
|
||||
style: trivialPursuit)
|
||||
}.environment(\.colorScheme, .dark)
|
||||
|
||||
}.previewLayout(.fixed(width: 250, height: 400))
|
||||
}
|
||||
}
|
||||
|
||||
private let styleOneColor = ChartStyle(backgroundColor: .white, foregroundColor: ColorGradient.init(.pink))
|
||||
|
||||
private let styleTwoColor = ChartStyle(backgroundColor: ColorGradient(.black), foregroundColor: [ColorGradient(.yellow), ColorGradient(.red)])
|
||||
|
||||
private let trivialPursuit = ChartStyle(
|
||||
backgroundColor: .yellow,
|
||||
foregroundColor: [ColorGradient(.yellow),
|
||||
ColorGradient(.pink),
|
||||
ColorGradient(.green),
|
||||
ColorGradient(.primary),
|
||||
ColorGradient(.blue),
|
||||
ColorGradient(.orange)])
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
//
|
||||
// PieChartCell.swift
|
||||
// SwiftUICharts
|
||||
//
|
||||
// Created by Nicolas Savoini on 2020-05-24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// One slice of a `PieChartRow`
|
||||
struct PieSlice: Identifiable {
|
||||
var id = UUID()
|
||||
var startDeg: Double
|
||||
@@ -14,6 +8,7 @@ struct PieSlice: Identifiable {
|
||||
var value: Double
|
||||
}
|
||||
|
||||
/// A single row of data, a view in a `PieChart`
|
||||
public struct PieChartCell: View {
|
||||
@State private var show: Bool = false
|
||||
var rect: CGRect
|
||||
@@ -21,7 +16,9 @@ public struct PieChartCell: View {
|
||||
return min(rect.width, rect.height)/2
|
||||
}
|
||||
var startDeg: Double
|
||||
var endDeg: Double
|
||||
var endDeg: Double
|
||||
|
||||
/// Path representing this slice
|
||||
var path: Path {
|
||||
var path = Path()
|
||||
path.addArc(
|
||||
@@ -42,6 +39,9 @@ public struct PieChartCell: View {
|
||||
// Section color
|
||||
var accentColor: ColorGradient
|
||||
|
||||
/// The content and behavior of the `PieChartCell`.
|
||||
///
|
||||
/// Fills and strokes with 2-pixel line (unless start/end degrees not yet set). Animates by scaling up to 100% when first appears.
|
||||
public var body: some View {
|
||||
Group {
|
||||
path
|
||||
|
||||
@@ -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,23 +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)
|
||||
@@ -27,55 +22,48 @@ public struct PieChartRow: View {
|
||||
|
||||
return tempSlices
|
||||
}
|
||||
|
||||
|
||||
@State private var currentTouchedIndex = -1 {
|
||||
didSet {
|
||||
if oldValue != currentTouchedIndex {
|
||||
chartValue.interactionInProgress = currentTouchedIndex != -1
|
||||
guard currentTouchedIndex != -1 else { return }
|
||||
chartValue.currentValue = slices[currentTouchedIndex].value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
ForEach(0..<self.slices.count) { index in
|
||||
PieChartCell(
|
||||
rect: geometry.frame(in: .local),
|
||||
startDeg: self.slices[index].startDeg,
|
||||
endDeg: self.slices[index].endDeg,
|
||||
index: index,
|
||||
backgroundColor: self.style.backgroundColor.startColor,
|
||||
accentColor: self.style.foregroundColor.rotate(for: index)
|
||||
)
|
||||
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))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private let defaultMultiColorChartStyle = ChartStyle(
|
||||
backgroundColor: Color.white,
|
||||
foregroundColor: [ColorGradient]())
|
||||
|
||||
private let multiColorChartStyle = ChartStyle(
|
||||
backgroundColor: Color.purple,
|
||||
foregroundColor: [ColorGradient.greenRed, ColorGradient.whiteBlack])
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//
|
||||
// Ring.swift
|
||||
// ChartViewV2Demo
|
||||
//
|
||||
// Created by Dan Wood on 8/20/20.
|
||||
// Based on article and playground code by Frank Jia
|
||||
// https://medium.com/@frankjia/creating-activity-rings-in-swiftui-11ef7d336676
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
extension Double {
|
||||
func toRadians() -> Double {
|
||||
return self * Double.pi / 180
|
||||
}
|
||||
func toCGFloat() -> CGFloat {
|
||||
return CGFloat(self)
|
||||
}
|
||||
}
|
||||
|
||||
struct RingShape: Shape {
|
||||
/// Helper function to convert percent values to angles in degrees
|
||||
/// - Parameters:
|
||||
/// - percent: percent, greater than 100 is OK
|
||||
/// - startAngle: angle to add after converting
|
||||
/// - Returns: angle in degrees
|
||||
static func percentToAngle(percent: Double, startAngle: Double) -> Double {
|
||||
(percent / 100 * 360) + startAngle
|
||||
}
|
||||
private var percent: Double
|
||||
private var startAngle: Double
|
||||
private let drawnClockwise: Bool
|
||||
|
||||
// This allows animations to run smoothly for percent values
|
||||
var animatableData: Double {
|
||||
get {
|
||||
return percent
|
||||
}
|
||||
set {
|
||||
percent = newValue
|
||||
}
|
||||
}
|
||||
|
||||
init(percent: Double = 100, startAngle: Double = -90, drawnClockwise: Bool = false) {
|
||||
self.percent = percent
|
||||
self.startAngle = startAngle
|
||||
self.drawnClockwise = drawnClockwise
|
||||
}
|
||||
|
||||
/// This draws a simple arc from the start angle to the end angle
|
||||
///
|
||||
/// - Parameter rect: The frame of reference for describing this shape.
|
||||
/// - Returns: A path that describes this shape.
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let width = rect.width
|
||||
let height = rect.height
|
||||
let radius = min(width, height) / 2
|
||||
let center = CGPoint(x: width / 2, y: height / 2)
|
||||
let endAngle = Angle(degrees: RingShape.percentToAngle(percent: self.percent, startAngle: self.startAngle))
|
||||
return Path { path in
|
||||
path.addArc(center: center, radius: radius, startAngle: Angle(degrees: startAngle), endAngle: endAngle, clockwise: drawnClockwise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Ring: View {
|
||||
|
||||
private static let ShadowColor: Color = Color.black.opacity(0.2)
|
||||
private static let ShadowRadius: CGFloat = 5
|
||||
private static let ShadowOffsetMultiplier: CGFloat = ShadowRadius + 2
|
||||
|
||||
private let ringWidth: CGFloat
|
||||
private let percent: Double
|
||||
private let foregroundColor: ColorGradient
|
||||
private let startAngle: Double = -90
|
||||
|
||||
private let touchLocation: CGFloat
|
||||
|
||||
|
||||
|
||||
private var gradientStartAngle: Double {
|
||||
self.percent >= 100 ? relativePercentageAngle - 360 : startAngle
|
||||
}
|
||||
private var absolutePercentageAngle: Double {
|
||||
RingShape.percentToAngle(percent: self.percent, startAngle: 0)
|
||||
}
|
||||
private var relativePercentageAngle: Double {
|
||||
// Take into account the startAngle
|
||||
absolutePercentageAngle + startAngle
|
||||
}
|
||||
private var lastGradientColor: Color {
|
||||
self.foregroundColor.endColor
|
||||
}
|
||||
|
||||
private var ringGradient: AngularGradient {
|
||||
AngularGradient(
|
||||
gradient: self.foregroundColor.gradient,
|
||||
center: .center,
|
||||
startAngle: Angle(degrees: self.gradientStartAngle),
|
||||
endAngle: Angle(degrees: relativePercentageAngle)
|
||||
)
|
||||
}
|
||||
|
||||
init(ringWidth: CGFloat, percent: Double, foregroundColor: ColorGradient, touchLocation:CGFloat) {
|
||||
self.ringWidth = ringWidth
|
||||
self.percent = percent
|
||||
self.foregroundColor = foregroundColor
|
||||
self.touchLocation = touchLocation
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Background for the ring. Use the final color with reduced opacity
|
||||
RingShape()
|
||||
.stroke(style: StrokeStyle(lineWidth: self.ringWidth))
|
||||
.fill(lastGradientColor.opacity(0.142857))
|
||||
// Foreground
|
||||
RingShape(percent: self.percent, startAngle: self.startAngle)
|
||||
.stroke(style: StrokeStyle(lineWidth: self.ringWidth, lineCap: .round))
|
||||
.fill(self.ringGradient)
|
||||
// End of ring with drop shadow
|
||||
if self.getShowShadow(frame: geometry.size) {
|
||||
Circle()
|
||||
.fill(self.lastGradientColor)
|
||||
.frame(width: self.ringWidth, height: self.ringWidth, alignment: .center)
|
||||
.offset(x: self.getEndCircleLocation(frame: geometry.size).0,
|
||||
y: self.getEndCircleLocation(frame: geometry.size).1)
|
||||
.shadow(color: Ring.ShadowColor,
|
||||
radius: Ring.ShadowRadius,
|
||||
x: self.getEndCircleShadowOffset().0,
|
||||
y: self.getEndCircleShadowOffset().1)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Padding to ensure that the entire ring fits within the view size allocated
|
||||
.padding(self.ringWidth / 2)
|
||||
}
|
||||
|
||||
private func getEndCircleLocation(frame: CGSize) -> (CGFloat, CGFloat) {
|
||||
// Get angle of the end circle with respect to the start angle
|
||||
let angleOfEndInRadians: Double = relativePercentageAngle.toRadians()
|
||||
let offsetRadius = min(frame.width, frame.height) / 2
|
||||
return (offsetRadius * cos(angleOfEndInRadians).toCGFloat(), offsetRadius * sin(angleOfEndInRadians).toCGFloat())
|
||||
}
|
||||
|
||||
private func getEndCircleShadowOffset() -> (CGFloat, CGFloat) {
|
||||
let angleForOffset = absolutePercentageAngle + (self.startAngle + 90)
|
||||
let angleForOffsetInRadians = angleForOffset.toRadians()
|
||||
let relativeXOffset = cos(angleForOffsetInRadians)
|
||||
let relativeYOffset = sin(angleForOffsetInRadians)
|
||||
let xOffset = relativeXOffset.toCGFloat() * Ring.ShadowOffsetMultiplier
|
||||
let yOffset = relativeYOffset.toCGFloat() * Ring.ShadowOffsetMultiplier
|
||||
return (xOffset, yOffset)
|
||||
}
|
||||
|
||||
private func getShowShadow(frame: CGSize) -> Bool {
|
||||
if self.percent >= 100 {
|
||||
return true
|
||||
}
|
||||
let circleRadius = min(frame.width, frame.height) / 2
|
||||
let remainingAngleInRadians = (360 - absolutePercentageAngle).toRadians().toCGFloat()
|
||||
|
||||
return circleRadius * remainingAngleInRadians <= self.ringWidth
|
||||
}
|
||||
}
|
||||
|
||||
struct Ring_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
Ring(
|
||||
ringWidth: 50, percent: 5 ,
|
||||
foregroundColor: ColorGradient(.green, .blue), touchLocation: -1.0
|
||||
)
|
||||
.frame(width: 200, height: 200)
|
||||
|
||||
Ring(
|
||||
ringWidth: 20, percent: 110 ,
|
||||
foregroundColor: ColorGradient(.red, .blue), touchLocation: -1.0
|
||||
)
|
||||
.frame(width: 200, height: 200)
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// RingsChart.swift
|
||||
// ChartViewV2Demo
|
||||
//
|
||||
// Created by Dan Wood on 8/20/20.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct RingsChart: View, 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)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//
|
||||
// RingsChartRow.swift
|
||||
// ChartViewV2Demo
|
||||
//
|
||||
// Created by Dan Wood on 8/20/20.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct RingsChartRow: View {
|
||||
|
||||
var width : CGFloat
|
||||
var spacing : CGFloat
|
||||
|
||||
@EnvironmentObject var chartValue: ChartValue
|
||||
@ObservedObject var chartData: ChartData
|
||||
@State var touchRadius: CGFloat = -1.0
|
||||
|
||||
var style: ChartStyle
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
|
||||
ZStack {
|
||||
|
||||
// FIXME: Why is background circle offset strangely when frame isn't specified? See Preview below. Related to the .animation somehow ????
|
||||
Circle()
|
||||
.fill(RadialGradient(gradient: self.style.backgroundColor.gradient, center: .center, startRadius: min(geometry.size.width, geometry.size.height)/2.0, endRadius: 1.0))
|
||||
|
||||
ForEach(0..<self.chartData.data.count, id: \.self) { index in
|
||||
|
||||
let scaleUp = isRingScaled(size:geometry.size, touchRadius: self.touchRadius, index: index)
|
||||
let scaledWidth = scaleUp ? self.width * 2.0 : self.width
|
||||
|
||||
let normalPadding = (width + spacing) * CGFloat(index)
|
||||
let scaledDiff = (scaledWidth - width) / 2.0
|
||||
let padding = min(normalPadding - scaledDiff,
|
||||
min(geometry.size.width, geometry.size.height)/2.0 - width
|
||||
// make sure it doesn't get to crazy value
|
||||
)
|
||||
|
||||
Ring(ringWidth:scaledWidth, percent: self.chartData.points[index], foregroundColor:self.style.foregroundColor.rotate(for: index),
|
||||
touchLocation: self.touchRadius)
|
||||
|
||||
|
||||
.zIndex(scaleUp ? 1 : 0) // make sure zoomed one is on top
|
||||
.padding(padding)
|
||||
|
||||
.animation(Animation.easeIn(duration: 0.5))
|
||||
}
|
||||
// .drawingGroup()
|
||||
}
|
||||
|
||||
.gesture(DragGesture()
|
||||
.onChanged({ value in
|
||||
let frame = geometry.frame(in: .local)
|
||||
let radius = min(frame.width, frame.height) / 2.0
|
||||
let deltaX = value.location.x - frame.midX
|
||||
let deltaY = value.location.y - frame.midY
|
||||
self.touchRadius = sqrt(deltaX*deltaX + deltaY*deltaY) // Pythagorean equation
|
||||
|
||||
if let currentValue = self.getCurrentValue(maxRadius: radius) {
|
||||
self.chartValue.currentValue = currentValue
|
||||
self.chartValue.interactionInProgress = true
|
||||
}
|
||||
})
|
||||
.onEnded({ value in
|
||||
self.chartValue.interactionInProgress = false
|
||||
self.touchRadius = -1
|
||||
})
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/// should we scale up the touched ring?
|
||||
/// - Parameters:
|
||||
/// - size: size of the view
|
||||
/// - touchRadius: distance from center where touched
|
||||
/// - index: which ring is being drawn
|
||||
/// - Returns: size to scale up or just scale of 1 if not scaled up
|
||||
func isRingScaled(size: CGSize, touchRadius: CGFloat, index: Int) -> Bool {
|
||||
let radius = min(size.width, size.height) / 2.0
|
||||
return index == self.touchedCircleIndex(maxRadius: radius)
|
||||
}
|
||||
|
||||
/// Find which circle has been touched
|
||||
/// - Parameter maxRadius: radius of overall view circle
|
||||
/// - Returns: which circle index was touched, if found. 0 = outer, 1 = next one in, etc.
|
||||
func touchedCircleIndex(maxRadius: CGFloat) -> Int? {
|
||||
guard self.chartData.data.count > 0 else { return nil } // no data
|
||||
|
||||
// Pretend actual circle goes ½ the inter-ring spacing out, so that a touch
|
||||
// is registered on either side of each ring
|
||||
let radialDistanceFromEdge = (maxRadius + spacing/2) - self.touchRadius;
|
||||
guard radialDistanceFromEdge >= 0 else { return nil } // touched outside of ring
|
||||
|
||||
let touchIndex = Int(floor(radialDistanceFromEdge / (width + spacing)))
|
||||
|
||||
if touchIndex >= self.chartData.data.count { return nil } // too far from outside, no ring
|
||||
|
||||
return touchIndex
|
||||
}
|
||||
|
||||
/// Description
|
||||
/// - Parameter maxRadius: radius of overall view circle
|
||||
/// - Returns: percentage value of the touched circle, based on `touchRadius` if found
|
||||
func getCurrentValue(maxRadius: CGFloat) -> Double? {
|
||||
|
||||
guard let index = self.touchedCircleIndex(maxRadius: maxRadius) else { return nil }
|
||||
return self.chartData.points[index]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct RingsChartRow_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
let multiStyle = ChartStyle(backgroundColor: ColorGradient(Color.black.opacity(0.05), Color.white),
|
||||
foregroundColor:
|
||||
[ColorGradient(.purple, .blue),
|
||||
ColorGradient(.orange, .red),
|
||||
ColorGradient(.green, .yellow),
|
||||
])
|
||||
|
||||
return RingsChartRow(width:20.0, spacing:10.0, chartData: ChartData([25,50,75,100,125]), style: multiStyle)
|
||||
|
||||
// and why does this not get centered when frame isn't specified?
|
||||
.frame(width:300, height:400)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
var chartType: AnyChartType {
|
||||
get {
|
||||
return self[ChartTypeKey.self]
|
||||
}
|
||||
set {
|
||||
self[ChartTypeKey.self] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var chartStyle: ChartStyle {
|
||||
get {
|
||||
return self[ChartStyleKey.self]
|
||||
}
|
||||
set {
|
||||
self[ChartStyleKey.self] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var chartValue: ChartValue {
|
||||
get {
|
||||
return self[ChartValueKey.self]
|
||||
}
|
||||
set {
|
||||
self[ChartValueKey.self] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChartTypeKey: EnvironmentKey {
|
||||
static let defaultValue: AnyChartType = AnyChartType(BarChart())
|
||||
}
|
||||
|
||||
struct ChartStyleKey: EnvironmentKey {
|
||||
static let defaultValue: ChartStyle = ChartStyle(backgroundColor: .white,
|
||||
foregroundColor: ColorGradient(ChartColors.orangeDark,
|
||||
ChartColors.orangeBright))
|
||||
}
|
||||
|
||||
struct ChartValueKey: EnvironmentKey {
|
||||
static let defaultValue: ChartValue = ChartValue()
|
||||
}
|
||||
@@ -1,10 +1,3 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Nicolas Savoini on 2020-05-25.
|
||||
//
|
||||
|
||||
@testable import SwiftUICharts
|
||||
import XCTest
|
||||
|
||||
|
||||
Reference in New Issue
Block a user