Animate spinning cog shape in SwiftUI

I discovered, when playing around with the rounded-corner star shape, that the same basic shape could be used to create a cog shape. This article demonstrates the creation of the cog shape and the use of timelineview to animate its rotation in SwiftUI.



Define path for cog shape

Building on the Star shape with rounded corners created in Star with rounded corners in SwiftUI a Path cog shape is defined by modifying the star shape path. The inner and outer radii are set to be equal, so the cog teeth will be determined by the corner radius. This is set relative to the width of the containing rectangle and the number of points in the cog. These values can be played around with, to create a cog that looks right.

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

No changes are made to the Segment struct.

 1func Cartesian(length:Double, angle:Double) -> CGPoint {
 2    return CGPoint(x: length * cos(angle), y: length * sin(angle))
 3}
 4
 5struct Segment {
 6    let outerCenter: CGPoint
 7    let outerAngle: Double
 8    let outerRadius: Double
 9    let innerCenter: CGPoint
10    let innerAngle: Double
11    
12    var line: CGPoint {
13        get {
14            let pt = Cartesian(length: outerRadius, angle: outerStartAngle)
15            return CGPoint(x: pt.x + outerCenter.x, y: pt.y + outerCenter.y)
16        }
17    }
18    
19    var line2: CGPoint {
20        get {
21            let pt = Cartesian(length: outerRadius, angle: innerStartAngle)
22            return CGPoint(x: pt.x + innerCenter.x, y: pt.y + innerCenter.y)
23        }
24    }
25    
26    var outerStartAngle: Double {
27        get { self.outerAngle - (Double.pi * (0.45)) }
28    }
29    var outerEndAngle: Double {
30        get { self.outerAngle + (Double.pi * (0.45)) }
31    }
32    
33    var innerStartAngle: Double {
34        get { self.innerAngle - (Double.pi * (0.7)) }
35    }
36    var innerEndAngle: Double {
37        get { self.innerAngle + (Double.pi * (0.7)) }
38    }
39}

The cog path is added to a canvas as described in Using canvas in SwiftUI. A TimelineView is used to wrap the view containing the canvas with a schedule of AnimationTimelineSchedule. The animation schedule will update the canvas as quickly as the UI can display changes. The angle calculated is passed into the cogPath function and the cog path is adjusted by this angle.

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

Starter cog shape on a Canvas
Starter cog shape on a Canvas


Animated cog shape on a canvas

Animated cog shape on a canvas



Separate into Cog Shape struct

As the path for shapes becomes more complicated, having the function inside the body view makes the code look ugly and is harder to modify and maintain. This is resolved by separating out the cog shape into its own struct and then accessing the path from the shape in the graphicsContext fill function.

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

The CogShape is created in the Canvas view and the path of the shape is retrieved.

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

Adding path from cog shape to a Canvas
Adding path from cog shape to a Canvas



Add circles to center of cog shape

