How to animate a Cubic Curve change in SwiftUI

Continuing on the exploration of animating Paths in SwiftUI. This article shows how to animate the movement of a cubic Bézier curve from one position to another. The smooth transition from a straight line to a curved line is also shown by adjusting the two control points.

The path structure can be used to create a cubic Bézier curve using the addCurve function that takes the end point and two control points as parameters.

Animate Cubic Curve Shape

First define a struct AnimatableCubicCurve that conforms to VectorArithmetic protocol. This is similar to the AnimatableQuadCurve that was defined in How to animate a quadratic curve change in SwiftUI.

 1struct AnimatableCubicCurve : VectorArithmetic {
2    var start: CGPoint
3    var end: CGPoint
4    var control1: CGPoint
5    var control2: CGPoint
6
7    var length: Double {
8        return Double(((end.x - start.x) * (end.x - start.x)) +
9                        ((end.y - start.y) * (end.y - start.y))).squareRoot()
10    }
11
12    var magnitudeSquared: Double {
13        return length * length
14    }
15
16    mutating func scale(by rhs: Double) {
17        self.start.x.scale(by: rhs)
18        self.start.y.scale(by: rhs)
19        self.end.x.scale(by: rhs)
20        self.end.y.scale(by: rhs)
21        self.control1.x.scale(by: rhs)
22        self.control1.y.scale(by: rhs)
23        self.control2.x.scale(by: rhs)
24        self.control2.y.scale(by: rhs)
25    }
26
27    static var zero: AnimatableCubicCurve {
28        return AnimatableCubicCurve(start: CGPoint(x: 0.0, y: 0.0),
29                                    end: CGPoint(x: 0.0, y: 0.0),
30                                    control1: CGPoint(x: 0.0, y: 0.0),
31                                    control2: CGPoint(x: 0.0, y: 0.0))
32    }
33
34    static func - (lhs: AnimatableCubicCurve, rhs: AnimatableCubicCurve) -> AnimatableCubicCurve {
35        return AnimatableCubicCurve(
36            start: CGPoint(
37                x: lhs.start.x - rhs.start.x,
38                y: lhs.start.y - rhs.start.y),
39            end: CGPoint(
40                x: lhs.end.x - rhs.end.x,
41                y: lhs.end.y - rhs.end.y),
42            control1: CGPoint(
43                x: lhs.control1.x - rhs.control1.x,
44                y: lhs.control1.y - rhs.control1.y),
45            control2: CGPoint(
46                x: lhs.control2.x - rhs.control2.x,
47                y: lhs.control2.y - rhs.control2.y))
48    }
49
50    static func -= (lhs: inout AnimatableCubicCurve, rhs: AnimatableCubicCurve) {
51        lhs = lhs - rhs
52    }
53
54    static func + (lhs: AnimatableCubicCurve, rhs: AnimatableCubicCurve) -> AnimatableCubicCurve {
55        return AnimatableCubicCurve(
56            start: CGPoint(
57                x: lhs.start.x + rhs.start.x,
58                y: lhs.start.y + rhs.start.y),
59            end: CGPoint(
60                x: lhs.end.x + rhs.end.x,
61                y: lhs.end.y + rhs.end.y),
62            control1: CGPoint(
63                x: lhs.control1.x + rhs.control1.x,
64                y: lhs.control1.y + rhs.control1.y),
65            control2: CGPoint(
66                x: lhs.control2.x + rhs.control2.x,
67                y: lhs.control2.y + rhs.control2.y))
68    }
69
70    static func += (lhs: inout AnimatableCubicCurve, rhs: AnimatableCubicCurve) {
71        lhs = lhs + rhs
72    }
73}


