Video #136: SwiftUI Animation: Composable Architecture
Episode: Video #136 Date: Feb 22, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep136-swiftui-animation-the-basics

Description
The Composable Architecture mostly “just works” with SwiftUI animations out of the box, except for one key situation: animations driven by asynchronous effects. To fix this we are led to a really surprising transformation of Combine schedulers.
Video
Cloudflare Stream video ID: 8945b45476a8914633fca0491cebeab4 Local file: video_136_swiftui-animation-the-basics.mp4 *(download with --video 136)*
References
- Discussions
- Store.swift
- combine-schedulers
- isowords
- combine-schedulers
- 0136-swiftui-animation-pt2
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
This is starting to show the power behind explicit animations. They allow you to be far more targeted in what you want to animate and how you want to animate.
— 0:14
It’s also worth noting that SwiftUI used to more heavily lean on implicit animations. For example, it used to be that if you ever made a change to the data source powering a List view then those changes would automatically animate. That would cause rows to animate into place or slide away when removed. That made for a really nice demo since you could get animation basically for free, but also meant that a lot of really strong opinions were hardcoded directly in SwiftUI’s foundational components, which seems strange. However, in iOS 14 and Xcode 12 that behavior was changed so that you had to start using explicit animations in order to animate a List view. So it appears that SwiftUI is heading more towards favoring explicit animations over implicit animations.
— 0:58
So, that’s the basics of SwiftUI animations. They come in two major flavors: implicit and explicit. Implicit is heavily state driven, in that whenever state changes it will automatically animate all changes to the view based on that state change. You don’t have the ability to perform animations when an event occurs, it only happens when state changes. This is why it was so hard to prevent animations when the reset button was tapped. Because that is an event and implicit animations don’t handle events well.
— 1:28
On the other hand, explicit animations are far more targeted and more event driven. This means that when events occur we can tell SwiftUI to animate a state change. This is why it was so easy to opt in or out of animation when the animation button was tapped. Synchronous animation
— 1:49
So now that we understand the basics of SwiftUI animations, let’s see how the Composable Architecture integrates with animation.
— 1:56
Most of our viewers probably already know this, but just in case, the Composable Architecture is a library that we opened sourced in May of 2019 after having built it from first principles in the episodes of Point-Free. It is focused on solving a few core problems that we think any architecture should solve, such as composability, modularity, testability, dependencies and more.
— 2:21
Nearly everything we’ve done so far with animations works just fine with the Composable Architecture, but there are a few rough edges that need to be smoothed out. And in the process of smoothing out those edges we will come across a really amazing application of transforming schedulers.
— 2:37
Let’s start by copying and pasting everything we’ve done so far into a new file and name it AnimationsTCA.swift .
— 2:51
And we’ll rename our view and preview to prefix
TCA 3:02
Next we’ll add the Composable Architecture library to our project.
TCA 3:25
And already we can import the Composable Architecture into our new file: import ComposableArchitecture
TCA 3:33
We can start refactoring this view to use the Composable Architecture by first getting the basic domain in place. This consists of specifying the state that drives the UI, the actions the user can perform in the UI, as well as the environment of dependencies the feature needs to do its job.
TCA 3:49
The state can essentially be copied and pasted from the view: struct AppState { var circleCenter = CGPoint.zero var circleColor = Color.black var isCircleScaled = false }
TCA 4:12
For the actions, there are basically 4 main things the user can do:
TCA 4:21
drag the circle around
TCA 4:30
toggle the scale
TCA 4:39
tap the “cycle colors” button
TCA 4:46
tap the “reset” button We can encode these four actions as four cases of an enum: enum AppAction { case cycleColorsButtonTapped case dragGesture(CGPoint) case resetButtonTapped case toggleScale(isOn: Bool) }
TCA 4:55
The environment holds all of the dependencies for this feature. Typically this includes API clients, analytics clients, databases and more. We’re not going to worry about dependencies for the purpose of this demo, so we’ll just use an empty struct. struct AppEnvironment { }
TCA 5:18
Next we will implement a reducer, which is responsible for gluing together the domain to implement the feature’s logic. It’s a function that takes the current state of the feature, an action that describes what the user is doing, an environment of dependencies, and then ultimately mutates the state to evolve it and returns any effects that the feature wants executed.
TCA 5:39
We can get a stub for this in place by switching on the action: let appReducer = Reducer< AppState, AppAction, AppEnvironment > { state, action, environment in switch action { case .cycleColorsButtonTapped: <#code#> case let .dragGesture(location): <#code#> case .resetButtonTapped: <#code#> case let .toggleScale(isOn): <#code#> } }
TCA 6:15
Some of these are easier to fill out than others. For example, when a drag gesture happens we simply want to update the state’s circleCenter field with the new location: case let .dragGesture(location): state.circleCenter = location return .none
TCA 6:35
And when the scale toggle is flipped we also want to perform a simple state update: case let .toggleScale(isOn): state.isCircleScaled = isOn return .none
TCA 6:51
When the reset button is tapped we want to flip all of the state back to its defaults. Previously, in the vanilla SwiftUI application, we had to individually set each field back to its defaults because we just had a bunch of @State variables: @State var circleCenter = CGPoint.zero @State var circleColor = Color.black @State var isCircleScaled = false
TCA 7:08
There’s no way to set all of these at once. You are forced to set each one individually: self.circleCenter = .zero self.circleColor = .black self.isCircleScaled = false
TCA 7:13
However, with the Composable Architecture our state is modeled as a simple value type, and so it’s quite easy to reset it back to its default state: case .resetButtonTapped: state = AppState() return .none
TCA 7:28
That’s one of the nice things about using value types to model your state rather than depending on @State and @Published property wrappers.
TCA 7:40
Finally we have the action that is executed when the user taps on the “cycle colors” button. This is a bit more complicated than the other actions. Here we want to update the circleColor field of state many times after waiting increasing amounts of time.
TCA 7:56
The only way to perform asynchronous mutations like this in the Composable Architecture is to leverage effects. All mutations that happen in a reducer are synchronous, and happen immediately when the action is sent to the store.
TCA 8:08
The way we do this is to return a bunch of delayed effects from the .cycleColorsButtonTapped case, and each effect will feed a color back into the system so that the state can be updated with that color. Let’s first add a new action to our enum that will act as the receiver of these effects’ output: case setCircleColor(Color)
TCA 8:36
And then we can handle that new action in the reducer: case let .setCircleColor(color): state.circleColor = color return .none
TCA 8:57
So now we just need to create a bunch of effects to pipe the new colors back into the system. We can start in a very straightforward manner by just creating a bunch of effects and combining them together. For example, we could start by immediately setting circleColor to .red and then return an effect to set the color to .blue after one second: state.circleColor = .red return Effect(value: AppAction.setCircleColor(.blue)) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .eraseToEffect()
TCA 10:19
That takes care of two steps of the color cycle. To layer on another step we could merge in another effect that emits after 2 seconds: state.circleColor = .red return .merge( Effect(value: AppAction.setCircleColor(.blue)) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .eraseToEffect(), Effect(value: AppAction.setCircleColor(.green)) .delay(for: .seconds(2), scheduler: DispatchQueue.main) .eraseToEffect() )
TCA 10:52
However, there’s something better. Rather than having to do the arithmetic manually to increase the delay duration we can instead concatenate the effects rather than merging. Merging causes the effects to all run at the same time, but concatenating makes sure that one effect is started only after the previous completes: state.circleColor = .red return .concatenate( Effect(value: AppAction.setCircleColor(.blue)) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .eraseToEffect(), Effect(value: AppAction.setCircleColor(.green)) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .eraseToEffect() )
TCA 11:15
And now it’s straightforward to fill in the rest of the colors we want to cycle through: state.circleColor = .red return .concatenate( Effect(value: AppAction.setCircleColor(.blue)) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .eraseToEffect(), Effect(value: AppAction.setCircleColor(.green)) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .eraseToEffect(), Effect(value: AppAction.setCircleColor(.purple)) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .eraseToEffect(), Effect(value: AppAction.setCircleColor(.black)) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .eraseToEffect() )
TCA 11:30
We can clean this up quite a bit just as we did for the DispatchQueue version. We can map over an array of colors we want to set, and constructed delayed effects for each color: import Combine … state.circleColor = .red return .concatenate( [ Color.blue, .green, .purple, .black ] .enumerated() .map { index, color in Effect(value: .setCircleColor(color)) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .eraseToEffect() } )
TCA 12:13
This is a little bit simpler than what we had to do with dispatch queues previously. Since we are concatenating effects, there’s no need to do the arithmetic for delay durations. We can just use a one second delay for all of the effects, so that’s nice.
TCA 12:32
We now have all of the feature’s logic implemented, only thing left to do is integrate the logic into the view. We can start by getting rid of all the @State in the view and replacing it with a store that holds onto AppState and can be sent AppAction s: struct TCAContentView: View { let store: Store<AppState, AppAction> // @State var circleCenter = CGPoint.zero // @State var circleColor = Color.black // @State var isCircleScaled = false … }
TCA 13:01
This breaks a bunch of stuff in the view because we no longer have those fields on the struct. In order to get access to the state inside a store we must construct a ViewStore . This allows us to observe state changes so that the view automatically updates when actions are sent into the system. It also helps us chisel away the state in the store to the bare essentials of what the view needs so that you don’t re-compute the view’s body too many times.
TCA 13:28
The easiest way to introduce a ViewStore is to use the special WithViewStore SwiftUI view: var body: some View { WithViewStore(self.store) { viewStore in … } }
TCA 13:52
Now we can actually access the state inside the store. Most of these compiler errors are easy to fix by just changing the self. to viewStore. , such as all of the modifiers on the circle shape: Circle() .fill(viewStore.circleColor) .overlay(Text("Hi")) .frame(width: 50, height: 50) .scaleEffect(viewStore.isCircleScaled ? 2 : 1) .offset( x: viewStore.circleCenter.x - 25, y: viewStore.circleCenter.y - 25 ) .gesture( … ) .foregroundColor(viewStore.isCircleScaled ? .red : nil)
TCA 14:13
In the .gesture modifier we are trying to mutate the view store’s circleCenter value directly, and that is not allowed: .gesture( DragGesture(minimumDistance: 0).onChanged { value in withAnimation(.spring(response: 0.3, dampingFraction: 0.1)) { self.circleCenter = value.location } } )
TCA 14:19
The only way to make changes to the state in the store is to send it actions. So instead of performing the mutation here we can send the corresponding action: // self.circleCenter = value.location viewStore.send(.dragGesture(value.location))
TCA 14:36
Now we seem to have an error where we construct the ViewStore , and that’s because AppState must be Equatable : struct AppState: Equatable { … }
TCA 14:50
The next error we have is where we construct the binding for isCircleScaled and animate its changes before passing it along to the Toggle view: Toggle( "Scale", isOn: self.$isCircleScaled.animation( .spring(response: 0.3, dampingFraction: 0.1) ) )
TCA 14:59
Again, we cannot construct such bindings directly because in the Composable Architecture we are not allowed to perform mutations, only send actions. So the library comes with a helper for constructing bindings that just send actions under the hood rather than perform mutations: Toggle( "Scale", isOn: viewStore.binding( get: \.isCircleScaled, send: AppAction.toggleScale(isOn:) ) // self.$isCircleScaled.animation( // .spring(response: 0.3, dampingFraction: 0.1) // ) )
TCA 15:34
This binding is like any other binding you encounter, so we can also tack on the .animation method to make its changes animate: Toggle( "Scale", isOn: viewStore.binding( get: \.isCircleScaled, send: AppAction.toggleScale(isOn:) ) .animation(.spring(response: 0.3, dampingFraction: 0.1)) // self.$isCircleScaled )
TCA 15:48
Next we have the mutation happening inside the “Cycle colors” button. We no longer want to perform any of this logic in the view, and instead we can just fire off an action and let the reducer handle this complexity: Button("Cycle colors") { withAnimation(.linear) { viewStore.send(.cycleColorsButtonTapped) } // [ // Color.red, .blue, .green, .purple, .black // ] // .enumerated() // .forEach { index, color in // DispatchQueue.main.asyncAfter( // deadline: .now() + .seconds(index) // ) { // withAnimation { // self.circleColor = color // } // } // } }
TCA 16:19
That cleans up a bunch of code, which is nice.
TCA 16:23
And finally we have the reset button, which can also send an action rather than performing a bunch of mutations: Button("Reset") { withAnimation { viewStore.send(.resetButtonTapped) // self.circleCenter = .zero // self.circleColor = .black // self.isCircleScaled = false } }
TCA 16:42
Now most of this file is compiling, we just have the preview to fix which can be done by constructing a store and providing it an initial state, reducer and environment: struct TCAContentView_Previews: PreviewProvider { static var previews: some View { TCAContentView( store: Store( initialState: AppState(), reducer: appReducer, environment: AppEnvironment() ) ) } }
TCA 17:18
When we run the preview we will see that dragging and scaling the circle works just as before, with a springy animation. Also resetting the state works, but uses the default animation. If we wanted to go back to resetting with no animation we can just remove the withAnimation block: Button("Reset") { // withAnimation { viewStore.send(.resetButtonTapped) // } }
TCA 17:47
Further, if we remove all withAnimation blocks and go back to putting in some calls to the implicit animation modifiers we will see that everything works as before. Asynchronous animation
TCA 18:10
What we are seeing so far is that SwiftUI animations in the Composable Architecture mostly work just as they do in a vanilla SwiftUI application. If you want to animate the state changes that occur from sending an action, you can either use implicit animation modifier or you can just wrap the viewStore.send method in withAnimation and it will all work just fine. Further, if you need to animate the state changes that happen from a binding that was derived from a ViewStore , then you can still just use the .animation() method on Binding and everything should just work.
TCA 18:42
In fact, the main reason that these things “just work” right out of the box is in large part due to some of the design decisions we made when designing the Composable Architecture.
TCA 18:51
If you’ve watched the many hours of episodes we have on building the Composable Architecture from scratch, then you will know we strive to add as little to the base library as possible, and instead focusing on small, reusable operators that allow you to solve larger problems by piecing together smaller things.
TCA 19:19
This is why we focused on operators like pullback on reducers and scope on stores, and made them as ergonomic as possible. Those units are so composable that most problems can be solved with those tools, rather than trying to bake the solution of every problem directly into the definition of Reducer and Store .
TCA 19:32
As a concrete example, when we first released the library a lot of people recommended that we force send to be called on the main thread inside the Store so that we don’t have to worry about accidentally sending actions on non-background threads. That would certainly mean we no longer have to worry about schedulers when performing effects. They could all be free to deliver their output to whatever queue they want since then the store would re-dispatch everything back to the main queue.
TCA 20:03
However, this is a very serious opinion to bake into the library at such a low level. It’s hard to imagine all the repercussions it could have, but there’s one very serious consequence we can see right in our demo app, and that is it would break animations. If the Store forced all actions to be sent on the main thread, then effectively it would mean our viewStore.send code looks something like this: withAnimation(.spring(response: 0.3, dampingFraction: 0.1)) { DispatchQueue.main.async { viewStore.send(.dragGesture(value.location)) } }
TCA 21:06
That is, we are animating the dispatching of .send to the main thread. This means that the .send will not actually happen immediately in the withAnimation closure, instead it happens a moment later. And that means withAnimation will not see any changes made to the state, and hence will not know how to animate anything. By running the preview we can instantly see this breaks the drag animation.
TCA 21:43
So, this makes a great mini-lesson in why we like to design our libraries the way we do. Although baking DispatchQueue.main directly into the store could help with a few use cases, it is ultimately too strong of an opinion to make for everyone using the store. Instead, we’d prefer to lean on external operators that people can use to solve their problems, rather than trying to solve the problem directly in the core types of the library.
TCA 22:11
However, with that said, there’s a problem in our code. If we tap the “Cycle colors” button we will see that it animates from black to red, but then all of the other color changes happen immediately. No animation whatsoever. What is going on? We can plainly see that we are sending the .cycleColorsButtonTapped action wrapped in an animation: Button("Cycle colors") { withAnimation { viewStore.send(.cycleColorsButtonTapped) } } So, what’s the deal. Why is only the first color change animating and the others are not?
TCA 22:46
Well, if we look in the reducer we will remind ourselves that when that action is sent we immediately perform a single state color, setting the color to .red , and then we fire off a bunch of delayed effects: case .cycleColorsButtonTapped: state.circleColor = .red return .concatenate( [Color.blue, .green, .purple, .black].map { color in Effect(value: .setCircleColor(color)) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .eraseToEffect() } )
TCA 23:00
Those delayed effects are happening much later, and so their changes to state can’t possibly be animated by the withAnimation we are doing over in the view because that only captures the state changes that happen right at that moment. The delayed effects change state much later. We would need to somehow wrap those later mutations in a withAnimation . But how can we do that?
TCA 23:27
Remember last time that withAnimation has that property of it returning a value: withAnimation: (() -> R) -> R
TCA 23:51
So maybe all we need to do is wrap the actions that are packaged up in the effect in one of these withAnimation continuations: Effect(value: withAnimation { .setCircleColor(color) }) So then perhaps when this action is emitted it will be wrapped in the withAnimation block?
TCA 24:05
Unfortunately, if we run previews there still is no animation when cycling colors. This is because the state is still not being mutated inside the withAnimation block. The only thing happening in the block is an action is being returned, and so as far as withAnimation is concerned there is nothing to animate.
TCA 24:26
Again, this is because we still are not performing any state mutation in the withAnimation block. How can we dig even deeper so that we get access to the exact moment that state is mutated?
TCA 24:31
Let’s quickly remind ourselves of the full end-to-end lifecycle of how an action is sent into the store, how that can cause effects to be performed, and then how those effects can be fed back into the store.
TCA 24:44
We can hop over to the Store.swift file in the Composable Architecture library and in there we will find an internal function called send : func send(_ action: Action) { … }
TCA 24:54
It is internal because users of the library never actually invoke this method. Instead they send actions into the system via ViewStore s, which are the actual observable objects that can be used in the view.
TCA 25:09
The body of this method is somewhat long, and it’s a bit more complex than what we covered in our episodes in the past because it’s handling a lot more edge cases, such as recursively sending actions and ordering simultaneous actions. However, the most important parts of this method is the spot where we mutate state by running the reducer: let effect = self.reducer(&self.state.value, action)
TCA 25:34
This is the work that is actually captured by the withAnimation block and animated.
TCA 25:50
The reducer returns the effects we want to run, which just a few lines later we .sink on to execute: let effectCancellable = effect.sink( … )
TCA 25:57
And whenever this effect emits a value we send it back into the system with these lines: receiveValue: { [weak self] action in if isProcessingEffects { self?.synchronousActionsToSend.append(action) } else { self?.send(action) } }
TCA 26:04
I suppose one silly thing we could is wrap this self?.send in an animation block: withAnimation { self?.send(action) }
TCA 26:20
That would certainly animate things, but it would also animate everything…always. This would be hugely problematic. Not only because it would be very inefficient to always be animating everything, but also there are times we will not want to animate anything, and further this doesn’t even allow us to customize the animation. We will be stuck with the default. We couldn’t use a springy animation if we wanted to.
TCA 26:53
This is reminiscent of the comments we made a moment ago about baking DispatchQueue.main directly in the store. Just as that was too opinionated to put directly into the store, so is this.
TCA 26:59
So, what other option do we have? Let’s look deeper into the full context of how this code is executed. We’ll put a breakpoint on the line where send is invoked in the effect and run our view in a simulator.
TCA 27:42
Now when we tap the “Cycle colors” button we get caught at the breakpoint, and the stack trace looks something like this: Thread 1 Queue : com.apple.main-thread (serial) #0 0x000000010debcd95 in closure #2 in Store.send(_:) at Sources/ComposableArchitecture/Store.swift:274 … Enqueued from com.apple.main-thread (Thread 1) Queue : com.apple.main-thread (serial) #0 0x000000010e2edcbe in dispatch_after () #1 0x00007fff54f50417 in OS_dispatch_queue.asyncAfter(wallDeadline:qos:flags:execute:) () #2 0x00007fff54f55ba1 in OS_dispatch_queue.schedule(after:tolerance:options:_:) () #3 0x00007fff54f55df0 in protocol witness for Scheduler.schedule(after:tolerance:options:_:) in conformance OS_dispatch_queue () #4 0x00007fff4ba9ee1b in Publishers.Delay.Inner.schedule(immediate:work:) () …
TCA 28:06
There’s something interesting in this stack trace. We can see that we are executing line 274 in Store.swift , which is where our breakpoint is, but we also see that this work is being executed due to work that was previously enqueued from another place in the code. That place is of course the delayed effect.
TCA 28:12
We can even see references to OS_dispatch_queue.asyncAfter , which performs the actual delay, and that was invoked from OS_dispatch_queue.schedule , which is the protocol witness to the corresponding requirement in the Scheduler protocol: Scheduler.schedule . So it seems that when we hit our breakpoint we are in the same callstack as code that is running in a scheduler.
TCA 28:45
We can get even deeper insight into this callstack if we use a scheduler that we actually have the source to, rather than DispatchQueue , which we can’t step through its source code here. We could create a whole new type that conforms to the Scheduler protocol and just calls out to DispatchQueue under the hood, but a quicker way to get the same effect would be to use one of the schedulers that ships with our combine-schedulers library, which the Composable Architecture depends on.
TCA 29:12
We can simply wrap the DispatchQueue.main in our AnyScheduler type, which is a type that erases all information from a scheduler value except for the bare minimum of the scheduler’s interface: Effect(value: .setCircleColor(color)) .delay( for: .seconds(1), scheduler: AnyScheduler(DispatchQueue.main) ) .eraseToEffect()
TCA 29:19
There’s also a method form for this initializer to make it look a little more similar to other Combine code: Effect(value: .setCircleColor(color)) .delay( for: .seconds(1), scheduler: DispatchQueue.main.eraseToAnyScheduler() ) .eraseToEffect()
TCA 29:26
Now when we run the app in the simulator, cycle the colors and hit the breakpoint we get something a little bit more interesting from the enqueued stack trace: Enqueued from com.apple.main-thread (Thread 1) Queue : com.apple.main-thread (serial) #0 0x000000010cddbcbe in dispatch_after () #1 0x00007fff54f50417 in OS_dispatch_queue.asyncAfter(wallDeadline:qos:flags:execute:) () #2 0x00007fff54f55ba1 in OS_dispatch_queue.schedule(after:tolerance:options:_:) () #3 0x00007fff54f55df0 in protocol witness for Scheduler.schedule(after:tolerance:options:_:) in conformance OS_dispatch_queue () #4 0x000000010ca0d9a2 in implicit closure #2 in implicit closure #1 in AnyScheduler.init<A>(_:) at Sources/CombineSchedulers/AnyScheduler.swift:165 #5 0x000000010ca0f211 in partial apply for implicit closure #2 in implicit closure #1 in AnyScheduler.init<A>(_:) () #6 0x000000010ca0e197 in AnyScheduler.schedule(after:tolerance:options:_:) at Sources/CombineSchedulers/AnyScheduler.swift:177
TCA 29:42
We still see a lot of the same DispatchQueue and scheduler stuff, but now we also have some stack frames showing up that come from the CombineSchedulers package. In particular, we see that the scheduler(after:) method on AnyScheduler is invoked: /// Performs the action at some time after the specified date. public func schedule( after date: SchedulerTimeType, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void ) { self._scheduleAfterToleranceSchedulerOptionsAction( date, tolerance, options, action ) }
TCA 30:06
The action closure passed to this function is literally the receiveValue closure that we were just looking at over in Store.swift . Seriously, this closure is none other than this block of code: receiveValue: { [weak self] action in if isProcessingEffects { self?.synchronousActionsToSend.append(action) } else { self?.send(action) } }
TCA 30:23
Which is precisely the code we want to wrap in a withAnimation block! If we could just further tap into how that action closure is executed in the AnyScheduler then we could wrap its invocation in a withAnimation , which then should allow us to directly animate the state mutation.
TCA 30:42
Luckily this is quite straightforward to do. We can create a new AnyScheduler from scratch that for the most part just forwards its requirements to DispatchQueue.main , but for the specific endpoint that schedules work after a delay we can further transform the action closure by wrapping it in withAnimation .
TCA 30:57
In order to create an AnyScheduler there are a bunch of arguments to fill in: let mainQueue = AnyScheduler( minimumTolerance: <#() -> _.Stride#>, now: <#() -> _#>, scheduleImmediately: <#(_?, @escaping () -> Void) -> Void#>, delayed: <#(_, _.Stride, _?, @escaping () -> Void) -> Void#>, interval: <#(_, _.Stride, _.Stride, _?, @escaping () -> Void) -> Cancellable#> )
TCA 31:12
To fill in the minimumTolerance argument we can open up a closure and invoke the corresponding property on DispatchQueue.main : minimumTolerance: { DispatchQueue.main.minimumTolerance },
TCA 31:21
And similarly for the now argument: now: { DispatchQueue.main.now },
TCA 31:26
The scheduleImmediately argument is what is invoked when a scheduler is being asked to invoke a unit of work immediately. This is not called in our current application because we aren’t invoking work immediately. We are doing it after a delay. So we will just pass along DispatchQueue.main ’s method that handles this: scheduleImmediately: DispatchQueue.main.schedule,
TCA 31:59
Similarly the interval endpoint is also not called in our application because we are not dealing with any recurring timers. So we will also pass along DispatchQueue.main ’s method that handles this: interval: DispatchQueue.main.schedule
TCA 32:05
The delayed endpoint is the one that is actually called in our application. It’s what is invoked by the effect to schedule some work for later, and it is handed a bunch of information: delayed: { duration, tolerance, options, action in },
TCA 32:21
The action parameter is the most important. It’s the unique of work that will actually be performed on the scheduler, which in this case is the work inside the receiveValue closure back in our store.
TCA 32:31
In here we can invoke the corresponding method on DispatchQueue.main : delayed: { after, tolerance, options, action in DispatchQueue.main.schedule( after: after, tolerance: tolerance, options: options, action ) },
TCA 32:46
Currently this would behave no differently than had we used DispatchQueue.main instead of doing this AnyScheduler dance. The real magic happens when we decide to open up the trailing closure for this .schedule method and decide to do something different: delayed: { after, tolerance, options, action in DispatchQueue.main.schedule( after: after, tolerance: tolerance, options: options ) { } },
TCA 32:59
In here we want to invoke the action closure, which again remember is the contents of the receiveValue closure in the store, which is what sends the effect’s output back into the store, thus mutating state. So, sounds like we should wrap action in withAnimation : delayed: { after, tolerance, options, action in DispatchQueue.main.schedule( after: after, tolerance: tolerance, options: options ) { withAnimation { action() } } },
TCA 33:10
That should make state mutations while still inside the withAnimation scope, and so we would hope that causes those state changes to animate in the view.
TCA 33:24
Let’s test this out by sticking this mainQueue scheduler in our reducer code that returns the delayed effects: Effect(value: .setCircleColor(color)) .delay( for: .seconds(1), scheduler: mainQueue ) .eraseToEffect()
TCA 33:34
Now when we run our SwiftUI preview we see that the colors are back to cycling with animation.
TCA 33:41
So this is pretty amazing. We’ve been able to animate state changes that result from asynchronous effects in a completely non-invasive manner. What we’ve done didn’t even require any changes to the Composable Architecture library. It can all be driven off of Combine’s scheduler machinery. It gives us the perfect opportunity to tap into the exact moment state is changed from actions that are sent via effects. Ergonomic animation
TCA 34:24
But we’re not quite done yet. It would be a bit of a pain if we had to construct AnyScheduler s from scratch anytime we wanted to inject an animation into the effect.
TCA 34:35
Luckily we can create an operator on schedulers that allows you to instantly enhance any existing scheduler with the functionality that it executes its work in an animation block.
TCA 34:51
This operator can be represented as a method on the Scheduler protocol that returns an AnyScheduler : import Combine extension Scheduler { func animation() -> AnyScheduler< Self.SchedulerTimeType, Self.SchedulerOptions > { } }
TCA 35:45
Rather than passing the associated types to AnyScheduler directly we can also use a handy typealias we have, AnySchedulerOf , which infers the generics from a scheduler type: extension Scheduler { func animation() -> AnySchedulerOf<Self> { } }
TCA 36:01
In here we need to return an AnyScheduler , and so we have to fill in all of these arguments: extension Scheduler { func animation() -> AnyScheduler<Self.SchedulerTimeType, Self.SchedulerOptions> { .init( minimumTolerance: <#() -> SchedulerTimeIntervalConvertible & Comparable & SignedNumeric#>, now: <#() -> Strideable#>, scheduleImmediately: <#(Self.SchedulerOptions?, @escaping () -> Void) -> Void#>, delayed: <#(Strideable, SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, Self.SchedulerOptions?, @escaping () -> Void) -> Void#>, interval: <#(Strideable, SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, Self.SchedulerOptions?, @escaping () -> Void) -> Cancellable#> ) } }
TCA 36:14
The first two arguments can just call down to self : minimumTolerance: { self.minimumTolerance }, now: { self.now },
TCA 36:22
The next argument can also call out to self , but it will replace the action passed along to be a new closure that wraps the existing action in a withAnimation block: scheduleImmediately: { options, action in self.schedule(options: options) { withAnimation { action() } } },
TCA 36:45
We can do the same thing for the last two arguments: delayed: { after, tolerance, options, action in self.schedule( after: after, tolerance: tolerance, options: options ) { withAnimation { action() } } }, interval: { after, interval, tolerance, options, action in self.schedule( after: after, interval: interval, tolerance: tolerance, options: options ) { withAnimation { action() } } }
TCA 37:38
Right now we are just using the default animation in all of these schedule endpoints, but remember that the first argument of withAnimation allows you to specify what kind of animation you perform: withAnimation(<#Animation?#>) { action() }
TCA 37:51
This is where you get to specify easing animations or springy animations, and even specify the duration of the animation. We’d probably like to be able to tell our scheduler which kind of animation to use.
TCA 38:01
This is easy enough to do. We can add an Animation value as an argument to the .animation() method: func animation( _ animation: Animation? = .default ) -> AnySchedulerOf<Self> { … }
TCA 38:17
With this value being passed in we can now use it to control the type of animation: 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) } }
TCA 38:24
We have marked this as optional with a default of .default because this is the signature used in SwiftUI: public func withAnimation<Result>( _ animation: Animation? = .default, _ body: () throws -> Result ) rethrows -> Result
TCA 38:48
With this transformation at our disposal we can now get rid of the ad hoc mainQueue scheduler we built from scratch a moment ago: // let mainQueue = AnyScheduler( // minimumTolerance: { DispatchQueue.main.minimumTolerance }, // now: { DispatchQueue.main.now }, // scheduleImmediately: DispatchQueue.main.schedule, // delayed: { after, tolerance, options, action in // DispatchQueue.main.schedule( // after: after, // tolerance: tolerance, // options: options // ) { // withAnimation(.linear(duration: 1)) { // action() // } // } // }, // interval: DispatchQueue.main.schedule // )
TCA 38:58
And instead we can transform DispatchQueue.main into something that animates its work: Effect(value: .setCircleColor(color)) .delay( for: .seconds(1), scheduler: DispatchQueue.main.animation(.linear) ) .eraseToEffect()
TCA 39:14
If we run the preview we will see that everything still works as before, but now we have the ability to transform any scheduler into one that animates in any kind of way. We could even do something silly like have the color change animate on odd indices but not animate on even indices: [ Color.blue, .green, .purple, .black ] .enumerated() .map { index, color in Effect(value: .setCircleColor(color)) .delay( for: .seconds(1), scheduler: DispatchQueue.main.animation( index.isMultiple(of: 2) ? nil : .linear ) ) .eraseToEffect() }
TCA 40:13
Not only can animate asynchronous effects but we can be very precise in how each effect is individually animated.
TCA 40:20
While we’re improving the ergonomics of how we work with effects by introducing an animation operator on schedulers, I think we can also introduce a helper on the store than makes it easier to send actions with animations.
TCA 40:33
Currently, when we send actions to the store and want them to animate, we need to wrap the entire thing in a withAnimation block: withAnimation(.spring(response: 0.3, dampingFraction: 0.1)) { viewStore.send(.dragGesture(value.location)) }
TCA 40:42
It would be nicer to be able to tell the store to send an action with an animation: viewStore.send( .dragGesture(value.location), animation: .spring(response: 0.3, dampingFraction: 0.1) )
TCA 40:54
This allows us to flatten things a bit and be very explicit that this action should animate changes to state.
TCA 41:03
To do this, we can extend the ViewStore with a new overload of send that takes an animation: extension ViewStore { func send(_ action: Action, animation: Animation) { } }
TCA 41:24
And under the hood we can do the work we were doing before: extension ViewStore { func send(_ action: Action, animation: Animation) { withAnimation(animation) { send(action) } } }
TCA 41:40
Things are already building again, and we can run our preview and things work the same.
TCA 41:58
Let’s clean up another call to send . Where we previously would need to wrap send in a withAnimation block we can now simply tack on an animation argument: // withAnimation { // viewStore.send(.resetButtonTapped) // } viewStore.send(.resetButtonTapped, animation: .default)
TCA 42:18
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. Next time: what’s the point?
TCA 42:56
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.
TCA 43:18
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 go a little deeper.
TCA 43:39
We are going to explore two additional facets of animations and schedulers.
TCA 43:44
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.
TCA 44:09
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.
TCA 44:33
Let’s get started…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 0136-swiftui-animation-pt2 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 .