EP 137 · SwiftUI Animation · Mar 1, 2021 ·Members

Video #137: SwiftUI Animation: The Point

smart_display

Loading stream…

Video #137: SwiftUI Animation: The Point

Episode: Video #137 Date: Mar 1, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep137-swiftui-animation-the-point

Episode thumbnail

Description

Animating asynchronous effects with Combine schedulers is not only important for the Composable Architecture. It can be incredibly useful for any SwiftUI application. We will explore this with a fresh SwiftUI project to see what problems they solve and how they can allow us to better embrace SwiftUI’s APIs.

Video

Cloudflare Stream video ID: 1e5222efe12773c77f99a9359d4defa5 Local file: video_137_swiftui-animation-the-point.mp4 *(download with --video 137)*

References

Transcript

0:05

So we think this is pretty incredible. We are now able to animate any kind of state change that happens in the Composable Architecture, whether it be from sending an action to the store directly, or if a binding makes a change to state, or if state is changed from an asynchronous effect that feeds data back into the system. All of it can be animated, and you can choose when and how the animation happens. And we did all of this without making a single change to the core Composable Architecture library. You could have added all of this functionality to your code base without waiting for us to add the functionality. This is the power of having small, un-opinionated, composable units to build your applications with.

0:43

So we’ve accomplished what we set out to do, which was unlock all of the amazing animation capabilities of SwiftUI for the Composable Architecture. There’s no type of animation that one can do in vanilla SwiftUI that we can’t also do in the Composable Architecture, and it all boiled down to a simple transformation that is performed on schedulers.

1:05

But we like to end our episodes by asking “what’s the point?”. This is our opportunity to bring things down to earth and show more real world applications of the concepts we are discussing. Now the concepts we are discussing are already pretty rooted in reality, having just fixed a deficiency in the Composable Architecture when dealing with animations, but that doesn’t mean we can’t go a little deeper.

1:26

We are going to explore two additional facets of animations and schedulers.

1:31

First, there may be a chance that some of our viewers don’t have much interest in the Composable Architecture, and they like to build their applications using view models or some other style. That’s quite alright, we know it isn’t for everyone. However, this concept of transforming schedulers into animatable schedulers is applicable to vanilla SwiftUI, even UIKit(!), and so we’d like to demonstrate that.

1:56

Second, as most of our viewers know by now, we are currently building an application in the Composable Architecture that will be released soon. It’s called isowords and it’s a word game. This is a large, real world code base consisting of both a client and a server component, both written in Swift. So we’d like to take a moment to demonstrate a few places we can make use of this new .animation operator.

2:20

Let’s get started. Animating effects in vanilla SwiftUI

2:24

We’ll start with a new, empty SwiftUI project. We are going to build a quick demo application that loads movies into a list view. We aren’t going to actually go through the trouble of hitting an external API or anything like that, but you should keep in mind that’s what you would typically do for this kind of application.

2:55

Let’s get a basic model in place to represent a movie: struct Movie: Identifiable { let id: UUID let name: String let isFavorite: Bool }

3:08

And let’s get a view model in place that will hold the demo’s logic. Right now it can just be a basic scaffold of a view model, but let’s go ahead and hold onto a published property of movies so that we can display them in the view: class MoviesViewModel: ObservableObject { @Published var movies: [Movie] = [] init() { } }

3:43

We’ll have our view hold onto this view model: struct ContentView: View { @ObservedObject var viewModel: MoviesViewModel … }

4:05

To get our our SwiftUI preview building we need to pass a view model along to the view: struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView(viewModel: MoviesViewModel()) } }

4:12

And we’ll do the same for our app’s entrypoint: import SwiftUI @main struct ViewModelAnimationsApp: App { var body: some Scene { WindowGroup { ContentView(viewModel: MoviesViewModel()) } } }

4:21

Now that we’re building again let’s get a basic view in place that simply renders a heart and a text view for each movie: struct ContentView: View { @ObservedObject var viewModel: MoviesViewModel var body: some View { List { ForEach(self.viewModel.movies) { movie in HStack { if movie.isFavorite { Image(systemName: "heart.fill") } else { Image(systemName: "heart.fill") .hidden() } Text(movie.name) } } } } }

5:03

