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