Let's add a little detail to the cog shape by adding circles paths to the shape. Concentric circles paths are added to the cogShape path.

 1struct CogShape2: Shape {
 2    var points: Int
 3    var rotationAngle: Angle
 4    
 5    func path(in rect: CGRect) -> Path {
 6        let center = CGPoint(x: rect.width/2.0 + rect.origin.x , y: rect.height/2.0 + rect.origin.y)
 7        let outerRadius = Double(min(rect.width,rect.height)) * 0.4
 8        let innerRadius = outerRadius * 1.0
 9        let offsetAngle = Double.pi * (-0.5)  + rotationAngle.radians
10        let cornerRadius = (rect.width / Double(points)) * 0.66
11        
12        var starSegments:[Segment] = []
13        for i in 0..<(points){
14            let angle1 = (2.0 * Double.pi/Double(points)) * Double(i)  + offsetAngle
15            let outerPoint = Cartesian(length: outerRadius, angle: angle1)
16            let angle2 = (2.0 * Double.pi/Double(points)) * (Double(i) + 0.5)  + offsetAngle
17            let innerPoint = Cartesian(length: (innerRadius), angle: (angle2))
18            
19            let segment = Segment(
20                outerCenter: CGPoint(x: outerPoint.x + center.x,
21                                     y: outerPoint.y + center.y),
22                outerAngle: angle1,
23                outerRadius: cornerRadius,
24                innerCenter: CGPoint(x: innerPoint.x + center.x,
25                                     y: innerPoint.y + center.y),
26                innerAngle: angle2)
27            starSegments.append(segment)
28        }
29        
30        let path = Path() { path in
31            for (n, seg) in starSegments.enumerated() {
32                n == 0 ? path.move(to: seg.line) : path.addLine(to: seg.line)
33                path.addArc(center: seg.outerCenter,
34                            radius: seg.outerRadius,
35                            startAngle: Angle(radians: seg.outerStartAngle),
36                            endAngle: Angle(radians: seg.outerEndAngle),
37                            clockwise: false)
38                path.addLine(to: seg.line2)
39                path.addArc(center: seg.innerCenter,
40                            radius: seg.outerRadius,
41                            startAngle: Angle(radians: seg.innerStartAngle),
42                            endAngle: Angle(radians: seg.innerEndAngle),
43                            clockwise: true)
44            }
45            path.closeSubpath()
46            
47            for i in [0.78, 0.5, 0.1] {
48                path.addPath(Circle().path(in: CGRect(x: center.x - (outerRadius * i),
49                                                      y: center.y - (outerRadius * i),
50                                                      width: outerRadius * i * 2,
51                                                      height: outerRadius * i * 2)))
52            }
53        }
54        return path
55    }
56}

The fillstyle of the contextGraphic is set to use the Even–odd rule property so that alternate paths are filled in.

 1    TimelineView(.animation) { timeline in
 2        VStack {
 3            let now = timeline.date.timeIntervalSinceReferenceDate
 4            let angle = Angle.degrees(now.remainder(dividingBy: 8)*45)
 5            
 6            Spacer().frame(height:30)
 7            
 8            Canvas { context, size in
 9                context.fill(
10                    CogShape2(points: 15, rotationAngle: angle)
11                        .path(in: CGRect(origin: .zero,
12                                         size: CGSize(
13                                            width: size.width,
14                                            height: size.height))),
15                    with: .color(.blue), style: FillStyle(eoFill: true, antialiased: true))
16            }
17            .frame(width: 300, height: 400)
18            
19            Spacer()
20        }
21    }

Adding circles to the cog shape to a Canvas
Adding circles to the cog shape to a Canvas


Animated cog shape with inner circles on a canvas

Animated cog shape with inner circles on a canvas



Add inner star shape to cog shape

