使用矢量算术的 SwiftUI 线图动画

SwiftUI Line Graph Animation using Vector Arithmetic

寻找提高绘图性能的方法(希望不使用 Metal)。将 LinearGradient 作为填充添加到 Shape 中会对绘图性能产生巨大影响,因此我将其排除在外。

向量

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: [])
    }
}

形状

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([=11=].x, [=11=].y) }) }
        set { points = newValue.points.map { CGPoint(x: [=11=].first, y: [=11=].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
            }
        }
    }
}

用法

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)
        }
    }
}

为什么要避免使用 Metal?启用它的支持就像将 LineGraphShape 包装到 Group 中并用 drawingGroup() 修改它一样简单。试试看:

...
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()
...

显着提高性能