Animating a shape change in SwiftUI
Animations can be used to give an App character and help bring the App to life. They can be used to explain how an app works, enhance navigation between screens or simply direct a user's attention. In this article we show how to animate the change of a shape from a triangle to a square or a circle.
There are two mechanisms of animating views in SwiftUI - implicit and explicit. Implicit animations are specified by attaching the Animation modifier to the view to be animated. Explicit animations are specified using withAnimation block and specifying the animations inside the closure. Implicit animations are used in this article.
Animate circle color and size
Start by creating an animation on a circle shape that changes size and color when the circle is tapped. The circle can be created using the Circle structure or using the Path structure. The duration of the animation in the second circle is set to 2 seconds to show the smooth transition in both color and size. These animations work because of the Animatable protocol where the views have a computed property that conforms to VectorArithmetic. This allows SwiftUI framework to interpolate values in between the two states and render intermediate views during the transition.
1struct CircleView: View {
2 @State private var open = false
3
4 var body: some View {
5 VStack {
6 Circle()
7 .scale(self.open ? 0.5 : 1.0)
8 .fill(self.open ? Color(.green) : .red)
9 .frame(width: 200, height: 200)
10 .animation(.linear)
11 .onTapGesture{
12 self.open.toggle()
13 }
14
15 // Slow down the animation to see the transition
16 Circle()
17 .scale(self.open ? 0.5 : 1.0)
18 .fill(self.open ? Color(.green) : .red)
19 .frame(width: 200, height: 200)
20 .animation(.linear(duration: 2))
21 .onTapGesture{
22 self.open.toggle()
23 }
24
25 Path() { path in
26 path.addEllipse(in: CGRect(x: 0, y: 0, width: 200, height: 200))
27 }
28 .scale(self.open ? 0.5 : 1.0)
29 .fill(self.open ? Color(.green) : .red)
30 .frame(width: 200, height: 200)
31 .animation(.linear)
32 .onTapGesture{
33 self.open.toggle()
34 }
35
36 Spacer()
37 }
38 }
39}
Animate circle size and color change on tap gesture
Animate circle color and size change
Animate by changing shape and color
The first attempt is to change the shape based on a state property. This animates one shape fading out and the other shape fading in. One view is transitioned to another view in the best way the SwiftUI framework can animate.
1struct CircleSquareView: View {
2 @State private var open = true
3
4 var body: some View {
5 VStack {
6 VStack {
7 if self.open{
8 Circle()
9 .scale(self.open ? 0.75 : 1.0)
10 .fill(Color.green)
11 }
12 else {
13 Rectangle()
14 .scale(self.open ? 0.75 : 1.0)
15 .fill(Color.red)
16 .cornerRadius(20)
17 }
18 Spacer()
19 }
20 .frame(width:200, height: 200)
21 .animation(.linear(duration: 2))
22 .onTapGesture{
23 self.open.toggle()
24 }
25
26 Spacer()
27 }
28 }
29}
Animate switching from Circle to a Square
Only color is animated, shape just changes
Define regular polygon shape
The desired animation is a changing of shape from a circle into a square. Define a regular polygon that takes the number of sides as a parameter. There are more details on Trigonometry and conversion between Polar and Cartesian coordinates covered in "Two Dimensional shapes".
1func Cartesian(length:Double, angle:Double) -> CGPoint {
2 return CGPoint(x: length * cos(angle), y: length * sin(angle))
3}
4
5struct RegularPolygon: Shape {
6 var sides:Int
7
8 func path(in rect: CGRect) -> Path {
9 // centre of the containing rect
10 let c = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
11 // radius of a circle that will fit in the rect
12 let r = Double(min(rect.width,rect.height)) / 2.0
13 let offsetAngle = (Double.pi / Double(sides)) + Double.pi/2.0
14 var vertices:[CGPoint] = []
15 for i in 0...sides {
16 // Calculate the angle in Radians
17 let angle = (2.0 * Double.pi * Double(i)/Double(sides)) + offsetAngle
18 let pt = Cartesian(length: r, angle: angle)
19 // Move the point relative to the center of the rect and add to vertices
20 vertices.append(CGPoint(x: pt.x + c.x, y: pt.y + c.y))
21 }
22
23 var path = Path()
24 for (n, pt) in vertices.enumerated() {
25 n == 0 ? path.move(to: pt) : path.addLine(to: pt)
26 }
27 path.closeSubpath()
28 return path
29 }
30}
The RegularPolygon
shape is used to generate a number of polygons with increasing
number of sides.
1struct RegularPloygonsView: View {
2 var body: some View {
3 VStack {
4 Group() {
5 RegularPolygon(sides: 3)
6 .stroke(Color.red, lineWidth: 10)
7 RegularPolygon(sides: 4)
8 .stroke(Color.orange, lineWidth: 10)
9 RegularPolygon(sides: 5)
10 .stroke(Color.yellow, lineWidth: 10)
11 RegularPolygon(sides: 6)
12 .stroke(Color.green, lineWidth: 10)
13 RegularPolygon(sides: 7)
14 .stroke(Color.blue, lineWidth: 10)
15 RegularPolygon(sides: 8)
16 .stroke(Color.purple, lineWidth: 10)
17 }
18 .padding(5)
19 .frame(width: 100, height: 100)
20
21 Spacer()
22 }
23 }
24}
Custom struct for regular polygon shape to create polygons
Change shape by changing sides of Polygon
The shape change was not animated when one shape was replaced with another shape. The
following code attempts to address this by having one shape of a RegularPolygon
and
changing the number of sides on the tap gesture. This animates the change in color as
RegularPolygon is derived from Shape and SwiftUI knows how to animate a color change
in Shapes. The change in shape from one polygon to another, such as from a triangle to
a square, is not animated other than one shape fades out and the other fades in.
1struct PolygonChangeView: View {
2 @State private var sides = 3
3 @State private var colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]
4
5 var body: some View {
6 VStack {
7 ZStack {
8 RegularPolygon(sides: self.sides)
9 .stroke(colors[(self.sides-3)%self.colors.count], lineWidth: 20)
10 .frame(width: 200, height: 200)
11 .padding()
12 .animation(.linear)
13 .onTapGesture{
14 self.sides = ((self.sides - 2) % 8) + 3
15 }
16
17 Text("\(self.sides)")
18 .font(.system(size: 48, design:.rounded))
19 .fontWeight(.heavy)
20 }
21
22 ZStack {
23 RegularPolygon(sides: self.sides)
24 .fill(colors[(self.sides-3)%self.colors.count])
25 .frame(width: 200, height: 200)
26 .padding()
27 Text("\(self.sides)")
28 .font(.system(size: 48, design:.rounded))
29 .fontWeight(.heavy)
30 }
31 .animation(.linear(duration: 2))
32 .onTapGesture{
33 self.sides = ((self.sides - 2) % 8) + 3
34 }
35
36 Spacer()
37 }
38 }
39}
Changing number of sides of a polygon does not animate shape change
Only color change is animated in polygon
animatableData
The issue is that the SwiftUI framework does not know how to animate a change from one RegularPolygon shape to another. There is no predefined transition from one polygon to another. This is what the animatableData instance property is for. The Shape struct already conforms to Animatable protocol and has animatableData, but this is defaulted to EmptyAnimatableData.
Modify RegularPolygon
to implement animatableData
as outlined below. The
animatableData has to use a double for the sides. This will allow SwiftUI to
interpolate data between a polygon of 3 sides and 4 sides. A private variable is used
for the double version of the number of sides as we do not want to allow creation of
polygons with a non-integer number of sides. The calculation of the polygon vertices
also needs to be updated to use the double value for sides.
1struct RegularPolygon: Shape {
2 var sides: Int
3 private var SidesDouble: Double
4
5 var animatableData: Double {
6 get { return SidesDouble }
7 set { SidesDouble = newValue }
8 }
9
10 init(sides: Int) {
11 self.sides = sides
12 self.SidesDouble = Double(sides)
13 }
14
15 func path(in rect: CGRect) -> Path {
16 // centre of the containing rect
17 let c = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
18 // radius of a circle that will fit in the rect
19 let r = Double(min(rect.width,rect.height)) / 2.0
20 let offsetAngle = (Double.pi / Double(SidesDouble)) + Double.pi/2.0
21 var vertices:[CGPoint] = []
22
23 let endAngle: Int = Double(SidesDouble) > Double(Int(SidesDouble)) ? Int(SidesDouble)+1 : Int(SidesDouble)
24 for i in 0..<endAngle{
25 // Calculate the angle in Radians
26 let angle = (2.0 * Double.pi * Double(i)/Double(SidesDouble)) + offsetAngle
27 let pt = Cartesian(length: r, angle: angle)
28 // move the point to the center of the rect and add to vertices
29 vertices.append(CGPoint(x: pt.x + c.x, y: pt.y + c.y))
30 }
31
32 var path = Path()
33 for (n, pt) in vertices.enumerated() {
34 n == 0 ? path.move(to: pt) : path.addLine(to: pt)
35 }
36 path.closeSubpath()
37 return path
38 }
39}
1struct PolygonAnimateView: View {
2 @State private var sides = 3
3 @State private var colors = [Color.red, Color.green, Color.blue]
4
5 var body: some View {
6 VStack {
7 ZStack {
8 RegularPolygon(sides: self.sides)
9 .stroke(colors[self.sides%3], lineWidth: 20)
10 .frame(width: 200, height: 200)
11 .padding()
12 Text("\(self.sides)")
13 .font(.system(size: 48, design:.rounded))
14 .fontWeight(.heavy)
15 }
16 .animation(.linear(duration: 1))
17 .onTapGesture{
18 self.sides = ((self.sides - 2) % 8) + 3
19 }
20
21 ZStack {
22 RegularPolygon(sides: self.sides)
23 .fill(colors[self.sides%3])
24 .frame(width: 200, height: 200)
25 .padding()
26 Text("\(self.sides)")
27 .font(.system(size: 48, design:.rounded))
28 .fontWeight(.heavy)
29 .foregroundColor(Color.white)
30 }
31 .animation(.linear(duration: 1))
32 .onTapGesture{
33 self.sides = ((self.sides - 2) % 8) + 3
34 }
35
36 Spacer()
37 }
38 }
39}
Animating change from 5-sided to 6-sided polygon
Animate color and side changes in polygon
Animate Circle to Square
Use the polygon shape to animate change from Circle to a Square. A regular polygon with enough sides resembles a circle depending on size. The following code uses a polygon of 50 sides, but 20 sides also looks reasonable.
1struct StartStopAnimateView: View {
2 @State private var start = true
3
4 var body: some View {
5 VStack {
6 ZStack {
7 RegularPolygon(sides: start ? 50 : 4)
8 .scale(self.start ? 0.75 : 1.0)
9 .stroke(start ? Color(.green) : .red, lineWidth: 20)
10 .frame(width: 200, height: 200)
11 .padding()
12 Text(self.start ? "start" : "Stop")
13 .font(.system(size: 36, design:.rounded))
14 .fontWeight(.heavy)
15 }
16 .animation(.easeOut(duration: 1))
17 .onTapGesture{
18 self.start.toggle()
19 }
20
21 ZStack {
22 ZStack {
23 RegularPolygon(sides: start ? 50 : 4)
24 .scale(self.start ? 0.75 : 1.0)
25 .fill(start ? Color(.green) : .red)
26 }
27 .frame(width: 200, height: 200)
28 .padding()
29 Text(self.start ? "start" : "Stop")
30 .font(.system(size: 36, design:.rounded))
31 .fontWeight(.heavy)
32 }
33 .animation(.easeOut(duration: 0.5))
34 .onTapGesture{
35 self.start.toggle()
36 }
37
38 Spacer()
39 }
40 }
41}
Animating transition from a circle to a square
Animate from circle to square
Alternative animation from circle to square
There are other ways of transitioning from a circle to a square such as using a rectangle and increasing the cornerRadius to get to a circle. The animation involves the use of a single rectangle shape and setting the start and end cornerRadius. As this is a numeric value, SwiftUI is able to interpolate the value and provide an animation.
1VStack {
2 Rectangle()
3 .fill(start ? Color(.green) : .red)
4 .frame(width: 200, height: 200)
5 .cornerRadius(self.start ? 100 : 20)
6 .animation(.linear(duration: 1))
7 .onTapGesture{
8 self.start.toggle()
9 }
10 Spacer()
11}
Conclusion
Animations are used in Apps to explain how an app works, improve navigation or direct a user's attention. This article showed that some animations are easy such as a change in color as SwiftUI knows how to animate a color change. The animation of a gradual change in the number of sides of a polygon requires creating a custom shape and overriding the default computed property for animatableData. This provides information to SwiftUI to be able to interpolate data from one state to another. This article is about the best way to animate the transition from one polygon to another. The custom RegularPolygon can also be used to animate transition from a hexagonal button to a square button.