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 on tap gesture
Animate circle size and color change on tap gesture

Animate circle color and size change

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
Animate switching from Circle to a Square

Only color is animated, shape just changes

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
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
Changing number of sides of a polygon does not animate shape change

Only color change is animated in polygon

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 polygon change from 5-sided to 6-sided
Animating change from 5-sided to 6-sided polygon

Animate color and side changes in 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
Animating transition from a circle to a square

Animate from circle to 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.