Next, define a CubicCurveShape Shape that uses the AnimatableCubicCurve for the AnimatableData. This can be constructed either with the four points or with an AnimatableCubicCurve.

 1struct CubicCurveShape: Shape {
2    var startPoint: CGPoint
3    var endPoint: CGPoint
4    var controlPoint1: CGPoint
5    var controlPoint2: CGPoint
6
7    private var animatableSegment: AnimatableCubicCurve
8
9    var animatableData: AnimatableCubicCurve {
10        get { AnimatableCubicCurve(
11            start: startPoint, end: endPoint, control1: controlPoint1, control2: controlPoint2) }
12        set {
13            startPoint = newValue.start
14            endPoint = newValue.end
15            controlPoint1 = newValue.control1
16            controlPoint2 = newValue.control2
17        }
18    }
19
20    init(startPoint: CGPoint, endPoint: CGPoint, controlPoint1: CGPoint, controlPoint2: CGPoint) {
21        self.startPoint = startPoint
22        self.endPoint = endPoint
23        self.controlPoint1 = controlPoint1
24        self.controlPoint2 = controlPoint2
25        self.animatableSegment = AnimatableCubicCurve(
26            start: startPoint,
27            end: endPoint,
28            control1: controlPoint1,
29            control2: controlPoint2)
30    }
31
32    init(curve: AnimatableCubicCurve) {
33        self.init(startPoint: curve.start,
34                  endPoint: curve.end,
35                  controlPoint1: curve.control1,
36                  controlPoint2: curve.control2)
37    }
38
39    func path(in rect: CGRect) -> Path {
40        let start = CGPoint(x: startPoint.x * rect.width,
41                            y: startPoint.y * rect.height)
42        let end = CGPoint(x: rect.width * endPoint.x,
43                          y: rect.height * endPoint.y)
44        let control1 = CGPoint(x: rect.width * controlPoint1.x,
45                               y: rect.height * controlPoint1.y)
46        let control2 = CGPoint(x: rect.width * controlPoint2.x,
47                               y: rect.height * controlPoint2.y)
48
49        var path = Path()
50        path.move(to: start)
51        path.addCurve(to: end, control1: control1, control2: control2)
52        return path
53    }
54}


Display AnimatableCubicCurve

Two cubic curves are defined and CubicCurveShape is used to transition from one curve to another when the button is selected. The line move is animated as well as the color change. The start point, end point and control points all transition smoothly from one location to another.

 1struct CurveView1: View {
2    @State private var changeCurve = true
3
4    let segment1 = AnimatableCubicCurve(
5        start: CGPoint(x: 0.8, y: 0.0),
6        end: CGPoint(x: 0.2, y: 1.0),
7        control1: CGPoint(x: 0.1, y: 0.25),
8        control2: CGPoint(x: 0.9, y: 0.75)
9    )
10    let segment2 = AnimatableCubicCurve(
11        start: CGPoint(x: 0.2, y: 0.9),
12        end: CGPoint(x: 0.9, y: 0.7),
13        control1: CGPoint(x: 1.0, y: 1.0),
14        control2: CGPoint(x: 0.0, y: 0.0)
15    )
16
17    var body: some View {
18        VStack(spacing:30) {
19            Text("Animatable Cubic Curve")
20
21            CubicCurveShape(curve: changeCurve ? segment1 : segment2)
22                .stroke(changeCurve ? Color(.purple) : .orange, lineWidth: 4.0)
23                .frame(width: 250, height: 250)
24                .animation(.linear(duration: 1))
25
26            Button("Change") {
27                changeCurve.toggle()
28            }
29            .buttonStyle(BlueButtonStyle())
30
31            Spacer()
32        }
33    }
34}


Animate a cubic curve transition

Animate a cubic curve

Animate a sequence of curve lines

An array of AnimatableCubicCurve is defined and the index of the selected curve is changed with a Timer. The timer is set to update every second and increment the index of the selected curve some the series of Cubic curves is cycled through and each transition is animated.

 1struct curves {
2    static let segments = createSegments()
3    static func createSegments() -> [AnimatableCubicCurve] {
4        var segs = [AnimatableCubicCurve]()
5        for i in (0...5) {
6            let seg = AnimatableCubicCurve(
7                start: CGPoint(x: 0.0 + (0.2 * Double(i)), y: 0.0 + (0.2 * Double(i))),
8                end: CGPoint(x: 1.0 - (0.2 * Double(i)), y: 1.0 - (0.2 * Double(i))),
9                control1: CGPoint(x: 0.0, y: 0.0 + (0.2 * Double(i))),
10                control2: CGPoint(x: 1.0, y: 1.0 - (0.2 * Double(i))))
11            segs.append(seg)
12        }
13        for i in (0...5) {
14            let seg = AnimatableCubicCurve(
15                start: CGPoint(x: 1.0, y: 1.0 - (0.2 * Double(i))),
16                end: CGPoint(x: 0.0, y: 0.0 + (0.2 * Double(i))),
17                control1: CGPoint(x: 1.0 - (0.2 * Double(i)), y: 1.0 - (0.2 * Double(i))),
18                control2: CGPoint(x: 1.0 - (0.2 * Double(i)), y: 0.0 + (0.2 * Double(i))))
19            segs.append(seg)
20        }
21        return segs
22    }
23}

 1struct CurveView2: View {
2    @State private var index = 0
3    @State private var counter = 0
4
5    let timer = Timer.publish(every: 1,
6                              on: .main,
7                              in: .common)
8        .autoconnect()
9    let colors:[Color] = [.red, .orange, .yellow, .green, .blue, .purple]
10    let segments = curves.segments
11
12    var body: some View {
13        VStack(spacing:30) {
14            Text("Animatable Cubic Curve")
15
16            CubicCurveShape(curve: segments[index])
17                .stroke(colors[index % colors.count], lineWidth: 4.0)
18                .frame(width: 250, height: 300)
19                .animation(.linear(duration: 1))
21                    if self.counter == (segments.count * 3) {
22                        self.timer.upstream.connect().cancel()
23                    } else {
24                        self.index = (self.index + 1) % segments.count
25                    }
26
27                    self.counter += 1
28                }
29
30            Spacer()
31        }
32    }
33}


