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