Search code examples
animationgraphswiftuigraphing

SwiftUI Line Graph Animation using Vector Arithmetic


Looking for ways to improve drawing performance (hopefully without using Metal). Adding LinearGradient as the fill to the Shape has a dramatic hit to drawing performance so I've left that out.

The Vector

struct LineGraphVector: VectorArithmetic {
    var points: [CGPoint.AnimatableData]
    
    static func + (lhs: LineGraphVector, rhs: LineGraphVector) -> LineGraphVector {
        return add(lhs: lhs, rhs: rhs, +)
    }
    
    static func - (lhs: LineGraphVector, rhs: LineGraphVector) -> LineGraphVector {
        return add(lhs: lhs, rhs: rhs, -)
    }
    
    static func add(lhs: LineGraphVector, rhs: LineGraphVector, _ sign: (CGFloat, CGFloat) -> CGFloat) -> LineGraphVector {
        let maxPoints = max(lhs.points.count, rhs.points.count)
        let leftIndices = lhs.points.indices
        let rightIndices = rhs.points.indices
        
        var newPoints: [CGPoint.AnimatableData] = []
        (0 ..< maxPoints).forEach { index in
            if leftIndices.contains(index) && rightIndices.contains(index) {
                // Merge points
                let lhsPoint = lhs.points[index]
                let rhsPoint = rhs.points[index]
                newPoints.append(
                    .init(
                        sign(lhsPoint.first, rhsPoint.first),
                        sign(lhsPoint.second, rhsPoint.second)
                    )
                )
            } else if rightIndices.contains(index), let lastLeftPoint = lhs.points.last {
                // Right side has more points, collapse to last left point
                let rightPoint = rhs.points[index]
                newPoints.append(
                    .init(
                        sign(lastLeftPoint.first, rightPoint.first),
                        sign(lastLeftPoint.second, rightPoint.second)
                    )
                )
            } else if leftIndices.contains(index), let lastPoint = newPoints.last {
                // Left side has more points, collapse to last known point
                let leftPoint = lhs.points[index]
                newPoints.append(
                    .init(
                        sign(lastPoint.first, leftPoint.first),
                        sign(lastPoint.second, leftPoint.second)
                    )
                )
            }
        }
        
        return .init(points: newPoints)
    }
    
    mutating func scale(by rhs: Double) {
        points.indices.forEach { index in
            self.points[index].scale(by: rhs)
        }
    }
    
    var magnitudeSquared: Double {
        return 1.0
    }
    
    static var zero: LineGraphVector {
        return .init(points: [])
    }
}

The Shape

struct LineGraphShape: Shape {
    var points: [CGPoint]
    let closePath: Bool
    
    init(points: [CGPoint], closePath: Bool) {
        self.points = points
        self.closePath = closePath
    }
    
    var animatableData: LineGraphVector {
        get { .init(points: points.map { CGPoint.AnimatableData($0.x, $0.y) }) }
        set { points = newValue.points.map { CGPoint(x: $0.first, y: $0.second) } }
    }
    
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: points.first ?? .zero)
            path.addLines(points)
            
            switch (closePath, points.first, points.last) {
            case (true, .some(let firstPoint), .some(let lastPoint)):
                path.addLine(to: .init(x: lastPoint.x, y: rect.height))
                path.addLine(to: .init(x: 0.0, y: rect.height))
                path.addLine(to: .init(x: 0.0, y: firstPoint.y))
                path.closeSubpath()
            default:
                break
            }
        }
    }
}

Usage

struct ContentView: View {
    static let firstPoints: [CGPoint] = [
        .init(x: 0.0, y: 0.0),
        .init(x: 20.0, y: 320.0),
        .init(x: 40.0, y: 50.0),
        .init(x: 60.0, y: 10.0),
        .init(x: 90.0, y: 140.0),
        .init(x: 200.0, y: 60.0),
        .init(x: 420.0, y: 20.0),
    ]
    