OK, now we’re ready to start doing the real work. Let’s start simple by implementing an endpoint on our view model that will load a bunch of movies, exposed as a Combine publisher. This will be a method that returns a publisher of arrays of movies: func allMovies() -> AnyPublisher<[Movie], Never> { }

5:33

To construct an AnyPublisher we can use any number of publisher types that come with Combine, and then erase it. For example, we could return a Just publisher, which emits immediately: func allMovies() -> AnyPublisher<[Movie], Never> { Just( (1...20).map { index in Movie( id: UUID(), name: "Movie \(index)", isFavorite: index.isMultiple(of: 2) ) } ) .eraseToAnyPublisher() }

6:28

To make use of this publisher we can fire it up when the view model is initialized, and when it produces a value update the published field. Combine even provides a really nice operator that is made specifically for piping the output of a publisher into an @Published field: init() { self.allMovies() .assign(to: &self.$movies) }

7:16

And just like that our preview is now populating movies in the list.

7:29

Our viewers should note that there is a separate assign(to:on:) method that takes an object and a key path to one of its properties, but in contrast to the one that we’re using, it requires us to manage memory and cancellation ourselves because it strongly retains the object and returns an AnyCancellable .

8:29

However, this wouldn’t be super realistic for our demo. In reality this publisher would be reaching out to an external API over the network, and that takes time. So, to add a bit more realism lets put in an artificial delay. To do that we can just tack on a .delay operator to further delay the output of the Just publisher: func allMovies() -> AnyPublisher<[Movie], Never> { Just( (1...20).map { index in Movie( id: UUID(), name: "Movie \(index)", isFavorite: index.isMultiple(of: 2) ) } ) .delay(for: 1, scheduler: DispatchQueue.main) .eraseToAnyPublisher() }

9:21

To be even more realistic we should probably also deliver the output on a non-main queue since that’s typically what would happen with an API request. If you use URLSession to make your network requests you will always receive its output on a non-main thread. So let’s hard code a background queue in the delay: .delay(for: 1, scheduler: DispatchQueue(label: "background.queue"))

9:54

If we run this we will see that after a second delay some movies populate the list. However, there is technically a problem with this and we can’t see it from the preview alone. If we run in the simulator and wait to get our results we will see the following warning pop up: SwiftUI: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

10:27

This is because the allMovies publisher delivers its results on a background thread, and we have to re-dispatch that back to the main thread before updating any published properties on our view model. The fix is easy enough, we just need to tack on a .receive(on:) operator before piping the output to our view model: self.allMovies() .receive(on: DispatchQueue.main) .assign(to: &self.$movies)

10:49

Now everything works as it did before, but no warnings.

10:57

Now the question is: how do we animate this?

11:00

We don’t actually have access to the moment self.movies is mutated. That’s hidden away somewhere inside the .assign(to:) operator, which means we can’t easily wrap it in a withAnimations block.

11:22

I suppose we could go add an implicit animation modifier on the list to get things animating: var body: some View { List { … } .animation(.default) }

11:32

That does the job, but remember that implicit animations are broad and un-targeted. They want to animate everything in the view hierarchy. In previous episodes we found that explicit animations were a lot easier to grapple with because it allowed us to be much targeted in what exactly we wanted to animate.

11:55

