TimelineView to rotate shapes on a canvas

TimelineView, introduced in iOS 15, can be used to change objects on a canvas over time. The article takes the star shape defined previously and animates rotating the star shape on the canvas in a SwiftUI view.



Animate star rotation

Using the Star shape path defined in Using canvas in SwiftUI to construct a star with rounded corners. The Star is composed of an array of Segments, one for each point in the star. As in TimelineView to move a shape on a canvas, an extra parameter is added to the StarPath to specify the angle by which to rotate the star. TimelineView is used to wrap the canvas view with an animation schedule to update the canvas as quickly as the UI can display the changes.

 1    func starPath(in rect: CGRect, points: Int, angle: Double) -> Path {
 2        let center = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
 3        // radius of a circle that will fit in the rect with some padding
 4        let outerRadius = (Double(min(rect.width,rect.height)) / 2.0) * 0.95
 5        let innerRadius = outerRadius * 0.4
 6        let offsetAngle = angle        
 7        let cornerRadius = (rect.width / Double(points)) * 0.07
 8        
 9        var starSegments:[Segment] = []
10        for i in 0..<(points){
11            let angle1 = (2.0 * Double.pi/Double(points)) * Double(i)  + offsetAngle
12            let outerPoint = Cartesian(length: outerRadius, angle: angle1)
13            let angle2 = (2.0 * Double.pi/Double(points)) * (Double(i) + 0.5)  + offsetAngle
14            let innerPoint = Cartesian(length: (innerRadius), angle: (angle2))
15            
16            let segment = Segment(
17                outerCenter: CGPoint(x: outerPoint.x + center.x,
18                                     y: outerPoint.y + center.y),
19                outerAngle: angle1,
20                outerRadius: cornerRadius,
21                innerCenter: CGPoint(x: innerPoint.x + center.x,
22                                     y: innerPoint.y + center.y),
23                innerAngle: angle2)
24            starSegments.append(segment)
25        }
26        
27        let path = Path() { path in
28            for (n, seg) in starSegments.enumerated() {
29                n == 0 ? path.move(to: seg.line) : path.addLine(to: seg.line)
30                path.addArc(center: seg.outerCenter,
31                            radius: seg.outerRadius,
32                            startAngle: Angle(radians: seg.outerStartAngle),
33                            endAngle: Angle(radians: seg.outerEndAngle),
34                            clockwise: false)
35                path.addLine(to: seg.line2)
36                path.addArc(center: seg.innerCenter,
37                            radius: seg.outerRadius,
38                            startAngle: Angle(radians: seg.innerStartAngle),
39                            endAngle: Angle(radians: seg.innerEndAngle),
40                            clockwise: true)
41            }
42            path.closeSubpath()
43        }
44        return path
45    }
1func Cartesian(length:Double, angle:Double) -> CGPoint {
2    return CGPoint(x: length * cos(angle), y: length * sin(angle))
3}
 1struct Segment {
 2    let outerCenter: CGPoint
 3    let outerAngle: Double
 4    let outerRadius: Double
 5    let innerCenter: CGPoint
 6    let innerAngle: Double
 7    
 8    var line: CGPoint {
 9        get {
10            let pt = Cartesian(length: outerRadius, angle: outerStartAngle)
11            return CGPoint(x: pt.x + outerCenter.x, y: pt.y + outerCenter.y)
12        }
13    }
14    
15    var line2: CGPoint {
16        get {
17            let pt = Cartesian(length: outerRadius, angle: innerStartAngle)
18            return CGPoint(x: pt.x + innerCenter.x, y: pt.y + innerCenter.y)
19        }
20    }
21    
22    var outerStartAngle: Double {
23        get { self.outerAngle - (Double.pi * (0.45)) }
24    }
25    var outerEndAngle: Double {
26        get { self.outerAngle + (Double.pi * (0.45)) }
27    }
28    
29    var innerStartAngle: Double {
30        get { self.innerAngle - (Double.pi * (0.7)) }
31    }
32    var innerEndAngle: Double {
33        get { self.innerAngle + (Double.pi * (0.7)) }
34    }
35}

The remainder(dividingBy:) method is used with a remainder of 4 to repeat the animation every 4 seconds. Multiplying this value by 90 will result in values from -180 to +180, which is used to construct an Angle that is passed into the starPath.

 1        TimelineView(.animation) { timeline in
 2            VStack {
 3                let now = timeline.date.timeIntervalSinceReferenceDate
 4                let angle = Angle.degrees(now.remainder(dividingBy: 4)*90)
 5                
 6                Canvas { context, size in
 7                    context.fill(
 8                        starPath(in: CGRect(origin: .zero,
 9                                            size: CGSize(width: size.width, height: size.height)),
10                                 points: 5,
11                                 angle: angle.radians),
12                        with: .color(.orange))
13                }
14                .frame(width: 300, height: 400)
15                .border(Color.blue)
16                
17                Spacer()
18            }
19        }

TimelineView to animate star rotation on Canvas
TimelineView to animate star rotation on Canvas


TimelineView to animate star rotation on Canvas

TimelineView to animate star rotation on Canvas



Reverse direction of rotation

Reversing the direction of rotation is achieved by simply passing in a negative value for the angle to the statPath function.

 1        TimelineView(.animation) { timeline in
 2            VStack {
 3                let now = timeline.date.timeIntervalSinceReferenceDate
 4                let angle = Angle.degrees(now.remainder(dividingBy: 4)*90)
 5                
 6                Canvas { context, size in
 7                    context.fill(
 8                        starPath(in: CGRect(origin: .zero,
 9                                            size: CGSize(width: size.width, height: size.height)),
10                                 points: 9,
11                                 angle: -angle.radians),
12                        with: .color(.purple))
13                }
14                .frame(width: 300, height: 400)
15                
16                Spacer()
17            }
18        }

