1

I know how to animate a single property like StrokeStart/StrokeEnd, or pairs of properties. I'm talking about what you can do from CoreAnimation: Create an animation that animates replacing one path with a different path with the same number of control points. Something like this:

enter image description here

I created that in Swift/UIKit with this project:

https://github.com/DuncanMC/RandomBlobs

That project uses CoreAnimation and creates a CABasicAnimation that builds a CAShapeLayer and replaces the path in the layer with a new path with the same number of control points.

I would expect SwiftUI to support the same ability using its Shape or Path objects, but have not figured out how to do that yet. (Yes, I know I could wrap a UIKit UIView for use in SwiftUI. I haven't done that yet, but I know it's possible. I'm looking for a native SwiftUI solution.)

askedSep 17, 2024 at 12:54
Duncan C's user avatar

1 Answer1

0

The designated way to do this in SwiftUI is throughAnimatable. The idea is you declare someanimatableData, that SwiftUI will set during each frame of the animation, interpolating between the start and end values.

animatableData needs to be aVectorArithmetic type. For this example, let's suppose the shape has 5 points, thenanimatableData would be representing a vector of 10 values. There is a built-inAnimatablePair that allows you to compose twoVectorArithmetics together, but that's only composing two at a time.

Here is a type that can compose any number ofVectorArithmetic types together

struct ComposedVectorArithmetic<each V>: VectorArithmetic where repeat each V: VectorArithmetic {        var elements: (repeat each V)        static func +(lhs: Self, rhs: Self) -> Self {        .init(elements: (repeat (each lhs.elements) + (each rhs.elements)))    }    static func -(lhs: Self, rhs: Self) -> Self {        .init(elements: (repeat (each lhs.elements) - (each rhs.elements)))    }        mutating func scale(by rhs: Double) {        elements = (repeat (each elements).scaled(by: rhs))    }        var magnitudeSquared: Double {        var sum = 0.0        for element in repeat each elements {            sum += element.magnitudeSquared        }        return sum    }        static func ==(lhs: Self, rhs: Self) -> Bool {        for (a, b) in repeat (each lhs.elements, each rhs.elements) {            if a != b {                return false            }        }        return true    }        static var zero: Self {        .init(elements: (repeat (each V).zero))    }}extension ComposedVectorArithmetic: Sendable where repeat each V: Sendable {}

Then you can write such aShape:

struct FivePointShape: View, Animatable {    var points: FivePoints        var animatableData: FivePoints {        get { points }        set { points = newValue }    }        var body: some View {        Path { p in            p.addLines([points.p1, points.p2, points.p3, points.p4, points.p5])            p.closeSubpath()        }        .fill(.yellow)    }}struct FivePointShape: Shape, Animatable {    typealias AnimatableData = ComposedVectorArithmetic<UnitPoint,UnitPoint,UnitPoint,UnitPoint,UnitPoint>    var points: AnimatableData        var animatableData: AnimatableData {        get { points }        set { points = newValue }    }        func path(in rect: CGRect) -> Path {        Path { p in            p.addLines([                points.elements.0.point(in: rect),                points.elements.1.point(in: rect),                points.elements.2.point(in: rect),                points.elements.3.point(in: rect),                points.elements.4.point(in: rect),            ])            p.closeSubpath()        }    }}extension UnitPoint: @retroactive AdditiveArithmetic, @retroactive VectorArithmetic {    public mutating func scale(by rhs: Double) {        x *= rhs        y *= rhs    }        public var magnitudeSquared: Double {        pow(x, 2) + pow(y, 2)    }        public static func +(lhs: UnitPoint, rhs: UnitPoint) -> UnitPoint {        UnitPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)    }    public static func -(lhs: UnitPoint, rhs: UnitPoint) -> UnitPoint {        UnitPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)    }        // this is for later...    static func random() -> UnitPoint {        .init(x: .random(in: 0...1), y: .random(in: 0...1))    }    func point(in rect: CGRect) -> CGPoint {        .init(x: rect.minX + x * rect.width, y: rect.minY + y * rect.height)    }}

Usage:

