Compare commits

...

1 Commits

Author SHA1 Message Date
Andras Samu ac7680320f feat(core): refactoring chart dispalying
now it is possible to add background lines precisely as charts are displayed at correct size
also rewrote basics to conform with Shapes and Animatable protocol
2021-04-04 15:54:15 +02:00
15 changed files with 293 additions and 195 deletions
@@ -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>
@@ -12,8 +12,20 @@ public class ChartData: ObservableObject {
data.map { $0.0 }
}
/// Initialize with data array
/// - Parameter data: Array of `Double`
var normalisedPoints: [Double] {
points.map { $0 / (points.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) }
}
@@ -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
@@ -1,25 +1,52 @@
import SwiftUI
/// <#Description#>
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
/// <#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{
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.3), style: StrokeStyle(lineWidth: 1, lineCap: .round, dash: [5, 10]))
}
}
}
@@ -4,27 +4,17 @@ import SwiftUI
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
}
@@ -33,20 +23,15 @@ public struct BarChartCell: View {
///
/// 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 +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)
}
}
}
@@ -6,10 +6,6 @@ public struct BarChartRow: View {
@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 {
@@ -27,20 +23,18 @@ public struct BarChartRow: View {
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,13 +52,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.points[index])/Double(maxValue)
}
/// Size to scale the touch indicator
/// - Parameters:
/// - touchLocation: fraction of width where touch is happening
@@ -87,3 +74,11 @@ public struct BarChartRow: View {
return self.chartData.points[index]
}
}
struct BarChartRow_Previews: PreviewProvider {
static let chartData = ChartData([6, 2, 5, 8, 6])
static let chartStyle = ChartStyle(backgroundColor: .white, foregroundColor: .orangeBright)
static var previews: some View {
BarChartRow(chartData: chartData, style: chartStyle)
}
}
+47 -130
View File
@@ -3,57 +3,16 @@ 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
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
@State private var didCellAppear: Bool = false
var curvedLines: Bool = true
/// 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.points)
}
/// Path of line graph
/// - Returns: A path for stroking representing the data, either curved or jagged.
var path: Path {
let points = chartData.points
if curvedLines {
return Path.quadCurvedPathWithPoints(points: points,
step: step,
globalOffset: nil)
}
return Path.linePathWithPoints(points: points, step: step)
}
/// 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.points
if curvedLines {
return Path.quadClosedCurvedPathWithPoints(points: points,
step: step,
globalOffset: nil)
}
return Path.closedLinePathWithPoints(points: points, step: step)
}
// 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.
let orientationChanged = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
.makeConnectable()
.autoconnect()
/// 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.
@@ -62,34 +21,35 @@ public struct Line: View {
public var body: some View {
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 && self.showBackground {
LineBackgroundShapeView(chartData: chartData,
geometry: geometry,
style: style)
}
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))
// .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
}
.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.getClosestDataPoint(point: self.getClosestPointOnPath(touchLocation: value.location))
self.chartValue.interactionInProgress = true
})
.onEnded({ value in
@@ -104,80 +64,37 @@ 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
}
/// 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))
if (index >= 0 && index < self.chartData.data.count){
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()
}
}
//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
// }
//
// /// 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))
// if (index >= 0 && index < self.chartData.data.count){
// self.chartValue.currentValue = self.chartData.points[index]
// }
// }
//}
struct Line_Previews: PreviewProvider {
/// Predefined style, black over white, for preview
static let blackLineStyle = ChartStyle(backgroundColor: ColorGradient(.white), foregroundColor: ColorGradient(.black))
/// Predefined style red over white, for preview
static let redLineStyle = ChartStyle(backgroundColor: .whiteBlack, foregroundColor: ColorGradient(.red))
static var previews: some View {
Group {
Line(chartData: ChartData([8, 23, 32, 7, 23, 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)
}
}
}
/// 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,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))
}
}
@@ -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,10 +1,3 @@
//
// File.swift
//
//
// Created by Nicolas Savoini on 2020-05-25.
//
@testable import SwiftUICharts
import XCTest