TimelineView to animate star rotation in reverse on Canvas

TimelineView to animate star rotation in reverse on Canvas



Rotate in z-axis

The rotation is achieved by passing an angle to the starPath function to offset each point in the star. As the time interval advances, a new angle is calculated and the star is created and drawn in a new position. The canvas can also be rotated using the SwiftUI view modifier rotation3DEffect, which allows the rotation of a view in a combination of the 3D axes. The following shows the effect of rotating the card in the y-axis resulting in a rotation of the card along the vertical axis. The same angle can be passed into the starPath function as well as the rotation3DEffect view modifier to combine rotating the star in 2 axes at the same time.

 1        TimelineView(.animation) { timeline in
 2            VStack {
 3                let now = timeline.date.timeIntervalSinceReferenceDate
 4                let angle = Angle.degrees(now.remainder(dividingBy: 4)*90)
 5                
 6                Canvas { context, size in
 7                    context.fill(
 8                        starPath(in: CGRect(origin: .zero,
 9                                            size: CGSize(width: size.width, height: size.height)),
10                                 points: 5,
11                                 angle: -Double.pi*0.5),
12                        with: .color(.orange))
13                }
14                .rotation3DEffect(angle, axis: (0,1,0))
15                .frame(width: 300, height: 200)
16                .border(Color.blue)
17                
18                Spacer().frame(height:30)
19                
20                Canvas { context, size in
21                    context.fill(
22                        starPath(in: CGRect(origin: .zero,
23                                            size: CGSize(width: size.width, height: size.height)),
24                                 points: 5,
25                                 angle: angle.radians),
26                        with: .color(.green))
27                }
28                .rotation3DEffect(angle, axis: (0,1,0))
29                .frame(width: 300, height: 200)
30                .border(Color.blue)
31                
32                Spacer()
33            }
34        }

TimelineView to animate star rotation in z-axis on Canvas
TimelineView to animate star rotation in z-axis on Canvas


TimelineView to animate star rotation in z-axis on Canvas

TimelineView to animate star rotation in z-axis on Canvas



Rotate and Resize the Star shape

It is possible to rotate any view in multiple axes by the same angle. Using a parameter in the starPath on the canvas is used to change the shape while the rotation3DEffect view modifier is used to rotate the shape. The size of the star is changed by adjusting the outer radius of the points on the star with the angle passed into the starPath. The angle is also used to rotate the star in the y-axis.

 1    var body: some View {
 2        ZStack {
 3            TimelineView(.animation) { timeline in
 4                VStack {
 5                    let now = timeline.date.timeIntervalSinceReferenceDate
 6                    let angle = Angle.degrees(now.remainder(dividingBy: 4)*90)
 7
 8                    Spacer().frame(height:30)
 9                    
10                    Canvas { context, size in
11                        context.fill(
12                            starPath(in: CGRect(origin: .zero,
13                                                size: CGSize(width: size.width, height: size.height)),
14                                     points: 5,
15                                     angle: angle.radians),
16                            with: .color(.green))
17                    }
18                    .rotation3DEffect(angle, axis: (0,1,0))
19                    .frame(width: 300, height: 400)
20                    
21                    Spacer()
22                }
23            }
24        }
25    }
26    
27    func starPath(in rect: CGRect, points: Int, angle: Double) -> Path {
28        let center = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
29        let outerRadius = Double(min(rect.width,rect.height)) * 0.5 * abs(angle/Double.pi)
30        let innerRadius = outerRadius * 0.4
31        let offsetAngle = Double.pi * (-0.5)
32        let cornerRadius = (rect.width / Double(points)) * 0.07
33        
34        var starSegments:[Segment] = []
35        for i in 0..<(points){
36            let angle1 = (2.0 * Double.pi/Double(points)) * Double(i)  + offsetAngle
37            let outerPoint = Cartesian(length: outerRadius, angle: angle1)
38            let angle2 = (2.0 * Double.pi/Double(points)) * (Double(i) + 0.5)  + offsetAngle
39            let innerPoint = Cartesian(length: (innerRadius), angle: (angle2))
40            
41            let segment = Segment(
42                outerCenter: CGPoint(x: outerPoint.x + center.x,
43                                     y: outerPoint.y + center.y),
44                outerAngle: angle1,
45                outerRadius: cornerRadius,
46                innerCenter: CGPoint(x: innerPoint.x + center.x,
47                                     y: innerPoint.y + center.y),
48                innerAngle: angle2)
49            starSegments.append(segment)
50        }
51        
52        let path = Path() { path in
53            for (n, seg) in starSegments.enumerated() {
54                n == 0 ? path.move(to: seg.line) : path.addLine(to: seg.line)
55                path.addArc(center: seg.outerCenter,
56                            radius: seg.outerRadius,
57                            startAngle: Angle(radians: seg.outerStartAngle),
58                            endAngle: Angle(radians: seg.outerEndAngle),
59                            clockwise: false)
60                path.addLine(to: seg.line2)
61                path.addArc(center: seg.innerCenter,
62                            radius: seg.outerRadius,
63                            startAngle: Angle(radians: seg.innerStartAngle),
64                            endAngle: Angle(radians: seg.innerEndAngle),
65                            clockwise: true)
66            }
67            path.closeSubpath()
68        }
69        return path
70    }

TimelineView to animate star rotation in z-axis while resizing

TimelineView to animate star rotation in z-axis while resizing




Conclusion

The TimelineView animation view was introduced in iOS 15 and is used to wrap a canvas view with an animation schedule. This updates the canvas as quickly as the UI can display changes. The time is used to calculate an angle to rotate a star shape resulting in the star rotating over time. The rotation can be combined with the shape resizing and rotating in 3D space to produce some interesting effects.