The problem with the inner circles is that it is not obvious that these circles are rotating when the cog rotates. So there can be a visual disconnect between the circles staying still and the outer cog rim rotating. An alternative detail is to add an inner star-style shape to the center of the cog.

 1struct CogShape3: Shape {
 2    var points = 10
 3    var rotationAngle: Angle
 4
 5    func path(in rect: CGRect) -> Path {
 6        let center = CGPoint(x: rect.width/2.0 + rect.origin.x , y: rect.height/2.0 + rect.origin.y)
 7        let length = Double(min(rect.width, rect.height))
 8        let outerRadius = length * 0.4
 9        let innerRadius = outerRadius * 0.95
10        let offsetAngle = Double.pi * (-0.5) + rotationAngle.radians
11        let cornerRadius = (length / Double(points)) * 0.6
12        
13        var outerSegments:[Segment] = []
14        for i in 0..<(points){
15            let angle1 = (2.0 * Double.pi/Double(points)) * Double(i)  + offsetAngle
16            let outerPoint = Cartesian(length: outerRadius, angle: angle1)
17            let angle2 = (2.0 * Double.pi/Double(points)) * (Double(i) + 0.5)  + offsetAngle
18            let innerPoint = Cartesian(length: (innerRadius), angle: (angle2))
19            
20            let segment = Segment(
21                outerCenter: CGPoint(x: outerPoint.x + center.x,
22                                     y: outerPoint.y + center.y),
23                outerAngle: angle1,
24                outerRadius: cornerRadius,
25                innerCenter: CGPoint(x: innerPoint.x + center.x,
26                                     y: innerPoint.y + center.y),
27                innerAngle: angle2)
28            outerSegments.append(segment)
29        }
30        
31        let innerPoints = points / 3
32        var innerSegments:[Segment] = []
33        for i in 0..<(innerPoints){
34            let angle1 = (2.0 * Double.pi/Double(innerPoints)) * Double(i)  + offsetAngle
35            let outerPoint = Cartesian(length: outerRadius * 0.7, angle: angle1)
36            let angle2 = (2.0 * Double.pi/Double(innerPoints)) * (Double(i) + 0.5)  + offsetAngle
37            let innerPoint = Cartesian(length: (innerRadius * 0.2), angle: (angle2))
38            
39            let segment = Segment(
40                outerCenter: CGPoint(x: outerPoint.x + center.x,
41                                     y: outerPoint.y + center.y),
42                outerAngle: angle1,
43                outerRadius: cornerRadius * 0.6,
44                innerCenter: CGPoint(x: innerPoint.x + center.x,
45                                     y: innerPoint.y + center.y),
46                innerAngle: angle2)
47            innerSegments.append(segment)
48        }
49        
50        let path = Path() { path in
51            for (n, seg) in outerSegments.enumerated() {
52                n == 0 ? path.move(to: seg.line) : path.addLine(to: seg.line)
53                path.addArc(center: seg.outerCenter,
54                            radius: seg.outerRadius,
55                            startAngle: Angle(radians: seg.outerStartAngle),
56                            endAngle: Angle(radians: seg.outerEndAngle),
57                            clockwise: false)
58                path.addLine(to: seg.line2)
59                path.addArc(center: seg.innerCenter,
60                            radius: seg.outerRadius,
61                            startAngle: Angle(radians: seg.innerStartAngle),
62                            endAngle: Angle(radians: seg.innerEndAngle),
63                            clockwise: true)
64            }
65            path.closeSubpath()
66            
67            for (n, seg) in innerSegments.enumerated() {
68                n == 0 ? path.move(to: seg.line) : path.addLine(to: seg.line)
69                path.addArc(center: seg.outerCenter,
70                            radius: seg.outerRadius,
71                            startAngle: Angle(radians: seg.outerStartAngle),
72                            endAngle: Angle(radians: seg.outerEndAngle),
73                            clockwise: false)
74                path.addLine(to: seg.line2)
75                path.addArc(center: seg.innerCenter,
76                            radius: seg.outerRadius,
77                            startAngle: Angle(radians: seg.innerStartAngle),
78                            endAngle: Angle(radians: seg.innerEndAngle),
79                            clockwise: true)
80            }
81            path.closeSubpath()
82        }
83        return path
84    }
85}
 1    TimelineView(.animation) { timeline in
 2        VStack {
 3            let now = timeline.date.timeIntervalSinceReferenceDate
 4            let angle = Angle.degrees(now.remainder(dividingBy: 8)*45)
 5            
 6            Spacer().frame(height:30)
 7            
 8            Canvas { context, size in
 9                context.fill(
10                    CogShape3(points: 30, rotationAngle: angle)
11                        .path(in: CGRect(origin: .zero,
12                                         size: CGSize(
13                                            width: size.width,
14                                            height: size.height))),
15                    with: .color(.blue), style: FillStyle(eoFill: true, antialiased: true))
16            }
17            .frame(width: 300, height: 400)
18            
19            Spacer()
20        }
21    }

