How to animate a line move in SwiftUI

Following on from creating blob shapes in SwiftUI, I started looking into animating a change from one shape to another. This article shows how to animate the movement of a straight line from one position to another.



Color and Scale change

As mentioned previously in Animating a shape change in SwiftUI, SwiftUI is great for animation with many feature provided by the SwiftUI framework. This code shows how to animate the size and color of a line segment that is defined using Path. The transition in size and color is animated smoothly when the button is pressed.

 1struct LineSegmentView: View {
 2    @State private var changeLine = false
 3
 4    var body: some View {
 5        VStack {
 6            Text("Scale animation")
 7
 8            LineSegment()
 9                .stroke(changeLine ? Color(.red) : .purple, lineWidth: 4.0)
10                .frame(width: 200, height: 150)
11                .scaleEffect(changeLine ? 0.5 : 1.0)
12                .animation(.linear(duration: 1))
13
14            Button("Change Line") {
15                changeLine.toggle()
16            }
17            .buttonStyle(BlueButtonStyle())
18
19            Spacer()
20        }
21    }
22}
 1struct LineSegment: Shape {
 2    func path(in rect: CGRect) -> Path {
 3        let start = CGPoint(x: 0.0, y: 0.0)
 4        let end = CGPoint(x: rect.width, y: rect.height)
 5
 6        var path = Path()
 7        path.move(to: start)
 8        path.addLine(to: end)
 9        return path
10    }
11}

A ButtonStyle is used to style the button.

 1struct BlueButtonStyle: ButtonStyle {
 2    func makeBody(configuration: Configuration) -> some View {
 3        configuration.label
 4            .font(.system(size: 24, weight:.bold, design: .rounded))
 5            .foregroundColor(.white)
 6            .padding(.horizontal)
 7            .padding(5)
 8            .background(Color.blue)
 9            .cornerRadius(10)
10            .shadow(color:.black, radius:3, x:3.0, y:3.0)
11            .opacity(configuration.isPressed ? 0.6 : 1.0)
12            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
13    }
14}

Animate a change in scale and color for a straight line

Animate a change in scale and color for a straight line



Move one point

Scale does move the line by scaling the line down, but this resizes the width and length of the line. What if we want to change the length of the line or move one end of a line, but not change the width? This code changes one point of the line as well as the color. The movement of the line is not animated, although the color change is.

LineSegment2 takes one parameter for the endpoint of the line and draws a line from (0,0) to this point. The coordinates for the point are between 0.0 and 1.0 and represent a proportion of the containing view. Remember that iOS coordinate system starts with (0,0) in the top left corner with x increasing across the screen and y increasing down the screen.

 1struct LineSegment2: Shape {
 2    var endPoint: CGPoint
 3
 4    func path(in rect: CGRect) -> Path {
 5        let start = CGPoint(x: 0.0, y: 0.0)
 6        let end = CGPoint(x: endPoint.x * rect.width,
 7                          y: endPoint.y * rect.height)
 8        var path = Path()
 9        path.move(to: start)
10        path.addLine(to: end)
11        return path
12    }
13}
 1struct LineSegment2View: View {
 2    @State private var changeLine = false
 3
 4    var body: some View {
 5        VStack {
 6            Text("Line move not animated")
 7
 8            LineSegment2(endPoint: CGPoint(
 9                            x: changeLine ? 0.3 : 1.0,
10                            y: changeLine ? 0.9: 1.0))
11                .stroke(changeLine ? Color(.red) : .purple, lineWidth: 4.0)
12                .frame(width: 200, height: 150)
13                .animation(.linear(duration: 2))
14
15            Button("Change Line") {
16                changeLine.toggle()
17            }
18            .buttonStyle(BlueButtonStyle())
19
20            Spacer()
21        }
22    }
23}

Move one point of line not animated by default

Move one point of line not animated by default



Use of AnimatablePair