Animate a series of curves

Combining curves to make a shape

This code defines the shapes of the Playing card suits as an array of AnimatableCubicCurves. These are variations on the shapes used in Create a blob shape in SwiftUI except All segments of the shapes are defined using AnimatableCubicCurve.

 1struct Suit {
2    static let spade = [
3        AnimatableCubicCurve(start: CGPoint(x: 0.50, y: 0.00), end: CGPoint(x: 0.85, y: 0.40), control1: CGPoint(x: 0.60, y: 0.30), control2: CGPoint(x: 0.60, y: 0.30)),
4        AnimatableCubicCurve(start: CGPoint(x: 0.85, y: 0.40), end: CGPoint(x: 0.55, y: 0.65), control1: CGPoint(x: 1.15, y: 0.50), control2: CGPoint(x: 0.95, y: 1.20)),
5        AnimatableCubicCurve(start: CGPoint(x: 0.55, y: 0.65), end: CGPoint(x: 0.70, y: 0.90), control1: CGPoint(x: 0.55, y: 0.80), control2: CGPoint(x: 0.55, y: 0.80)),
6        AnimatableCubicCurve(start: CGPoint(x: 0.70, y: 0.90), end: CGPoint(x: 0.70, y: 1.00), control1: CGPoint(x: 0.80, y: 0.98), control2: CGPoint(x: 0.80, y: 0.98)),
7        AnimatableCubicCurve(start: CGPoint(x: 0.70, y: 1.00), end: CGPoint(x: 0.30, y: 1.00), control1: CGPoint(x: 0.70, y: 1.00), control2: CGPoint(x: 0.70, y: 1.00)),
8        AnimatableCubicCurve(start: CGPoint(x: 0.30, y: 1.00), end: CGPoint(x: 0.30, y: 0.90), control1: CGPoint(x: 0.20, y: 0.98), control2: CGPoint(x: 0.20, y: 0.98)),
9        AnimatableCubicCurve(start: CGPoint(x: 0.30, y: 0.90), end: CGPoint(x: 0.45, y: 0.65), control1: CGPoint(x: 0.45, y: 0.80), control2: CGPoint(x: 0.45, y: 0.80)),
10        AnimatableCubicCurve(start: CGPoint(x: 0.45, y: 0.65), end: CGPoint(x: 0.15, y: 0.40), control1: CGPoint(x: 0.05, y: 1.20), control2: CGPoint(x: -0.15, y: 0.50)),
11        AnimatableCubicCurve(start: CGPoint(x: 0.15, y: 0.40), end: CGPoint(x: 0.50, y: 0.00), control1: CGPoint(x: 0.40, y: 0.30), control2: CGPoint(x: 0.40, y: 0.30))
12    ]
13
14    static let club = [
15        AnimatableCubicCurve(start: CGPoint(x: 0.30, y: 0.30), end: CGPoint(x: 0.70, y: 0.30), control1: CGPoint(x: 0.0, y: -0.1), control2: CGPoint(x: 1.0, y: -0.1)),
16        AnimatableCubicCurve(start: CGPoint(x: 0.70, y: 0.30), end: CGPoint(x: 0.55, y: 0.45), control1: CGPoint(x: 0.70, y: 0.30), control2: CGPoint(x: 0.70, y: 0.30)),
17        AnimatableCubicCurve(start: CGPoint(x: 0.55, y: 0.45), end: CGPoint(x: 0.55, y: 0.70), control1: CGPoint(x: 1.15, y: 0.0), control2: CGPoint(x: 1.15, y: 1.20)),
18        AnimatableCubicCurve(start: CGPoint(x: 0.55, y: 0.70), end: CGPoint(x: 0.70, y: 1.0), control1: CGPoint(x: 0.55, y: 0.70), control2: CGPoint(x: 0.55, y: 0.70)),
19        AnimatableCubicCurve(start: CGPoint(x: 0.70, y: 1.0), end: CGPoint(x: 0.30, y: 1.0), control1: CGPoint(x: 0.70, y: 1.0), control2: CGPoint(x: 0.70, y: 1.0)),
20        AnimatableCubicCurve(start: CGPoint(x: 0.30, y: 1.0), end: CGPoint(x: 0.45, y: 0.70), control1: CGPoint(x: 0.30, y: 1.0), control2: CGPoint(x: 0.30, y: 1.0)),
21        AnimatableCubicCurve(start: CGPoint(x: 0.45, y: 0.70), end: CGPoint(x: 0.45, y: 0.45), control1: CGPoint(x: -0.15, y: 1.2), control2: CGPoint(x: -0.15, y: 0.0)),
22        AnimatableCubicCurve(start: CGPoint(x: 0.45, y: 0.45), end: CGPoint(x: 0.3, y: 0.3), control1: CGPoint(x: 0.45, y: 0.45), control2: CGPoint(x: 0.45, y: 0.45))
23    ]
24
25    static let diamond = [
26        AnimatableCubicCurve(start: CGPoint(x: 0.5, y: 0.0), end: CGPoint(x: 1.0, y: 0.5), control1: CGPoint(x: 0.7, y: 0.3), control2: CGPoint(x: 0.7, y: 0.3)),
27        AnimatableCubicCurve(start: CGPoint(x: 1.0, y: 0.5), end: CGPoint(x: 0.5, y: 1.0), control1: CGPoint(x: 0.7, y: 0.7), control2: CGPoint(x: 0.7, y: 0.7)),
28        AnimatableCubicCurve(start: CGPoint(x: 0.5, y: 1.0), end: CGPoint(x: 0.0, y: 0.5), control1: CGPoint(x: 0.3, y: 0.7), control2: CGPoint(x: 0.3, y: 0.7)),
29        AnimatableCubicCurve(start: CGPoint(x: 0.0, y: 0.5), end: CGPoint(x: 0.5, y: 0.0), control1: CGPoint(x: 0.3, y: 0.3), control2: CGPoint(x: 0.3, y: 0.3))
30    ]
31
32    static let heart = [
33        AnimatableCubicCurve(start: CGPoint(x: 0.5, y: 0.25), end: CGPoint(x: 1.0, y: 0.25), control1: CGPoint(x: 0.5, y: -0.1), control2: CGPoint(x: 1.0, y: 0.0)),
34        AnimatableCubicCurve(start: CGPoint(x: 1.0, y: 0.25), end: CGPoint(x: 0.5, y: 1.0), control1: CGPoint(x: 1.0, y: 0.6), control2: CGPoint(x: 0.5, y: 0.8)),
35        AnimatableCubicCurve(start: CGPoint(x: 0.5, y: 1.0), end: CGPoint(x: 0.0, y: 0.25), control1: CGPoint(x: 0.5, y: 0.8), control2: CGPoint(x: 0.0, y: 0.6)),
36        AnimatableCubicCurve(start: CGPoint(x: 0.0, y: 0.25), end: CGPoint(x: 0.5, y: 0.25), control1: CGPoint(x: 0.0, y: 0.0), control2: CGPoint(x: 0.5, y: -0.1))
37    ]
38}


