TimelineView to move a shape on a canvas
TimelineView was introduced in iOS 15 to control how a view can change over time. This article takes the heart shape defined previously and animates by changing the path on the canvas in a SwiftUI view.
Wrap Canvas in TimelineView
Start with a canvas containing the heart shape from 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 timeIntervalSinceReferenceDate
is the
number of seconds since January 01 2001 and it is used to get an ever increasing
number. Using the remainder when divided by 5 will give a number between -2.5 and
+2.5. It can be beneficial to display the value for x and the time interval value
using a Text view when experimenting with the animation. This value is multiplied by
20 to get an x-value varying from -50 to +50, which is passed into the heartPath
function.
1 var body: some View {
2 TimelineView(.animation) { timeline in
3 VStack {
4 let now = timeline.date.timeIntervalSinceReferenceDate
5 let x = now.remainder(dividingBy: 5) * 20
6
7 Text("now = \(now)").frame(width:250)
8 Text("x = \(x)").frame(width:250)
9 Spacer().frame(height:50)
10
11
12 Canvas { context, size in
13 context.fill(
14 heartPath(in: CGRect(origin: .zero,
15 size: CGSize(width: size.width, height: size.height)),
16 x: x),
17 with: .color(.red))
18 }
19 .frame(width: 300, height: 200)
20 .border(Color.blue)
21
22 Spacer()
23 }
24 }
25 }
The heartPath
function is updated to offset the x-coordinates of the path by the x
value passed in. This results in the heart shape sliding across the canvas in 5
seconds.
1 func heartPath(in rect: CGRect, x: Double) -> Path {
2 let size = min(rect.width, rect.height)
3 let xOffset = (rect.width > rect.height) ? (rect.width - rect.height) / 2.0 : 0.0
4 let yOffset = (rect.height > rect.width) ? (rect.height - rect.width) / 2.0 : 0.0
5
6 func offsetPoint(p: CGPoint) -> CGPoint {
7 return CGPoint(x: p.x + xOffset + x, y: p.y+yOffset)
8 }
9 let path = Path() { path in
10 path.move(to: offsetPoint(p: (CGPoint(x: (size * 0.50), y: (size * 0.25)))))
11 path.addCurve(to: offsetPoint(p: CGPoint(x: 0, y: (size * 0.25))),
12 control1: offsetPoint(p: CGPoint(x: (size * 0.50), y: (-size * 0.10))),
13 control2: offsetPoint(p: CGPoint(x: 0, y: 0)))
14 path.addCurve(to: offsetPoint(p: CGPoint(x: (size * 0.50), y: size)),
15 control1: offsetPoint(p: CGPoint(x: 0, y: (size * 0.60))),
16 control2: offsetPoint(p: CGPoint(x: (size * 0.50), y: (size * 0.80))))
17 path.addCurve(to: offsetPoint(p: CGPoint(x: size, y: (size * 0.25))),
18 control1: offsetPoint(p: CGPoint(x: (size * 0.50), y: (size * 0.80))),
19 control2: offsetPoint(p: CGPoint(x: size, y: (size * 0.60))))
20 path.addCurve(to: offsetPoint(p: CGPoint(x: (size * 0.50), y: (size * 0.25))),
21 control1: offsetPoint(p: CGPoint(x: size, y: 0)),
22 control2: offsetPoint(p: CGPoint(x: (size * 0.50), y: (-size * 0.10))))
23 }
24 return path
25 }
Using TimelineView to animate heart shape moving in a canvas
TimelineView to animate heart shape moving on Canvas
Slide the shape on/off canvas
The remainder(dividingBy:) method returns a number between -1.5 and +1.5 when the number 3 is passed as the parameter. This will provide values in the range -300 to +300 when multiplied by 200. Adjusting all the x coordinates by -300 results in the heart shape off to the left of the canvas. As the time changes and the remainder values increase the heart slides in from the left and continues to slide off the canvas on the right. The dividedBy value also sets the duration of the animation as it is the time that is divided. In the first case the value 3 sets the animation to repeat every 3 seconds and the second one repeats every second.
1 TimelineView(.animation) { timeline in
2 VStack {
3 let now = timeline.date.timeIntervalSinceReferenceDate
4 let x = now.remainder(dividingBy: 3) * 200
5
6 Text("now.remainder(dividingBy: 3) * 200").frame(width:300)
7 Canvas { context, size in
8 context.fill(
9 heartPath(in: CGRect(origin: .zero,
10 size: CGSize(width: size.width, height: size.height)),
11 x: x),
12 with: .color(.red))
13 }
14 .frame(width: 300, height: 200)
15 .border(Color.blue)
16
17 Spacer().frame(height:50)
18
19 let x2 = now.remainder(dividingBy: 1) * 500
20 Text("now.remainder(dividingBy: 1) * 500").frame(width:300)
21 Canvas { context, size in
22 context.fill(
23 heartPath(in: CGRect(origin: .zero,
24 size: CGSize(width: size.width, height: size.height)),
25 x: x2),
26 with: .color(.red))
27 }
28 .frame(width: 300, height: 100)
29
30 Spacer()
31 }
32 }
Using TimelineView to animate heart shape moving on and off Canvas
TimelineView to animate heart shape moving on and off Canvas
Slide the shape back and forth
How can the shape move back and forth rather than moving to the right, disappearing and reappearing on the left? The remainder value is adjusted to return values between -180 and +180, which are used to create angles by degrees representing a 360 degrees circle. The x coordinate of the angle is calculated using the cosine function and this gradually increases and then gradually decreases. Once again, increasing the size of x can result in the shape moving off the canvas.
1 TimelineView(.animation) { timeline in
2 VStack {
3 let now = timeline.date.timeIntervalSinceReferenceDate
4 let angle = Angle.degrees(now.remainder(dividingBy: 2)*180)
5 let x = (cos(angle.radians) * 50)
6
7 Canvas { context, size in
8 context.fill(
9 heartPath(in: CGRect(origin: .zero,
10 size: CGSize(width: size.width, height: size.height)),
11 x: x),
12 with: .color(.red))
13 }
14 .frame(width: 300, height: 200)
15 .border(Color.blue)
16
17 Spacer().frame(height:50)
18
19 let x2 = cos(angle.radians) * 300
20 Canvas { context, size in
21 context.fill(
22 heartPath(in: CGRect(origin: .zero,
23 size: CGSize(width: size.width, height: size.height)),
24 x: x2),
25 with: .color(.red))
26 }
27 .frame(width: 300, height: 100)
28
29 Spacer()
30 }
31 }
TimelineView to animate heart shape moving back and forth on Canvas
Animate heart shape moving back and forth on Canvas
Adjust the shape
The shape can be animated by changing the path based on the values passed into the
path function. In this case there are two parameters passed to the heartPath
, one
to move the shape on the x-axis and one to change the tip of the heart shape.
1 func heartPath(in rect: CGRect, move: Double, tip: Double) -> Path {
2 let size = min(rect.width, rect.height)
3 let xOffset = (rect.width > rect.height) ? (rect.width - rect.height) / 2.0 : 0.0
4 let yOffset = (rect.height > rect.width) ? (rect.height - rect.width) / 2.0 : 0.0
5
6 func offsetPoint(p: CGPoint) -> CGPoint {
7 return CGPoint(x: p.x + xOffset + move, y: p.y+yOffset)
8 }
9 let path = Path() { path in
10 path.move(to: offsetPoint(p: (CGPoint(x: (size * 0.50), y: (size * 0.25)))))
11 path.addCurve(to: offsetPoint(p: CGPoint(x: 0, y: (size * 0.25))),
12 control1: offsetPoint(p: CGPoint(x: (size * 0.50), y: (-size * 0.10))),
13 control2: offsetPoint(p: CGPoint(x: 0, y: 0)))
14 path.addCurve(to: offsetPoint(p: CGPoint(x: (size * 0.50 + tip), y: size)),
15 control1: offsetPoint(p: CGPoint(x: 0, y: (size * 0.60))),
16 control2: offsetPoint(p: CGPoint(x: (size * 0.50), y: (size * 0.80))))
17 path.addCurve(to: offsetPoint(p: CGPoint(x: size, y: (size * 0.25))),
18 control1: offsetPoint(p: CGPoint(x: (size * 0.50), y: (size * 0.80))),
19 control2: offsetPoint(p: CGPoint(x: size, y: (size * 0.60))))
20 path.addCurve(to: offsetPoint(p: CGPoint(x: (size * 0.50), y: (size * 0.25))),
21 control1: offsetPoint(p: CGPoint(x: size, y: 0)),
22 control2: offsetPoint(p: CGPoint(x: (size * 0.50), y: (-size * 0.10))))
23 }
24 return path
25 }
Just one of these parameters can be set to animate the shape changing or both parameters can be set to move the shape while change it.
1 TimelineView(.animation) { timeline in
2 VStack {
3 let now = timeline.date.timeIntervalSinceReferenceDate
4 let angle = Angle.degrees(now.remainder(dividingBy: 2)*180)
5 let x = (cos(angle.radians) * 50)
6
7 Canvas { context, size in
8 context.fill(
9 heartPath(in: CGRect(origin: .zero,
10 size: CGSize(width: size.width, height: size.height)),
11 move: 0,
12 tip: x),
13 with: .color(.red))
14 }
15 .frame(width: 300, height: 200)
16 .border(Color.blue)
17
18 Spacer().frame(height:50)
19
20 Canvas { context, size in
21 context.fill(
22 heartPath(in: CGRect(origin: .zero,
23 size: CGSize(width: size.width, height: size.height)),
24 move: x,
25 tip: x),
26 with: .color(.red))
27 }
28 .frame(width: 300, height: 200)
29
30 Spacer()
31 }
32 }
TimelineView to animate heart shape moving and point in shape moving on Canvas
Animate heart shape moving and point in shape moving on Canvas
Conclusion
Canvas does not offer interactivity to individual elements but it can provide better performance. The TimelineView which was introduced in iOS 15, is used to wrap the view with the canvas. The animation schedule is used with the TimelineView to update the canvas as quickly as the UI can display changes. The time is used to calculate an x-coordinate adjustment and passed to the path in the canvas, which redraws the shape with the new path as quickly as possible. The animations here are simply to move the shape and adjust one point in the shape. Multiple points in the shape as well as the control points can all be adjusted with redrawing of the path.