The movement of the line is not animated because there are two pieces of data changing; the x coordinate; and the y coordinate. The Shape struct already conforms to Animatable protocol and has animatableData, but this is defaulted to EmptyAnimatableData. Modify the LineSegment3 shape to implement the animatableData instance property with an AnimatablePair. This AnimatablePair consists of the x and y coordinates of the line end point. SwiftUI now knows how to transition progressively from a line with one endpoint to another and produces a smooth animation.

 1struct LineSegment3: Shape {
 2    var endPoint: CGPoint
 3
 4    var animatableData: AnimatablePair<CGFloat, CGFloat> {
 5        get { AnimatablePair(endPoint.x, endPoint.y) }
 6        set {
 7            endPoint.x = newValue.first
 8            endPoint.y = newValue.second
 9        }
10    }
11
12    func path(in rect: CGRect) -> Path {
13        let start = CGPoint(x: 0.0, y: 0.0)
14        let end = CGPoint(x: endPoint.x * rect.width,
15                          y: endPoint.y * rect.height)
16        var path = Path()
17        path.move(to: start)
18        path.addLine(to: end)
19        return path
20    }
21}

Animate movement of one point of a line with AnimatablePair

Animate movement of one point of a line with AnimatablePair



AnimatableData and VectorArithmetic

The animatableData instance property works with data types that conform to VectorArithmetic protocol. We can create a structure that conforms to VectorArithmetic protocol if we want to animate the movement of a straight line when both endpoints are changing. This code defines an AnimatableSegment composed of two CGPoints.

 1struct AnimatableSegment : VectorArithmetic {
 2    var startPoint: CGPoint
 3    var endPoint: CGPoint
 4
 5    var length: Double {
 6        return Double(((endPoint.x - startPoint.x) * (endPoint.x - startPoint.x)) +
 7                        ((endPoint.y - startPoint.y) * (endPoint.y - startPoint.y))).squareRoot()
 8    }
 9
10    var magnitudeSquared: Double {
11        return length * length
12    }
13
14    mutating func scale(by rhs: Double) {
15        self.startPoint.x.scale(by: rhs)
16        self.startPoint.y.scale(by: rhs)
17        self.endPoint.x.scale(by: rhs)
18        self.endPoint.y.scale(by: rhs)
19    }
20
21    static var zero: AnimatableSegment {
22        return AnimatableSegment(startPoint: CGPoint(x: 0.0, y: 0.0),
23                                 endPoint: CGPoint(x: 0.0, y: 0.0))
24    }
25
26    static func - (lhs: AnimatableSegment, rhs: AnimatableSegment) -> AnimatableSegment {
27        return AnimatableSegment(
28            startPoint: CGPoint(x: lhs.startPoint.x - rhs.startPoint.x,
29                                y: lhs.startPoint.y - rhs.startPoint.y),
30            endPoint: CGPoint(x: lhs.endPoint.x - rhs.endPoint.x,
31                              y: lhs.endPoint.y - rhs.endPoint.y))
32    }
33
34    static func -= (lhs: inout AnimatableSegment, rhs: AnimatableSegment) {
35        lhs = lhs - rhs
36    }
37
38    static func + (lhs: AnimatableSegment, rhs: AnimatableSegment) -> AnimatableSegment {
39        return AnimatableSegment(
40            startPoint: CGPoint(x: lhs.startPoint.x + rhs.startPoint.x,
41                                y: lhs.startPoint.y + rhs.startPoint.y),
42            endPoint: CGPoint(x: lhs.endPoint.x + rhs.endPoint.x,
43                              y: lhs.endPoint.y + rhs.endPoint.y))
44    }
45
46    static func += (lhs: inout AnimatableSegment, rhs: AnimatableSegment) {
47        lhs = lhs + rhs
48    }
49}


Animate line segment move

