Offer to come, dig friends take it! I am participating in the 2022 Spring Recruit Punch card activity. Click here for details.
preface
The first three SwiftUI animation series are the author’s actual summary before WWDC 2021. Excited about the TimelineView and Canvas introduced by WWDC 2021. This opens up a whole new set of possibilities that I will attempt to illuminate in this and the next part of the series.
In this article, we’ll explore the TimelineView in detail. We’ll start slowly with the most common uses. However, in my opinion, the greatest possibility comes from the combination of TimelineView and the animation we already know. With a little creativity, among other things, this combination will eventually allow us to achieve “keyframe-like” animations.
In Part 5, we’ll explore the Canvas View and how well it works in combination with our new friend TimelineView.
The animations shown above were created using the techniques described in this article. The full code for the animation can be found in this GIST.
TimelineView components
The TimelineView is a container view that reevaluates its contents at a frequency determined by the associated scheduler:
TimelineView(.periodic(from: .now, by: 0.5)) { timeline in
ViewToEvaluatePeriodically()}Copy the code
The TimelineView receives the scheduler as a parameter. We’ll look at them in more detail later, but for now, the example uses a scheduler that fires every half second.
The other argument is a content closure that takes a timelineView.context argument that looks something like this:
struct Context {
let cadence: Cadence
let date: Date
enum Cadence: Comparable {
case live
case seconds
case minutes
}
}
Copy the code
Cadence is an enumerated type that we can use to decide what to display in our view. Possible values are: live, seconds, and minutes. As a hint, avoid displaying information that is not related to Cadence. A typical example is to avoid displaying milliseconds on a scheduler’s clock with a second or minute rhythm.
Note that Cadence is not something you can change, but something that reflects the state of the device. The documentation provides only one example. On watchOS, Cadence slows down when you lower your wrist. If you find anything else about Cadence that’s changed, I’d love to know. Leave a comment below.
Ok, this all looks great, but there are a number of subtleties we should be aware of. Let’s start building our first TimelineView animations and see what they are.
Understand how TimelineView works
Look at the code below. We have two randomly changing emojis. The only difference between the two is that one is written in a content closure, while the other is placed in a separate view to improve readability.
struct ManyFaces: View {
static let emoji = ["ð"."ðŽ"."ð"."ð"."ð"."ðĪ"."ð"."ð"."ð"."ð"."ð"."ð"."ðĪŠ"]
var body: some View {
TimelineView(.periodic(from: .now, by: 0.2)) { timeline in
HStack(spacing: 120) {
let randomEmoji = ManyFaces.emoji.randomElement() ?? ""
Text(randomEmoji)
.font(.largeTitle)
.scaleEffect(4.0)
SubView()}}}struct SubView: View {
var body: some View {
let randomEmoji = ManyFaces.emoji.randomElement() ?? ""
Text(randomEmoji)
.font(.largeTitle)
.scaleEffect(4.0)}}}Copy the code
Now, let’s see what happens when we run the code:
Surprised? Why does the one on the left change while the other one is always sad? As it turns out, the SubView doesn’t receive any changing parameters, which means it has no dependencies. SwiftUI has no reason to recalculate the body of the view. One of the highlights of WWDC 2021 was Demystify SwiftUI. It explains the view identity, lifecycle, and dependencies. All of these topics are important to understanding why the timeline works the way it does.
To solve this problem, we changed the SubView view to add a parameter that will change with each update to the timeline. Notice that we don’t need to use the parameter, it just needs to be there. However, we will see that this unused value can be very useful later.
struct SubView: View {
let date: Date // just by declaring it, the view will now be recomputed apropriately.
var body: some View {
let randomEmoji = ManyFaces.emoji.randomElement() ?? ""
Text(randomEmoji)
.font(.largeTitle)
.scaleEffect(4.0)}}Copy the code
Now the SubView is created like this:
SubView(date: timeline.date)
Copy the code
Finally, we experience emotional sturm und drang with both expressions:
Follow the timeline
Most examples of TimelineView (as of this writing) are usually about drawing a clock. That makes sense. The data provided by the timeline is, after all, an instance of a date type.
The simplest TimelineView clock ever:
TimelineView(.periodic(from: .now, by: 1.0)) { timeline in
Text("\(timeline.date)")}Copy the code
Clocks are likely to get more elaborate. For example, use an analog clock with shapes, or use a new Canvas view to draw the clock.
However, TimelineView is not just for clocks. In many cases, we want the view to handle something every time the timeline updates our view. The best place to put this code is the onChange(of: Perform) closure.
In the following example, we use this technique to update the model every 3 seconds.
struct ExampleView: View {
var body: some View {
TimelineView(.periodic(from: .now, by: 3.0)) { timeline in
QuipView(date: timeline.date)
}
}
struct QuipView: View {
@StateObject var quips = QuipDatabase(a)let date: Date
var body: some View {
Text("_\(quips.sentence)_")
.onChange(of: date) { _ in
quips.advance()
}
}
}
}
class QuipDatabase: ObservableObject {
static var sentences = [
"There are two types of people, those who can extrapolate from incomplete data"."After all is said and done, more is said than done."."Haikus are easy. But sometimes they don't make sense. Refrigerator."."Confidence is the feeling you have before you really understand the problem."
]
@Published var sentence: String = QuipDatabase.sentences[0]
var idx = 0
func advance(a) {
idx = (idx + 1) % QuipDatabase.sentences.count
sentence = QuipDatabase.sentences[idx]
}
}
Copy the code
Note that our QuipView refreshes twice every time the timeline is updated. That is, once when the timeline is updated, and then again immediately after, because the @published value of quips.sentence changes and triggers the view update by calling quips.advance(). This is good, but be careful, because it becomes more important later on.
An important concept to take away from this is that while the timeline may produce a certain number of updates, the content of the view is likely to be updated many more times.
TimelineView is combined with traditional animation
The new TimelineView opens up many new opportunities. As we’ll see in a future article, combining it with Canvas is a nice addition. But writing all the code for every frame of the animation put a lot of burden on us. The technique I will introduce in this section is to use animation that we are already familiar with and to be keen on view animation to update from one timeline to the next. This will eventually allow us to create our own keyframe-like animations in pure SwiftUI.
But let’s start slowly, starting with our little project: metronome as shown below. Turn up the volume and play the video to see how the beat sounds in sync with the pendulum. Also, like a metronome, the bell rings every few beats:
Swiftui-lab.com/wp-content/…
First, let’s take a look at what our timeline looks like:
struct Metronome: View {
let bpm: Double = 60 // beats per minute
var body: some View {
TimelineView(.periodic(from: .now, by: 60 / bpm)) { timeline in
MetronomeBack()
.overlay(MetronomePendulum(bpm: bpm, date: timeline.date))
.overlay(MetronomeFront(), alignment: .bottom)
}
}
}
Copy the code
Metronome speed is usually specified in BPM (beats per minute). This example uses a periodic scheduler that repeats every 60/ BPM seconds. For our example, BPM = 60, so the scheduler fires every 1 second. That’s 60 beats per minute.
The Metronome view consists of three layers: MetronomeBack, MetronomePendulum, and MetronomeFront. They are superimposed in that order. The only view that must be refreshed every time the timeline is the MetronomePendulum, which can swing left and right. Other views are not refreshed because they have no dependencies.
The code for MetronomeBack and Metronome Front is very simple, using a custom shape called a circular trapezoid. To avoid making this page too long, customize the shape code in this GIST.
struct MetronomeBack: View {
let c1 = Color(red: 0, green: 0.3, blue: 0.5, opacity: 1)
let c2 = Color(red: 0, green: 0.46, blue: 0.73, opacity: 1)
var body: some View {
let gradient = LinearGradient(colors: [c1, c2],
startPoint: .topLeading,
endPoint: .bottomTrailing)
RoundedTrapezoid(pct: 0.5, cornerSizes: [CGSize(width: 15, height: 15)])
.foregroundStyle(gradient)
.frame(width: 200, height: 350)}}struct MetronomeFront: View {
var body: some View {
RoundedTrapezoid(pct: 0.85, cornerSizes: [.zero, CGSize(width: 10, height: 10)])
.foregroundStyle(Color(red: 0, green: 0.46, blue: 0.73, opacity: 1))
.frame(width: 180, height: 100).padding(10)}}Copy the code
However, the MetronomePendulum view is where things start to get interesting:
struct MetronomePendulum: View {
@State var pendulumOnLeft: Bool = false
@State var bellCounter = 0 // sound bell every 4 beats
let bpm: Double
let date: Date
var body: some View {
Pendulum(angle: pendulumOnLeft ? -30 : 30)
.animation(.easeInOut(duration: 60 / bpm), value: pendulumOnLeft)
.onChange(of: date) { _ in beat() }
.onAppear { beat() }
}
func beat(a) {
pendulumOnLeft.toggle() // triggers the animation
bellCounter = (bellCounter + 1) % 4 // keeps count of beats, to sound bell every 4th
// sound bell or beat?
if bellCounter = = 0 {
bellSound?.play()
} else {
beatSound?.play()
}
}
struct Pendulum: View {
let angle: Double
var body: some View {
return Capsule()
.fill(.red)
.frame(width: 10, height: 320)
.overlay(weight)
.rotationEffect(Angle.degrees(angle), anchor: .bottom)
}
var weight: some View {
RoundedRectangle(cornerRadius: 10)
.fill(.orange)
.frame(width: 35, height: 35)
.padding(.bottom, 200)}}}Copy the code
Our view needs to keep track of where we are in the animation. I call it the animation phase. Since we need to track these phases, we’ll use the @state variable:
pendulumOnLeft
: Track the pendulumPendulum
The direction of the swing.bellCounter
: Record the number of beats to determine whether beats or ringtones should be heard.
This example uses the.animation(_:value:) modifier. This version of the modifier applies animation when a specified value changes. Note that explicit animations can also be used. Instead of calling.animation(), you simply switch the pendulumOnLeft variable within the withAnimation closure.
To move our view forward through the animation phase, we use the onChange(of: Perform) modifier to monitor date changes, just as we did in the previous quip example.
In addition to advancing the animation phase each time the date value changes, we also do this in the onAppear closure. Otherwise, there will be a pause at the beginning.
The last piece of code not related to SwiftUI is to create an NSSound instance. To avoid making the example too complex, I created several global variables:
let bellSound: NSSound? = {
guard let url = Bundle.main.url(forResource: "bell", withExtension: "mp3") else { return nil }
return NSSound(contentsOf: url, byReference: true)
}()
let beatSound: NSSound? = {
guard let url = Bundle.main.url(forResource: "beat", withExtension: "mp3") else { return nil }
return NSSound(contentsOf: url, byReference: true)
}()
Copy the code
If you need a sound file, you can download it at Freesound: freesound.org/
The sound in the sample code is:
-
Chimes: Metronome_pling according to license CC BY 3.0 (m1rk0)
-
Metronome. Wav according to CC0 1.0
TimelineScheduler
As we’ve already seen, the TimelineView needs a TimelineScheduler to determine when to update its content. SwiftUI provides some predefined schedulers, such as the ones we used. However, we can also create our own custom scheduler. I will elaborate in the next section. But let’s start with an existing scheduler.
The TimelineScheduler is basically a structure that uses the TimelineScheduler protocol. Available types are:
AnimationTimelineSchedule
: Update as quickly as possible, giving you the opportunity to animate every frame. It has parameters that let you limit the frequency of updates and pause updates. inTimelineView
With the newCanvas
This is useful when combined with views.EveryMinuteTimelineSchedule
: As the name implies, it updates every minute, at the beginning of every minute.ExplicitTimelineSchedule
You can provide an array of all The Times you want the timeline to update.PeriodicTimelineSchedule
: can provide the start time and how often updates occur.
Although you can create Timeline this way:
Timeline(EveryMinuteTimelineSchedule()) { timeline in
.
}
Copy the code
Since the introduction of Swift 5.5 and SE-0299, we now support class enumeration syntax. This makes the code more readable and improves the auto-complete feature. It is suggested that we use this syntax instead:
TimelineView(.everyMinute) { timeline in
.
}
Copy the code
Note: You may have heard of it, but styles have also been introduced this year. Even better, for styles, as long as you’re using Swift 5.5, you can reverse deploy using previous versions.
There may be multiple enumeration-like options for each existing scheduler. These two lines of code, for example, to create a scheduler AnimationTimelineSchedule type:
TimelineView(.animation) { . }
TimelineView(.animation(minimumInterval: 0.3, paused: false)) { . }
Copy the code
You can even create your own scheduler (don’t forget the static keyword) :
extension TimelineSchedule where Self= =PeriodicTimelineSchedule {
static var everyFiveSeconds: PeriodicTimelineSchedule {
get{.init(from: .now, by: 5.0)}}}struct ContentView: View {
var body: some View {
TimelineView(.everyFiveSeconds) { timeline in
.}}}Copy the code
Custom TimelineScheduler
If none of your existing schedulers meet your needs, create your own. Consider the following animation:
In this animation, we have a heart emoji that changes its scale at irregular intervals and by irregular amplitude. It starts at a scale of 1.0, grows to 1.6 after 0.2 seconds, grows to 2.0 after 0.2 seconds, then shrinks to 1.0 and holds for 0.4 seconds, then starts again. Put it another way:
Scale changes: 1.0 â 1.6 â 2.0 â start again
Time between changes: 0.2 â 0.2 â 0.4 â restart
We can create a HeartTimelineSchedule that updates exactly as the heart needs it. But in the name of reusability, let’s do something more generic that can be reused in the future.
Our new scheduler will be called CyclicTimelineSchedule and will receive a set of time offsets. Each offset value will be relative to the previous value in the array. When the scheduler runs out of offsets, it loops back to the beginning of the array and starts over.
struct CyclicTimelineSchedule: TimelineSchedule {
let timeOffsets: [TimeInterval]
func entries(from startDate: Date.mode: TimelineScheduleMode) -> Entries {
Entries(last: startDate, offsets: timeOffsets)
}
struct Entries: Sequence.IteratorProtocol {
var last: Date
let offsets: [TimeInterval]
var idx: Int = -1
mutating func next(a) -> Date? {
idx = (idx + 1) % offsets.count
last = last.addingTimeInterval(offsets[idx])
return last
}
}
}
Copy the code
Implementing TimelineSchedule has several requirements:
- provide
entry(from:mode:)
Function. - we
Entries
The type of theSequence where Entries.Element == Date
There are several ways to follow the Sequence. This example implements IteratorProtocol and declares conformance to Sequence and IteratorProtocol. You can read more about sequence consistency here.
For Entries that implement IteratorProtocol, we must write the next() function, which generates the date in the timeline. Our scheduler remembers the last date and adds the appropriate offset. When there are no more offsets, it loops back to the first one in the array.
Finally, the icing on the cake is to create an enumeration-like initializer for our scheduler:
extension TimelineSchedule where Self= =CyclicTimelineSchedule {
static func cyclic(timeOffsets: [TimeInterval]) -> CyclicTimelineSchedule{.init(timeOffsets: timeOffsets)
}
}
Copy the code
Now that we’re ready for the TimelineSchedue type, let’s inject some life into our hearts:
struct BeatingHeart: View {
var body: some View {
TimelineView(.cyclic(timeOffsets: [0.2.0.2.0.4])) { timeline in
Heart(date: timeline.date)
}
}
}
struct Heart: View {
@State private var phase = 0
let scales: [CGFloat] = [1.0.1.6.2.0]
let date: Date
var body: some View {
HStack {
Text("âĪ ïļ")
.font(.largeTitle)
.scaleEffect(scales[phase])
.animation(.spring(response: 0.10,
dampingFraction: 0.24,
blendDuration: 0.2),
value: phase)
.onChange(of: date) { _ in
advanceAnimationPhase()
}
.onAppear {
advanceAnimationPhase()
}
}
}
func advanceAnimationPhase(a) {
phase = (phase + 1) % scales.count
}
}
Copy the code
This pattern, which you should be familiar with by now, is the same pattern we use with metronomes. Use onChange and onAppear to advance the animation, use the @state variable to track the animation, and set an animation to transition our view from one timeline update to the next. In this case, we use the.spring animation to give it a nice wobble effect.
Keyframe animation
The heart and metronome examples are, to some extent, keyframe animations. We defined several key points throughout the animation, where we changed the parameters of our view and let SwiftUI animate the transition between these points. The following example will try to generalize this idea and make it more obvious. Meet our new project friends, the Jumping guys:
If you look closely at the animation, you’ll notice that many of the parameters of the emoji character change at different points in time. These parameters are: y-offset, rotation, and y-scale. Equally important, different segments of an animation have different animation types (linear, slow in, and slow out). Since these are the parameters we are changing, it is best to put them in an array. Let’s get started:
struct KeyFrame {
let offset: TimeInterval
let rotation: Double
let yScale: Double
let y: CGFloat
let animation: Animation?
}
let keyframes = [
// Initial state, will be used once. Its offset is useless and will be ignored
KeyFrame(offset: 0.0, rotation: 0, yScale: 1.0, y: 0, animation: nil),
// Animation keyframes
KeyFrame(offset: 0.2, rotation: 0, yScale: 0.5, y: 20, animation: .linear(duration: 0.2)),
KeyFrame(offset: 0.4, rotation: 0, yScale: 1.0, y: -20, animation: .linear(duration: 0.4)),
KeyFrame(offset: 0.5, rotation: 360, yScale: 1.0, y: -80, animation: .easeOut(duration: 0.5)),
KeyFrame(offset: 0.4, rotation: 360, yScale: 1.0, y: -20, animation: .easeIn(duration: 0.4)),
KeyFrame(offset: 0.2, rotation: 360, yScale: 0.5, y: 20, animation: .easeOut(duration: 0.2)),
KeyFrame(offset: 0.4, rotation: 360, yScale: 1.0, y: -20, animation: .linear(duration: 0.4)),
KeyFrame(offset: 0.5, rotation: 0, yScale: 1.0, y: -80, animation: .easeOut(duration: 0.5)),
KeyFrame(offset: 0.4, rotation: 0, yScale: 1.0, y: -20, animation: .easeIn(duration: 0.4)),]Copy the code
It is important to know that when TimelineView appears, it will draw our view, even if there are no planned updates or if they are in the future. When the TimelineView appears, it needs to display something in order to draw our view. We will use the first keyframe as our view state, but it will be ignored when we loop. This is an implementation decision that you may need or want to make differently.
Now, let’s look at our timeline:
struct JumpingEmoji: View {
// Use all offset, minus the first
let offsets = Array(keyframes.map { $0.offset }.dropFirst())
var body: some View {
TimelineView(.cyclic(timeOffsets: offsets)) { timeline in
HappyEmoji(date: timeline.date)
}
}
}
Copy the code
We’ve already benefited from the work we did in the previous example, reusing CyclicTimelineScheduler. As mentioned earlier, we don’t need the offset of the first keyframe, so we discard it.
Now, the fun part:
struct HappyEmoji: View {
// current keyframe number
@State var idx: Int = 0
// timeline update
let date: Date
var body: some View {
Text("ð")
.font(.largeTitle)
.scaleEffect(4.0)
.modifier(Effects(keyframe: keyframes[idx]))
.animation(keyframes[idx].animation, value: idx)
.onChange(of: date) { _ in advanceKeyFrame() }
.onAppear { advanceKeyFrame()}
}
func advanceKeyFrame(a) {
// advance to next keyframe
idx = (idx + 1) % keyframes.count
// skip first frame for animation, which we
// only used as the initial state.
if idx = = 0 { idx = 1}}struct Effects: ViewModifier {
let keyframe: KeyFrame
func body(content: Content) -> some View {
content
.scaleEffect(CGSize(width: 1.0, height: keyframe.yScale))
.rotationEffect(Angle(degrees: keyframe.rotation))
.offset(y: keyframe.y)
}
}
}
Copy the code
For better readability, I put all the changing parameters in a modifier called Effects. As you can see, it’s the same pattern: use onChange and onAppear to advance our animation and add an animation for each keyframe fragment. There is nothing new there.
Don’t! It’s a trap!
In your TimelineView discovery path, you may encounter this error:
Action Tried to Update Multiple Times Per Frame
Let’s look at an example of generating this message:
struct ExampleView: View {
@State private var flag = false
var body: some View {
TimelineView(.periodic(from: .now, by: 2.0)) { timeline in
Text("Hello")
.foregroundStyle(flag ? .red : .blue)
.onChange(of: timeline.date) { (date: Date) in
flag.toggle()
}
}
}
}
Copy the code
The code looks fine, and it should change the text color every two seconds, alternating between red and blue. So what’s likely to happen? Wait a moment and see if you can figure out why.
We’re not dealing with a bug. In fact, the problem was predictable. It is important to remember that the first update to the timeline is when it first appears, and then it follows the scheduler rules to trigger the following updates. So even if our scheduler doesn’t produce updates, the TimelineView ‘content will be generated at least once.
In this particular example, we monitor the timeline. Date value, and when it changes, we toggle the flag variable, which produces a color change.
The TimelineView will appear first. After two seconds, the timeline will update (for example, due to the first scheduler update), triggering the onChange shutdown. This in turn will change the flag variable. Now, since our TimelineView depends on it, it needs to refresh immediately, triggering another switch of flag variables, forcing another TimelineView to refresh, and so on… You get the idea: multiple updates per frame.
So how do we solve this? The solution may be different. In this case, we simply encapsulate the content and move the flag variable into the encapsulated view. Now TimelineView no longer relies on it:
struct ExampleView: View {
var body: some View {
TimelineView(.periodic(from: .now, by: 1.0)) { timeline in
SubView(date: timeline.date)
}
}
}
struct SubView: View {
@State private var flag = false
let date: Date
var body: some View {
Text("Hello")
.foregroundStyle(flag ? .red : .blue)
.onChange(of: date) { (date: Date) in
flag.toggle()
}
}
}
Copy the code
Explore new ideas
Refresh once per timeline update: As mentioned earlier, this mode causes our views to evaluate their body twice per update: first when the timeline is updated, and then again when we advance the animation state value. In this type of animation, we spaced out key points in time, which was great.
In these animations where the time points are too close together, you may need/want to avoid this. If you need to change stored values but avoid view refreshes… There’s a trick you can use. Use @stateObject instead of @state. Make sure you don’t set this value in @published. If at some point you want/need to tell your view to refresh, you can always call objectwillchange.send ().
Match animation duration and offset: In the keyframe example, we use a different animation for each animation segment. To do this, we store the animated values in an array. If you look closely, you’ll see that in our specific example, the offset matches the animation duration! That makes sense, right? Therefore, you can define an enumeration with an Animation type instead of containing the Animation value in an array. Later in your view, you will create the animation value based on the animation type, but instantiate it with the duration of the offset value. Something like this:
enum KeyFrameAnimation {
case none
case linear
case easeOut
case easeIn
}
struct KeyFrame {
let offset: TimeInterval
let rotation: Double
let yScale: Double
let y: CGFloat
let animationKind: KeyFrameAnimation
var animation: Animation? {
switch animationKind {
case .none: return nil
case .linear: return .linear(duration: offset)
case .easeIn: return .easeIn(duration: offset)
case .easeOut: return .easeOut(duration: offset)
}
}
}
let keyframes = [
// Initial state, will be used once. Its offset is useless and will be ignored
KeyFrame(offset: 0.0, rotation: 0, yScale: 1.0, y: 0, animationKind: .none),
// Animation keyframes
KeyFrame(offset: 0.2, rotation: 0, yScale: 0.5, y: 20, animationKind: .linear),
KeyFrame(offset: 0.4, rotation: 0, yScale: 1.0, y: -20, animationKind: .linear),
KeyFrame(offset: 0.5, rotation: 360, yScale: 1.0, y: -80, animationKind: .easeOut),
KeyFrame(offset: 0.4, rotation: 360, yScale: 1.0, y: -20, animationKind: .easeIn),
KeyFrame(offset: 0.2, rotation: 360, yScale: 0.5, y: 20, animationKind: .easeOut),
KeyFrame(offset: 0.4, rotation: 360, yScale: 1.0, y: -20, animationKind: .linear),
KeyFrame(offset: 0.5, rotation: 0, yScale: 1.0, y: -80, animationKind: .easeOut),
KeyFrame(offset: 0.4, rotation: 0, yScale: 1.0, y: -20, animationKind: .easeIn),
]
Copy the code
If you’re wondering why I didn’t do this in the first place, I just want to show you that it’s possible to go both ways. The first is more flexible, but more verbose. That is, we are forced to specify a duration for each animation, but it is more flexible because we are free to use a duration that does not match the offset.
However, when using this new method, you can easily add a customizable factor that lets you slow down or speed up the animation without touching the keyframe.
Nested TimelineViews: There is nothing to stop you from nesting one TimelineView into another. Now that we have JumpingEmoji, we can place three JumpingEmoji views in the TimelineView, making them appear one at a time with a delay:
For the entire Emoji wave source, check out this gits.
GifImage sample
I had another example, but it was scrapped by the time I published the article. It doesn’t make the cut because the concurrency API is not stable. Fortunately, it is now safe to publish it. This code uses the TimelineView to implement the animated GIF view. The view loads the GIF asynchronously from the URL, which can be local or remote. All the code is provided in this GIST.
summary
Congratulations on reading to the end of such a long article. It’s a ride! Let’s move from the simplest example of a TimelineView to some creative uses of views. In Part 5, I’ll explore the new Canvas view and how it integrates with the TimelineView. By putting them together, we expand the possibilities in the SwiftUI animation world.