    static let secondPoints: [CGPoint] = [
        .init(x: 0.0, y: 0.0),
        .init(x: 10.0, y: 200.0),
        .init(x: 20.0, y: 50.0),
        .init(x: 30.0, y: 70.0),
        .init(x: 40.0, y: 90.0),
        .init(x: 50.0, y: 150.0),
        .init(x: 60.0, y: 120.0),
        .init(x: 70.0, y: 20.0),
        .init(x: 80.0, y: 30.0),
        .init(x: 90.0, y: 20.0),
        .init(x: 100.0, y: 0.0),
        .init(x: 110.0, y: 200.0),
        .init(x: 120.0, y: 50.0),
        .init(x: 130.0, y: 70.0),
        .init(x: 140.0, y: 90.0),
        .init(x: 150.0, y: 150.0),
        .init(x: 160.0, y: 120.0),
        .init(x: 170.0, y: 20.0),
        .init(x: 180.0, y: 30.0),
        .init(x: 190.0, y: 20.0),
        .init(x: 200.0, y: 0.0),
        .init(x: 210.0, y: 200.0),
        .init(x: 220.0, y: 50.0),
        .init(x: 230.0, y: 70.0),
        .init(x: 240.0, y: 90.0),
        .init(x: 250.0, y: 150.0),
        .init(x: 260.0, y: 120.0),
        .init(x: 270.0, y: 20.0),
        .init(x: 280.0, y: 30.0),
        .init(x: 290.0, y: 20.0),
        .init(x: 420.0, y: 20.0),
    ]
    
    static let thirdPoints: [CGPoint] = [
        .init(x: 0.0, y: 0.0),
        .init(x: 20.0, y: 30.0),
        .init(x: 40.0, y: 20.0),
        .init(x: 60.0, y: 320.0),
        .init(x: 80.0, y: 200.0),
        .init(x: 100.0, y: 300.0),
        .init(x: 120.0, y: 320.0),
        .init(x: 140.0, y: 400.0),
        .init(x: 160.0, y: 400.0),
        .init(x: 180.0, y: 320),
        .init(x: 200.0, y: 400.0),
        .init(x: 420.0, y: 400.0),
    ]
    
    let pointTypes = [0, 1, 2]
    @State private var selectedPointType = 0
    let points: [[CGPoint]] = [firstPoints, secondPoints, thirdPoints]
    
    var body: some View {
        VStack {
            ZStack {
                LineGraphShape(points: points[selectedPointType], closePath: true)
                    .fill(Color.blue.opacity(0.5))
                
                LineGraphShape(points: points[selectedPointType], closePath: false)
                    .stroke(
                        Color.blue,
                        style: .init(
                            lineWidth: 4.0,
                            lineCap: .round,
                            lineJoin: .round,
                            miterLimit: 10.0
                        )
                    )
                
                Text("\(points[selectedPointType].count) Points")
                    .font(.largeTitle)
                    .animation(.none)
            }
            .animation(.easeInOut(duration: 2.0))
            
            VStack {
                Picker("", selection: $selectedPointType) {
                    ForEach(pointTypes, id: \.self) { pointType in
                        Text("Graph \(pointType)")
                    }
                }
                .pickerStyle(SegmentedPickerStyle())
            }
            .padding(.horizontal, 16.0)
            .padding(.bottom, 32.0)
        }
    }
}

Demo


Solution

  • Why do you want to avoid Metal? Enabling its support is as easy as wrapping your LineGraphShapes into Group and modifying it with a drawingGroup(). Try it out:

    ...
    Group {
        let gradient = LinearGradient(
            gradient: Gradient(colors: [Color.red, Color.blue]),
            startPoint: .leading,
            endPoint: .trailing
        )
        
        LineGraphShape(points: points[selectedPointType], closePath: true)
            .fill(gradient)
        
        LineGraphShape(points: points[selectedPointType], closePath: false)
            .stroke(
                gradient,
                style: .init(
                    lineWidth: 4.0,
                    lineCap: .round,
                    lineJoin: .round,
                    miterLimit: 10.0
                )
            )
    }
        .drawingGroup()
    ...
    

    Results in a significant performance improvement 🙂