The cubic line segments are arranged in a ZStack to create the shape of the card suit. A loop is used inside the ZStack to add a stroke for the shape. The card suit is changed from one suit to another with the use of a state variable changeCurve. This codes switches from Club to Spade and also from heard to diamond. These shapes have similar number of lines and the animation effect shows the shape breaking up into component lines and rearranging into the new shape.

 1struct SuitChangeView1: View {
2    @State private var changeCurve = true
3
4    var body: some View {
5        VStack(spacing:30) {
6            Text("Shape change with Animatable Cubic Curve")
7
8            Spacer()
9                .frame(height:20)
10
11            HStack(spacing:30) {
12                ZStack {
13                    ForEach(0..<max(Suit.club.count, Suit.spade.count)) { i in
15                            .stroke(changeCurve ? Color(.black) : .blue, lineWidth: 4.0)
16                    }
17                }
18                .frame(width: 150, height: 150)
19                .animation(.linear(duration: 1))
20
21                ZStack {
22                    ForEach(0..<max(Suit.heart.count, Suit.diamond.count)) { i in
23                        CubicCurveShape(curve: changeCurve ? Suit.heart[(i % Suit.heart.count)] : Suit.diamond[(i % Suit.diamond.count)])
24                            .stroke(changeCurve ? Color(.red) : .orange, lineWidth: 4.0)
25                    }
26                }
27                .frame(width: 150, height: 150)
28                .animation(.linear(duration: 1))
29
30            }
31
32            Spacer()
33                .frame(height:20)
34
35            Button("Change") {
36                self.changeCurve.toggle()
37            }
38            .buttonStyle(BlueButtonStyle())
39
40            Spacer()
41        }
42    }
43}


