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
Using TimelineView to animate heart shape moving in a canvas


Using TimelineView to animate heart shape moving on 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
Using TimelineView to animate heart shape moving on and off Canvas


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
TimelineView to animate heart shape moving back and forth on Canvas


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
TimelineView to animate heart shape moving and point in shape moving on Canvas


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.