Adding star-style shape to the cog shape to a Canvas
Adding star-style shape to the cog shape to a Canvas


Animated cog shape with inner star on a canvas

Animated cog shape with inner star on a canvas



Add inner star-style shape and circles to cog shape

What about both the inner star-shape path and concentric circle paths? The number of points on the star as well as the number and thickness of the circles can be modified to create some interesting patterns.

 1struct CogShape4: Shape {
 2    var points = 10
 3    var rotationAngle: Angle
 4
 5    func path(in rect: CGRect) -> Path {
 6        let center = CGPoint(x: rect.width/2.0 + rect.origin.x , y: rect.height/2.0 + rect.origin.y)
 7        let length = Double(min(rect.width, rect.height))
 8        let outerRadius = length * 0.4
 9        let innerRadius = outerRadius * 0.95
10        let offsetAngle = Double.pi * (-0.5) + rotationAngle.radians
11        let cornerRadius = (length / Double(points)) * 0.6
12        
13        var outerSegments:[Segment] = []
14        for i in 0..<(points){
15            let angle1 = (2.0 * Double.pi/Double(points)) * Double(i)  + offsetAngle
16            let outerPoint = Cartesian(length: outerRadius, angle: angle1)
17            let angle2 = (2.0 * Double.pi/Double(points)) * (Double(i) + 0.5)  + offsetAngle
18            let innerPoint = Cartesian(length: (innerRadius), angle: (angle2))
19            
20            let segment = Segment(
21                outerCenter: CGPoint(x: outerPoint.x + center.x,
22                                     y: outerPoint.y + center.y),
23                outerAngle: angle1,
24                outerRadius: cornerRadius,
25                innerCenter: CGPoint(x: innerPoint.x + center.x,
26                                     y: innerPoint.y + center.y),
27                innerAngle: angle2)
28            outerSegments.append(segment)
29        }
30        
31        let innerPoints = points / 3
32        var innerSegments:[Segment] = []
33        for i in 0..<(innerPoints){
34            let angle1 = (2.0 * Double.pi/Double(innerPoints)) * Double(i)  + offsetAngle
35            let outerPoint = Cartesian(length: outerRadius * 0.8, angle: angle1)
36            let angle2 = (2.0 * Double.pi/Double(innerPoints)) * (Double(i) + 0.5)  + offsetAngle
37            let innerPoint = Cartesian(length: (innerRadius * 0.2), angle: (angle2))
38            
39            let segment = Segment(
40                outerCenter: CGPoint(x: outerPoint.x + center.x,
41                                     y: outerPoint.y + center.y),
42                outerAngle: angle1,
43                outerRadius: cornerRadius * 0.6,
44                innerCenter: CGPoint(x: innerPoint.x + center.x,
45                                     y: innerPoint.y + center.y),
46                innerAngle: angle2)
47            innerSegments.append(segment)
48        }
49        
50        let path = Path() { path in
51            for (n, seg) in outerSegments.enumerated() {
52                n == 0 ? path.move(to: seg.line) : path.addLine(to: seg.line)
53                path.addArc(center: seg.outerCenter,
54                            radius: seg.outerRadius,
55                            startAngle: Angle(radians: seg.outerStartAngle),
56                            endAngle: Angle(radians: seg.outerEndAngle),
57                            clockwise: false)
58                path.addLine(to: seg.line2)
59                path.addArc(center: seg.innerCenter,
60                            radius: seg.outerRadius,
61                            startAngle: Angle(radians: seg.innerStartAngle),
62                            endAngle: Angle(radians: seg.innerEndAngle),
63                            clockwise: true)
64            }
65            path.closeSubpath()
66            
67            for (n, seg) in innerSegments.enumerated() {
68                n == 0 ? path.move(to: seg.line) : path.addLine(to: seg.line)
69                path.addArc(center: seg.outerCenter,
70                            radius: seg.outerRadius,
71                            startAngle: Angle(radians: seg.outerStartAngle),
72                            endAngle: Angle(radians: seg.outerEndAngle),
73                            clockwise: false)
74                path.addLine(to: seg.line2)
75                path.addArc(center: seg.innerCenter,
76                            radius: seg.outerRadius,
77                            startAngle: Angle(radians: seg.innerStartAngle),
78                            endAngle: Angle(radians: seg.innerEndAngle),
79                            clockwise: true)
80            }
81            path.closeSubpath()
82
83            for i in [0.82, 0.7, 0.6, 0.1, 0.15] {
84                path.addPath(Circle().path(in: CGRect(x: center.x - (outerRadius * i),
85                                                      y: center.y - (outerRadius * i),
86                                                      width: outerRadius * i * 2,
87                                                      height: outerRadius * i * 2)))
88            }
89        }
90        return path
91    }
92}

Adding star-style shape and circles to the cog shape
Adding star-style shape and circles to the cog shape


Animated cog shape with inner star and circles

Animated cog shape with inner star and circles



Add multiple paths to canvas

Having settled on a cog shape with star and circles, the canvas is modified to contain two cogs inter-linking. The angles to animate the rotation are calculated so that one cog rotates twice as fast as the other. The size of the larger cog is set to twice that of the smaller cog and the rotation is twice as slow. There is also a shadow added to the cogs to create a bit of depth.

 1    TimelineView(.animation) { timeline in
 2        VStack {
 3            let now = timeline.date.timeIntervalSinceReferenceDate
 4            let angle1 = Angle.degrees(now.remainder(dividingBy: 8)*45)
 5            let angle2 = Angle.degrees(now.remainder(dividingBy: 4)*90)
 6            
 7            Spacer().frame(height:30)
 8            
 9            Canvas { context, size in
10                var innerContext = context
11                innerContext.addFilter(.shadow(color: .black, radius: 3, x: 3, y: 3, blendMode: .screen))
12
13                context.fill(
14                    CogShape4(points: 30, rotationAngle: angle1)
15                        .path(in: CGRect(x: 4,
16                                         y: 18,
17                                         width: 240,
18                                         height: 240)),
19                    with: .color(.red), style: FillStyle(eoFill: true, antialiased: true))
20                innerContext.stroke(
21                    CogShape4(points: 30, rotationAngle: angle1)
22                        .path(in: CGRect(x: 4,
23                                         y: 18,
24                                         width: 240,
25                                         height: 240)),
26                    with: .color(.red), lineWidth: 1)
27
28                context.fill(
29                    CogShape4(points: 15, rotationAngle: -angle2)
30                        .path(in: CGRect(x: 190,
31                                         y: 150,
32                                         width: 120,
33                                         height: 120)),
34                    with: .color(.blue), style: FillStyle(eoFill: true, antialiased: true))
35                innerContext.stroke(
36                    CogShape4(points: 15, rotationAngle: -angle2)
37                        .path(in: CGRect(x: 190,
38                                         y: 150,
39                                         width: 120,
40                                         height: 120)),
41                    with: .color(.blue), lineWidth: 1)
42            }
43            .frame(width: 330, height: 400)
44            
45            Spacer()
46        }
47    }

Animated interlocking cog shapes on a canvas
Animated interlocking cog shapes on a canvas


Multiple cog shapes spinning on a canvas

Multiple cog shapes spinning on a canvas




Conclusion

The Segments used to create a star shape with round corners in Star with rounded corners in SwiftUI is a suitable starting point for creating a cog shape. The inner and outer radius for the cog points are almost the same and the cog is created with an alternate arc paths. The TimelineView is used to animate the rotation of the cogs by redrawing the shapes on a canvas in a performant manner. Be warned that adding extra paths such as the star-shapes and concentric circles is a bit of a rabbit hole, where one can get lost creating multiple interesting patterns! I feel more work could be done on multiple inter-locking cogs spinning - to improve the cogs to lock just right, but I'll leave it for now.