Transition to shape with similar number of segments

Shapes with different number of segments

The transition from one shape to another using the AnimatableCubicCurve in a ZStack works much better when the number of segments are equal. This can be seen in the animation from heart to diamond and back again. The code that loops through the shape segments does allow for shapes with different number of segments by cycling around to the start of the shape. If you look closely it can be seen that there are duplicate lines in the simpler shapes like heart when it is transitioned from the club shape.

 1struct SuitChangeView2: View {
2    @State private var changeCurve = true
3
4    var body: some View {
5        VStack(spacing:50) {
6            Text("Shape change with Animatable Cubic Curve")
7
8            HStack(spacing:40) {
9                ZStack {
10                    ForEach(0..<max(Suit.club.count, Suit.heart.count)) { i in
11                        CubicCurveShape(curve: changeCurve ?
12                                            Suit.club[(i % Suit.club.count)] :
13                                            Suit.heart[(i % Suit.heart.count)])
14                            .stroke(changeCurve ? Color(.black) : .red, lineWidth: 4.0)
15                    }
16                }
17                .frame(width: 200, height: 200)
18                .animation(.linear(duration: 1))
19
20                ZStack {
21                    ForEach(0..<max(Suit.heart.count, Suit.spade.count)) { i in
22                        CubicCurveShape(curve: changeCurve ?
23                                            Suit.heart[(i % Suit.heart.count)] :
25                            .stroke(changeCurve ? Color(.red) : .black, lineWidth: 4.0)
26                    }
27                }
28                .frame(width: 200, height: 200)
29                .animation(.linear(duration: 1))
30
31                ZStack {
32                    ForEach(0..<max(Suit.diamond.count, Suit.spade.count)) { i in
33                        CubicCurveShape(curve: changeCurve ?
35                                            Suit.diamond[(i % Suit.diamond.count)])
36                            .stroke(changeCurve ? Color(.black) : .red, lineWidth: 4.0)
37                    }
38                }
39                .frame(width: 200, height: 200)
40                .animation(.linear(duration: 1))
41
42                ZStack {
43                    ForEach(0..<max(Suit.club.count, Suit.diamond.count)) { i in
44                        CubicCurveShape(curve: changeCurve ?
45                                            Suit.diamond[(i % Suit.diamond.count)] :
46                                            Suit.club[(i % Suit.club.count)])
47                            .stroke(changeCurve ? Color(.red) : .black, lineWidth: 4.0)
48                    }
49                }
50                .frame(width: 200, height: 200)
51                .animation(.linear(duration: 1))
52            }
53
54            Button("Change") {
55                self.changeCurve.toggle()
56            }
57            .buttonStyle(BlueButtonStyle())
58
59            Spacer()
60        }
61    }
62}


Transition to shape with different number of segments

Conclusion

This article built on the animation of quadratic Bézier curve by defining AnimatableCubicCurve to animate changes to cubic Bézier curves.The animation of a cubic curve requires the animation of eight values - the coordinates of the start, end and two control points of the cubic curve path. This is achieved by defining a structure for AnimatableCubicCurve that conforms to the VectorArithmetic protocol and using this new type as the AnimatableData in a shape structure. The CubicCurveShape structure can be used in views to animate the transition from straight lines, through quadratic curves to cubic curves as all these paths can be defined as cubic Bézier paths.

The cubic line segments can be arranged in a ZStack to create shapes. The change from one shape to another can be animated by changing the individual segments. The transition from one shape to another is better when the number of segments are equal. Animation of shape change between shapes with different number of segments is shown and duplicate segments can be seen in the more simpler shapes during the transition.