So let’s back out of using implicit animations for this: var body: some View { List { … } // .animation(.default) }

11:58

It looks as if maybe we can’t use the .assign(to:) operator since it prevents us from accessing the exact moment our field is mutated. To work around we can use a more blunt tool: .sink . The .sink method subscribes to a publisher and receives its value via a closure: self.allMovies() .receive(on: DispatchQueue.main) // .assign(to: self.$movies) .sink { movies in self.movies = movies }

12:32

But now there are a couple of new things to think about. Result of call to sink(receiveValue:) is unused First, we now have a warning about an unused value. That’s because .sink returns a cancellable which must be held onto in the view model. This keeps the subscription alive long enough for the publisher to deliver its output to us. If we don’t hold onto the cancellable it will be cancelled immediately.

12:51

So, let’s add a cancellable to our view model: class MoviesViewModel: ObservableObject { private var cancellable: Cancellable? … }

12:59

And assign it when we .sink : self.cancellable = self.allMovies() .receive(on: DispatchQueue.main) .sink { movies in self.movies = movies }

13:04

But we have another problem that doesn’t have a warning. The sink is strongly retaining self . Now while we know this publisher will complete after it delivers its value, there’s no reason why this logic couldn’t evolve into a more longer-living effect, like a web socket that periodically delivers movies, and then we would have a retain cycle on our hands, so we should also weakify self to break this potential cycle: self.cancellable = self.allMovies() .receive(on: DispatchQueue.main) .sink { [weak self] movies in self?.movies = movies }

13:45

Our demo should work exactly as it did before, but we now have the opportunity to animate the changes to the movies field: self.cancellable = self.allMovies() .receive(on: DispatchQueue.main) .sink { [weak self] movies in withAnimation { self?.movies = movies } }

13:53

Now the list animates and we don’t have to be worried about accidentally animating everything in the view as we would have with the implicit animation modifier.

13:56

However, it’s a bit of a bummer that we had to give up the nice .assign(to:) API just because we wanted to shoehorn in a bit of animation in our publisher chain, and we now have to worry about cancellables and retain cycles.

14:10

But, at least we were able to work around and find a solution.

14:20

So that’s how you typically animate asynchronous effects in SwiftUI: you introduce a sink and call withAnimation in the receive block, but it’s unfortunate to lose a nicer API in the process. It’s a little strange that Apple would provide such a nice API with such a glaring oversight. If you want to animate your state changes at all you simply cannot use the .assign(to:) operator. Layering complexity onto animation logic

15:00

However, there are times where even this escape hatch of using .sink is insufficient to implement certain kinds of animations. To explore this let’s kick things up a notch.

15:12

Let’s pretend that we have a collection of our favorite movies saved to disk, and we want to immediately display them while the network request is inflight, and then once the request finishes the results will be appended to the favorites.

15:28

We can start by adding another endpoint that returns a publisher of the collection of cached favorites: func cachedFavorites() -> AnyPublisher<[Movie], Never> { }

15:51

We are modeling this as a publisher because the process of loading data from disk and deserializing it into a model can take a bit of time, and should probably not be done on the main queue. For now we will approximate this by returning a Just publisher that delivers its output on a background thread: func cachedFavorites() -> AnyPublisher<[Movie], Never> { Just( [ .init( id: .init(), name: "2001: A Space Odyssey", isFavorite: true ), .init(id: .init(), name: "Parasite", isFavorite: true), .init(id: .init(), name: "Moonlight", isFavorite: true), ] ) .receive(on: DispatchQueue(label: "file.queue")) .eraseToAnyPublisher() }

16:40

We can now use this publisher to implement the feature we want. We can prepend it to our other publisher: self.allMovies() .prepend(self.cachedFavorites())

16:53

This is a new publisher that will first run the cachedFavorites publisher, and once it produces an output it will start the allMovies publisher.

16:59

This publisher isn’t quite right yet because it will emit and assign the cached favorites to self.movies and then a second later it will emit allMovies and reassign self.movies again, losing the cached favorites. What we want to to is to append both of these values together into a single array, which we can do with the .scan operator. If you aren’t familiar with the .scan operator then all you need to know about it is that it’s a lot like the .reduce method on arrays in that it can accumulate a single value from all the values in the publisher, but it further outputs each accumulated value. So, doing this: self.allMovies() .prepend(self.cachedFavorites()) .scan([], +)

17:53

Produces a new publisher that first emits the cached favorites, and then a second later emits the concatenation of the cached favorites with all the movies. And then finally we can .sink on this publisher to update the view model’s movies field:

18:03

When we run our preview we see our cached favorites up at the top of the list along with the rest of the movies. But in order to better distinguish the groups, though, let’s make sure that allMovies do not include any favorites.

18:15

Now when we run the preview we still see the favorites appear immediately, and then a second later the rest of the movies animate into place. It’s a little strange that the favorites didn’t animate. After all, we are explicitly animating all mutations to the movies. What gives?

18:30

Well, turns out that animations can be a little finicky in SwiftUI previews. You can get a lot done with the preview, but at the end of the day it’s best to double check your work in the simulator or on a device because certain things can behave slightly differently, animation being one example.

18:44

If we run our app in the simulator we see slightly different behavior. We see the favorites animate in immediately, and then a second later the rest of the movies animate in. So it seems that SwiftUI previews have trouble performing animations that happen immediately in the application’s lifecycle. That’s unfortunate, and if we don’t want to lose the nice feedback cycle that previews give us we can introduce a minuscule delay in the output of the cached publisher: func cachedFavorites() -> AnyPublisher<[Movie], Never> { Just( [ .init( id: .init(), name: "2001: A Space Odyssey", isFavorite: true ), .init(id: .init(), name: "Parasite", isFavorite: true), .init(id: .init(), name: "Moonlight", isFavorite: true), ] ) // .receive(on: DispatchQueue(label: "file.queue")) .delay(for: 0.1, scheduler: DispatchQueue(label: "file.queue")) .eraseToAnyPublisher() }

19:33

Now when we run the preview we get the favorites animating in and a second later the rest of the movies animate in.

19:41

However, what if we didn’t want the favorites to animate in? After all, they are available almost immediately. It would be a better user experience if the favorites were immediately displayed, and then once the rest of the movies are available they animate in.

19:54

Currently we are animating all mutations to the movies field in the .sink : .sink { [weak self] movies in withAnimation { self?.movies = movies } }

20:01

We need to somehow distinguish between this mutation happening due to cached favorites emitting versus when the rest of the movies emit.

20:09

One really silly thing we could do is keep track of a little mutable count outside the .sink , increment it every time the .sink closure is invoked, and then use that count to determine which mutation we are doing: var count = 0 self.cancellable = self.allMovies() .prepend(self.cachedFavorites()) .scan([], +) .receive(on: DispatchQueue.main) .sink { [weak self] movies in count += 1 withAnimation(count == 1 ? nil : .default) { self?.movies = movies } }

20:49

That may get the job done here, but for more complex publishers this may not be feasible at all. In general we would probably instead introduce some extra state to our publishers, but that would make things way more complicated that we’d like it to be. So let’s undo that work: self.cancellable = self.allMovies() .prepend(self.cachedFavorites()) .scan([], +) .receive(on: DispatchQueue.main) .sink { [unowned self] movies in withAnimation { self.movies = movies } } Fine-tuning animation logic with schedulers

21:09

So, it seems even if we abandon the nice .assign(on:) operator we can still run into trouble with wanting to animate certain emissions of a publisher and not animating other emissions. It doesn’t seem that the Combine framework comes with the tools for us to easily solve this problem.

21:25

The crux of the problem is that we don’t currently have access to the exact moment a state mutation is made for each of our concatenated publishers. The .sink publisher is called all the same regardless of which publisher is emitting, and there’s no obvious way to distinguish.

21:46

However, the .sink closure is invoked in a very specific context. Let’s add a breakpoint inside the .sink closure: .sink { [unowned self] movies in withAnimation { self.movies = movies } }

22:09

And when running the app in the simulator we’ll get caught in a stack trace that shows that this code was enqueued from another thread: Enqueued from com.apple.root.default-qos (Thread 5) Queue : com.apple.root.default-qos (serial) #0 0x000000010e8388c9 in dispatch_async () #1 0x00007fff54f4f837 in OS_dispatch_queue.async(group:qos:flags:execute:) () #2 0x00007fff54f55a8e in OS_dispatch_queue.schedule(options:_:) () #3 0x00007fff54f55dd0 in protocol witness for Scheduler.schedule(options:_:) in conformance OS_dispatch_queue () #4 0x00007fff4ba26404 in Publishers.ReceiveOn.Inner.receive(_:) () …

22:13

In that thread’s stack trace we can clearly see that our work is being executed in the context of a Combine scheduler. We see mentions of OS_dispatch_queue , which is the concrete scheduler being used, and below that we see mentions of protocol witness for Scheduler.schedule , which indicates that the dispatch queue is being used abstractly as a Scheduler protocol conformance.

22:49

This shows that Combine schedulers are somehow intimately involved with how mutations are made to our view model. In fact, the scheduler code completely wraps our mutation, and so it seems like a great place to look for ways to slightly alter the scheduler so that we can wrap its work in an animation block.

23:09

Unfortunately this can’t be done with the tools that Combine gives us today. However, we can build those tools ourselves and in fact we open sourced a library that specifically implements schedulers that we feel should be included in Combine proper. And last week we introduced a new transformation on schedulers that allows you to enhance any existing scheduler into one that animates the execution of its work.

23:45

To get access to this we need to add the combine-schedulers library to our project:

24:15

It’s important to note that you can bring in just this library without bringing in the entirety of the Composable Architecture. While it is true that the Composable Architecture depends on combine-schedulers, they are still separate libraries and so if you have no interest in using the Composable Architecture you can still make use of our scheduler enhancements.

24:35

Let’s remind ourselves what the animated scheduler we defined last time looks like: extension Scheduler { func animation( _ animation: Animation? = .default ) -> AnySchedulerOf<Self> { .init( minimumTolerance: { self.minimumTolerance }, now: { self.now }, scheduleImmediately: { options, action in self.schedule(options: options) { withAnimation(animation, action) } }, delayed: { after, tolerance, options, action in self.schedule( after: after, tolerance: tolerance, options: options ) { withAnimation(animation, action) } }, interval: { after, interval, tolerance, options, action in self.schedule( after: after, interval: interval, tolerance: tolerance, options: options ) { withAnimation(animation, action) } } ) } }

24:43

This method may seem a little intense, but it’s pretty straightforward. First, we trying to construct a whole new scheduler, which requires us to provide five arguments, each of which is a closure. For some of these endpoints we can simply forward to the corresponding endpoint on self , and the other arguments are closures that are invoked when the scheduler is being asked to schedule a unit of work. In each of those cases we simple forward to the underlying self ’s implementation, but with the added twist that we wrap the execution of the work in a withAnimation block.

24:54

This is what gives us access to the exact moment state is mutated even when it seems like that moment is hopelessly hidden from us deep in the bowels of Combine’s code.

25:10

For example, instead of wrapping the mutation inside of .sink with a withAnimation block we can simply tack on an .animation transformation to the main queue scheduler that we want to receive values on: self.cancellable = self.allMovies() .prepend(self.cachedFavorites()) .scan([], +) .receive(on: DispatchQueue.main.animation()) .sink { [weak self] movies in self?.movies = movies }

25:22

This operates exactly as it did before, but now we’ve moved animation out of the .sink and to the higher level of schedulers.

25:45

But it gets even better. We can further localize the .receive(on:) so that it is attached to each of the publishers being concatenated together. This allows us to be very explicit with which output gets animated and which does not: self.cancellable = self.allMovies() .receive(on: DispatchQueue.main.animation()) .prepend( self.cachedFavorites() .receive(on: DispatchQueue.main) )

26:08

If we run the preview we will see that we have the behavior we want. The favorites appear immediately with no animation, and the rest of the movies come in a second later with animation.

26:16

This now reads very nicely and tells us that we want to receive the view model mutations from cached favorites on the main thread with no animations, and we want to receive the model mutations from all movies on the main thread with the default animation.

26:24

Further, now that we aren’t performing animation in the .sink we can go back to using the .assign(to:) operator: self.allMovies() .receive(on: DispatchQueue.main.animation()) .prepend( self.cachedFavorites .receive(on: DispatchQueue.main) ) .scan([], +) .assign(to: self.$movies)

26:33

And we no longer need to hold onto a cancellable. // private var cancellable: AnyCancellable?

26:42

So this is pretty nice! We’re able to go back to the nice, succinct .assign(to:) operator, and we even got rid of the cancellable held in the view model, all thanks to the scheduler animation operator.

27:17

We can even apply this idea more broadly. Say that you still have a few older UIKit views in your application. If you have any Combine code powering their logic then you will need to create another one of these animation operators on the Scheduler type, but this time tuned for UIView animations instead of SwiftUI animations. To do this we just need to to wrap the invocation of the scheduler’s action in a UIView.animate call. Here’s how it’s done: extension Scheduler { public func animate( withDuration duration: TimeInterval, delay: TimeInterval = 0, options animationOptions: UIView.AnimationOptions = [] ) -> AnyScheduler<SchedulerTimeType, SchedulerOptions> { AnyScheduler( minimumTolerance: { self.minimumTolerance }, now: { self.now }, scheduleImmediately: { options, action in self.schedule(options: options) { UIView.animate( withDuration: duration, delay: delay, options: animationOptions, animations: action ) } }, delayed: { date, tolerance, options, action in self.schedule( after: date, tolerance: tolerance, options: options ) { UIView.animate( withDuration: duration, delay: delay, options: animationOptions, animations: action ) } }, interval: { date, interval, tolerance, options, action in self.schedule( after: date, interval: interval, tolerance: tolerance, options: options ) { UIView.animate( withDuration: duration, delay: delay, options: animationOptions, animations: action ) } } ) } }

30:23

So what we’re seeing is that this kind of “animated” scheduler can be useful for many more things than the Composable Architecture. It could be just as powerfully used in a UIKit app that powers some of its features using Combine.

31:09

Further, you could even be using a reactive library that isn’t Combine. Perhaps you have some really old legacy code that you must support or are supporting older versions of iOS. There are even some ports of the Composable Architecture that use ReactiveSwift or RxSwift instead of Combine. Each of those libraries have their own version of the Scheduler protocol, and it serves the same purpose. It describes when and how work is executed in a reactive chain. And so those libraries can even benefit from this concept of “transforming schedulers.” They can easily enhance any existing scheduler with one that animates its actions.

31:51

So this is pretty cool. Not only can we use scheduler animations to hyper localize animations to just a single publisher that is combined with many others, but we can also allow ourselves to use all the niceties Combine has to offer, such as the .assign(to:) operator. Being able to transform schedulers is a truly foundational and important concept, and opens up lots of possibilities. There are even more examples of scheduler transformations that we could explore, but we’ll have to save that for another episode. Animating in isowords

32:19

Now that we’ve seen that scheduler transformations are important whether you are building your application with the Composable Architecture or with vanilla SwiftUI, or even UIKit, let’s turn our attention on a very real world application of the concept.

32:32

As many of our viewers know we have been working on a word game for iOS called “isowords”, and hopefully we will be releasing it very soon. Both the client and server are built in Swift, and the client is 100% Composable Architecture. We gave a little preview of the application and it’s project structure a few episodes back, so let’s just jump straight into things.

32:54

I want to focus on one very specific interaction of the application, and that’s start-up. When the application first loads a pretty complex effect is fired off. Let’s launch the app in the simulator so that we can see what it’s doing:

33:20

First the effect will invoke Game Center authentication. We use Game Center authentication because it gives us a small bit of information about the player, such as an identifier and display name, without asking them to authenticate with email, phone number, Apple login, or any of those other authentication schemes. Also, by authenticating with Game Center we allow people to participate in multiplayer games, which lets you play isowords against friends in a turn-based fashion. It’s a lot of fun!

33:43

Once authenticated with Game Center we authenticate with our servers, which allows the app to speak to our API and get some additional data, such as the current number of players in today’s daily challenge, as well as this little summary “week in review” module at the bottom which shows you your ranks for the past week.

34:03

Also once authenticated with Game Center we can load up all of your active multiplayer matches. You can have many of these going at once, and some of them it may be your turn and others you may be waiting for the other player. All of those games are displayed in this little horizontal scrolling module.

34:23

Further, if that wasn’t enough, we also load up any currently in progress games you have to make it easy for you to resume. See, when you play isowords you can choose if you want a short timed game, or an open-ended, unlimited game. Since unlimited games don’t need to be completed all at once you can stop and resume them at anytime. Those games are stored as JSON on the disk, and so on start up we want to load them up and display them in this active games module.

34:43

So that’s quite complex! Before diving into the code let’s take notice of a few things that happened quickly when we launched the app. First we will notice that when the daily challenge summary loaded so that it says “28 people have already played!” it did so with no animation at all. The content just popped in. The same thing happened when loading the active turn based matches too. Those cards just appeared immediately. I think it would be far better if we had some animation to make it less jarring when new data comes in.

35:11

However, let’s forget for a moment that we have this cool animation operator on schedulers. Let’s approach things as if we only had the tools that SwiftUI gives us. Since these state changes are performed from asynchronous effects, we cannot use the withAnimation API. All we can do use is implicit animations.

35:27

The view we are seeing in the simulator is called HomeView . It’s got quite a bit in it, but if we scroll down we’ll see a part where we specifically check if we have some active games so that we can display the ActiveGamesView side-scrolling module: if self.viewStore.hasActiveGames { VStack(alignment: .leading) { Text("Active games") .adaptiveFont(.matterMedium, size: 16) .foregroundColor( self.colorScheme == .dark ? .hex(0xEBAE83) : .isowordsBlack ) .adaptivePadding() ActiveGamesView( store: self.store.scope( state: \.activeGames, action: HomeAction.activeGames ) ) .foregroundColor( self.colorScheme == .dark ? .hex(0xE9A27C) : .isowordsBlack ) } }

35:44

We want the changes in this view to animation, so perhaps we can just tack on a .animation modifier and be done with it: if self.viewStore.hasActiveGames { VStack(alignment: .leading) { … } .animation(.default) }

35:51

If we run this in the simulator we something really funky. The whole module seems to animate up and then the multiplayer matches fade in. That seems really distracting. I have no idea why it is sliding up, but ideally the local game would not animate at all because it’s data loads pretty much instantly.

36:13

We could also try localizing the .animation modifier a bit by moving it onto the ActiveGamesView rather than the parent VStack : if self.viewStore.hasActiveGames { VStack(alignment: .leading) { … ActiveGamesView( … ) … .animation(.default) } }

36:22

Now when we run in the simulator we see only the local game card slides up rather than the whole module. Also we seem to have also picked up a weird glitch where the card’s corner radius isn’t set until the animation completes. To see this we can turn on slow animations in the simulator and launch again. It appears that corner radius of the view is animating for some reason!

36:54

To understand why this might be happening let’s recall what the documentation for .animation said: Note Use this modifier on leaf views rather than container views. The animation applies to all child views within this view; calling animation(_:) on a container view can lead to unbounded scope.

37:14

It seems that the documentation recommends placing .animation modifiers as close to leaf views as possible. This is probably because implicit animations are very broad, un-targeted forms of animation. They just try to animate everything that changes in the view, and so if you apply this modifier at a high level you run the risk of animating a lot more than you intended. And that is in fact exactly what is happening here. We are animating all types of things that we did not expect, such as vertical position and corner radius.

37:38

So, perhaps we should try moving this .animation modifier even closer to the thing we actually want to animate. Let’s remove it from this view: if self.viewStore.hasActiveGames { VStack(alignment: .leading) { … ActiveGamesView( … ) … // .animation(.default) } }

37:48

And let’s just into ActiveGamesView to see if there is an appropriate place to add it in there.

37:49

We’ll see that ActiveGamesView basically consist of a horizontal scroll view with an HStack inside so that we can array the active game cards in a row. First we see if you have a resumable daily challenge game or unlimited game and add an ActiveGameView to the HStack , and then for all of your turn-based games we ForEach over that array to add another ActiveGameView to the HStack .

38:18

So maybe all we have to do is add the .animation modifier to just the ForEach since that’s all we want to animate. We don’t ever want to animate the locally saved games. So let’s try it: ForEach(self.viewStore.turnBasedMatches) { match in … } .animation(.default)

38:33

When running in the simulator we see some strange behavior. The local games appear immediately with no animation and that is good, but then a moment later the multiplayer games appear, but also without animation. I really don’t understand why nothing is animating when we apply the modifier here, though, since it seems like the perfect place to do it.

38:50

Perhaps we are being too targeted in our application of .animation ? Maybe we should apply further up the view hierarchy, like say on the HStack inside the ScrollView : ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 20) { … } .animation(.default) }

39:02

Running this in the simulator shows even weirder behavior. Now the contents of the local game slide up into place but the card background stays fixed. And then a moment later the multiplayer games animate into view.

39:25

But there was also another animation we wanted to perform besides the active games. It was the daily challenge summary. If we go back to HomeView we will find a place where we are showing a DailyChallengeHeaderView : DailyChallengeHeaderView(store: self.store) .adaptivePadding([.leading, .trailing])

39:38

This renders the top header, so perhaps we just need to tack on a .animation modifier to make it animate: DailyChallengeHeaderView(store: self.store) .adaptivePadding([.leading, .trailing]) .animation(.default)

39:47

If we run this we see something really bizarre. The view is rendered in a really tall rectangle that animates into its final size. I have no idea what that is happening, but maybe again it is due to us applying the modifier too high up in the view hierarchy. Maybe we need to go inside DailyChallengeHeaderView and find somewhere more local to apply it.

40:12

The place we construct the sentence saying how many players have played the daily challenge is towards the bottom in a Group that holds a bunch of if / else if logic. Perhaps we can apply the .animation modifier to this Group so that the animation is localized to just this bit of text: Group { if numberOfPlayers == 0 { Text("No one has played. Be the first!") } else if self.hasPlayedAllDailyChallenges { … } } .animation(.default)

40:36

When we run this in the simulator we see something really bizarre: the label first animates in from off-screen, and even more bizarrely, the text doesn’t animate at all when it’s populated with server data.

40:50

So this is not instilling a lot of confidence that we are going to be able to figure out this animation, and even if we do figure it out have faith that we’ll understand the ins and outs of how we concocted it in the first place, and that we won’t break it in the future.

41:05

Now that we see how difficult it would be to accomplish this with only implicit animations, let’s remember that we do actually have the ability to animate asynchronous effects in a really nice, succinct way.

41:21

Let’s undo what we’ve done so far, and let’s look at where the effect is created that loads all of this initial data for the application. It’s towards the bottom of the Home.swift file in a function we called onAppearEffects . There’s quite a bit in here, but the crux of it is in this return statement right here where we concatenate a bunch of things together: return Effect.concatenate( environment.fileClient.loadSavedGames() .map(HomeAction.savedGamesLoaded), gameCenterAuthentication .fireAndForget(), .merge( serverAuthenticateAndLoadData .receive(on: environment.mainQueue) .eraseToEffect(), loadMatches(environment: environment) .receive(on: environment.mainQueue) .eraseToEffect() ) )

41:46

First we load the saved games, then once that’s done we authenticate with Game Center, and then when that is done we will authenticate with our server to load daily challenge and “week in review” data while simultaneously loading any active multiplayer games. The latter two effects can deliver their output on a background queue, and so we further have to make sure to receive the output back on the main queue.

42:06

Phew! It’s a big effect, but also it’s 100% testable 😎.

42:15

The cool thing is that with all these effects laid out next to each other it’s really easy to decide which ones you want to animate and which ones you do not. For example, we know we do not want to animate the loadSavedGames effect, but we do want to animate the serverAuthenticateAndLoadData and loadMatches effects, so we can localize our effects to just those two: .merge( serverAuthenticateAndLoadData .receive(on: environment.mainQueue.animation()) .eraseToEffect(), loadMatches(environment: environment) .receive(on: environment.mainQueue.animation()) .eraseToEffect() ) Conclusion

42:46

And just like that when we run the application in the simulator everything magically works somehow. The daily challenge summary cross fades when new data comes in, the locally saved game appears immediately with no animation, but the multiplayer matches animate in a moment later, and all without any weird animation glitches.

43:14

So with our animated scheduler helper we were able to solve a pretty complex problem simply, in just a couple lines of code, and I don’t know how complex or possible it would have been to solve with implicit animations, or without the helper.

43:28

So that’s just a small taste of how to use this functionality in a real world app, but it is incredibly handy. There are dozens of more places we will be able to use this animation operator to improve our application, and we can’t wait.

44:05

Until next time! References isowords Point-Free A word game by us, written in the Composable Architecture. https://www.isowords.xyz Collection: Schedulers Brandon Williams & Stephen Celis • Jun 4, 2020 We previously did a deep-dive into all things Combine schedulers. We showed what they are used for, how to use them in generic contexts, and how to write tests that make the passage of time controllable and determinstic. Note There’s a lot of great material in the community covering almost every aspect of the Combine framework, but sadly Combine’s Scheduler protocol hasn’t gotten much attention. It’s a pretty mysterious protocol, and Apple does not provide much documentation about it, but it is incredibly powerful and can allow one to test how time flows through complex publishers. https://www.pointfree.co/collections/combine/schedulers combine-schedulers Brandon Williams & Stephen Celis • Jun 14, 2020 An open source library that provides schedulers for making Combine more testable and more versatile. http://github.com/pointfreeco/combine-schedulers Downloads Sample code 0137-swiftui-animation-pt3 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .