184 lines
7.3 KiB
Swift
184 lines
7.3 KiB
Swift
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
|
|
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.
|
|
/// 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()
|
|
}
|
|
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))
|
|
}
|
|
}
|
|
.onAppear {
|
|
self.frame = geometry.frame(in: .local)
|
|
|
|
}
|
|
.onReceive(orientationChanged) { _ in
|
|
// When we receive notification here, the geometry is still the old value
|
|
// so delay evaluation to get the new frame!
|
|
DispatchQueue.main.async {
|
|
self.frame = geometry.frame(in: .local) // recalculate layout with new frame
|
|
}
|
|
}
|
|
|
|
.gesture(DragGesture()
|
|
.onChanged({ value in
|
|
self.touchLocation = value.location
|
|
self.showIndicator = true
|
|
self.getClosestDataPoint(point: self.getClosestPointOnPath(touchLocation: value.location))
|
|
self.chartValue.interactionInProgress = true
|
|
})
|
|
.onEnded({ value in
|
|
self.touchLocation = .zero
|
|
self.showIndicator = false
|
|
self.chartValue.interactionInProgress = false
|
|
})
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
struct Line_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
Group {
|
|
Line(chartData: ChartData([8, 23, 32, 7, 23, 43]), style: blackLineStyle)
|
|
Line(chartData: ChartData([8, 23, 32, 7, 23, 43]), style: redLineStyle)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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))
|