The AnimatableSegment conforms to VectorArithmetic protocol, so it can be used in the animatableData instance property. This will provide enough information to SwiftUI to be able to transition both points at the same time and display intermediary paths between these points. A private variable is used for the AnimatableSegment, so the parameters required are still just a start point and end point. An initializer needs to be added to create the animatableSegment from the line points.

 1struct LineSegment4: Shape {
 2    var startPoint: CGPoint
 3    var endPoint: CGPoint
 4
 5    private var animatableSegment: AnimatableSegment
 6
 7    var animatableData: AnimatableSegment {
 8        get { AnimatableSegment(startPoint: startPoint, endPoint: endPoint) }
 9        set {
10            startPoint = newValue.startPoint
11            endPoint = newValue.endPoint
12        }
13    }
14
15    init(startPoint: CGPoint, endPoint: CGPoint) {
16        self.startPoint = startPoint
17        self.endPoint = endPoint
18        self.animatableSegment = AnimatableSegment(startPoint: startPoint, endPoint: endPoint)
19    }
20
21    func path(in rect: CGRect) -> Path {
22        let start = CGPoint(x: startPoint.x * rect.width,
23                            y: startPoint.y * rect.height)
24        let end = CGPoint(x: rect.width * endPoint.x,
25                          y: rect.height * endPoint.y)
26        var path = Path()
27        path.move(to: start)
28        path.addLine(to: end)
29        return path
30    }
31}
 1struct LineSegment4View: View {
 2    @State private var changeLine = false
 3
 4    var body: some View {
 5        VStack {
 6            Text("Animatable Line Segment")
 7
 8            LineSegment4(startPoint: CGPoint(
 9                            x: changeLine ? 0.2 : 0.8,
10                            y: changeLine ? 0.3 : 0.9),
11                         endPoint: CGPoint(
12                            x: changeLine ? 0.5 : 0.9,
13                            y: changeLine ? 0.9 : 0.4))
14                .stroke(changeLine ? Color(.red) : .purple, lineWidth: 4.0)
15                .frame(width: 250, height: 150)
16                .animation(.linear(duration: 2))
17
18            HStack {
19                LineSegment4(startPoint: CGPoint(
20                                x: changeLine ? 0.0 : 0.2,
21                                y: changeLine ? 0.0 : 0.9),
22                             endPoint: CGPoint(
23                                x: changeLine ? 0.2 : 0.9,
24                                y: changeLine ? 0.9 : 0.4))
25                    .stroke(Color.green, lineWidth: 4.0)
26                    .frame(width: 150, height: 150)
27                    .animation(.linear(duration: 2))
28                LineSegment4(startPoint: CGPoint(
29                                x: changeLine ? 1.0 : 0.1,
30                                y: changeLine ? 1.0 : 0.2),
31                             endPoint: CGPoint(
32                                x: changeLine ? 0.8 : 0.4,
33                                y: changeLine ? 0.1 : 0.7))
34                    .stroke(Color.orange, lineWidth: 4.0)
35                    .frame(width: 150, height: 150)
36                    .animation(.linear(duration: 2))
37            }
38
39            Button("Change Line") {
40                changeLine.toggle()
41            }
42            .buttonStyle(BlueButtonStyle())
43
44            Spacer()
45        }
46    }
47}

Animatable line segments using AnimatableData

Animatable line segments using AnimatableData




Conclusion

As discussed in Animating a shape change in SwiftUI, the SwiftUI framework does not know how to animate a Path change from one path to another. Animation works well when there is a predefined transition from one state to another such as a color change. The Shape struct already conforms to Animatable protocol, but has animatableData set to EmptyAnimatableData by default.

The movement of one point of a line requires the change of two values (the x and y coordinates). This can be implemented using an AnimatablePair in the animatableData instance property. The full animation of a line segment requires the animation of four values. AnimatableData can be set to one value or and AnimatablePair. The solution is to create a custom type that conforms to the VectorArithmetic protocol and use this new type as the AnimatableData.