struct ContentView: View {        @State var points = FivePointShape.AnimatableData.zero        var body: some View {        ZStack {            FivePointShape(points: points)            Button("Animate!") {                withAnimation {                    points.elements = (                        .random(),                        .random(),                        .random(),                        .random(),                        .random()                    )                }            }        }    }}

Again, I've kept it simple here and just randomly choose a set of points to animate to, so most of the times the path intersects itself. In any case, you can see that each point animates as desired.


To do curves, you can create a type that represents a curve, and compose that usingComposedVectorArithmetic.

struct UnitBezier: VectorArithmetic {        var points: ComposedVectorArithmetic<UnitPoint, UnitPoint, UnitPoint>        var control1: UnitPoint {        get { points.elements.0 }        set { points.elements.0 = newValue }    }    var control2: UnitPoint {        get { points.elements.1 }        set { points.elements.1 = newValue }    }    var endPoint: UnitPoint {        get { points.elements.2 }        set {            // for some reason swiftc cannot infer the type of 'points.elements' here, and crashes            // so I have to write it in this ugly way...            var elems: (UnitPoint, UnitPoint, UnitPoint) = points.elements            elems.2 = newValue            points.elements = elems        }    }        init(control1: UnitPoint, control2: UnitPoint, endPoint: UnitPoint) {        points = .init(elements: (control1, control2, endPoint))    }        init(points: ComposedVectorArithmetic<UnitPoint, UnitPoint, UnitPoint>) {        self.points = points    }        mutating func scale(by rhs: Double) {        points.scale(by: rhs)    }        var magnitudeSquared: Double {        points.magnitudeSquared    }        static var zero: UnitBezier { .init(control1: .zero, control2: .zero, endPoint: .zero) }        static func +(lhs: Self, rhs: Self) -> Self {        .init(points: lhs.points + rhs.points)    }    static func -(lhs: Self, rhs: Self) -> Self {        .init(points: lhs.points - rhs.points)    }    }// example:struct ThreeCurves: Shape, Animatable {    typealias AnimatableData = ComposedVectorArithmetic<UnitPoint, UnitBezier, UnitBezier, UnitBezier>    var points: AnimatableData        var animatableData: AnimatableData {        get { points }        set { points = newValue }    }        func path(in rect: CGRect) -> Path {        Path { p in            p.move(to: points.elements.0.point(in: rect))            p.addCurve(                to: points.elements.1.endPoint.point(in: rect),                control1: points.elements.1.control1.point(in: rect),                control2: points.elements.1.control2.point(in: rect)            )            p.addCurve(                to: points.elements.2.endPoint.point(in: rect),                control1: points.elements.2.control1.point(in: rect),                control2: points.elements.2.control2.point(in: rect)            )            p.addCurve(                to: points.elements.3.endPoint.point(in: rect),                control1: points.elements.3.control1.point(in: rect),                control2: points.elements.3.control2.point(in: rect)            )            p.closeSubpath()        }    }}
answeredSep 17, 2024 at 14:15
Sweeper's user avatar
Sign up to request clarification or add additional context in comments.

12 Comments

Thanks for the answer. That seems pretty unsatisfactory for a number of reasons: You can convert segments in a path from straight lines to Bézier curves and Core Animation animates the change smoothly. Having to build a separate structure for differing numbers of points is awful, and it is a lot of boilerplate code to write. (Not a critique of your answer, but of the current limitations of SwiftUI animation.)
Why don't the creators of SwiftUI treat a path as a single object and use CoreAnimation to do the heavy lifting to animate changes? That's what you do in Core Animation.
I don't see any way to represent curves vs straight lines using VectorArithmetic.
I guess you'd have to represent straight lines as Bézier curves with the middle control points along a straight line between the endpoints. But that wouldn't let you switch between quadratic and cubic Bézier curves like you can with CGPath CAAnimation.
@DuncanC I agree that SwiftUI makes this rather cumbersome, but I do think that curved lines can be somehow represented with vectors, and it is possible to switch between quadratic and cubic curves, because quadratic curves can be seen as a subset of cubic curves, where the 2 control points coincide. I’d imagine that’s how CA does it under the hood too. All animation will eventually boil down to interpolating numbers. There is no magic.
|

Your Answer

Sign up orlog in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

By clicking “Post Your Answer”, you agree to ourterms of service and acknowledge you have read ourprivacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.