Async Animations in SwiftUI

Async Animations in SwiftUI

·

7 min read

SwiftUI comes with some convenient animation capabilities out of the box. Using the withAnimation function, any interaction that triggers a state update will be animated. This allows us to add some great UI experiences to our apps with minimal code.

As we make use of the withAnimation function on more complex views that require async operations however, we'd likely come across scenarios where the animation is out of sync or does not animate at all.

To better understand why that is, let's walk through putting together an animated experience and adjusting it to work in an async context.

Setting the scene

Let’s assume we have a screen that allows a user to pin movies from a suggested list.

async-animations.png

As the user pins a movie, we’d like that movie item to animate out of the “Suggestions” section into the “Pinned” section.

Let’s start off with building this interaction using the view’s local State:

import SwiftUI

struct MovieList: View {
    @State
    var pinned: [Movie] = [
        Movie(title: "Inception", year: "2010")
    ]
    @State
    var suggestions: [Movie] = [
        Movie(title: "Arrival", year: "2016"),
        Movie(title: "Edge of Tomorrow", year: "2014"),
        Movie(title: "Palm Springs", year: "2020")
    ]
    var body: some View {
        NavigationView {
            List {
                Section(header: Text("Pinned")) {
                    ForEach(pinned) { movie in
                        ListItem(movie: movie) {
                            Image(systemName: "xmark")
                                .imageScale(.small)
                        } onTap: {
                            withAnimation {
                                pinned.removeAll(where: { $0.id == movie.id})
                            }
                        }
                    }
                }
                Section(header: Text("Suggestions")) {
                    ForEach(suggestions) { movie in
                        ListItem(movie: movie) {
                            Image(systemName: "pin.fill")
                                .imageScale(.small)
                                .foregroundColor(.white)
                                .padding(8)
                                .background(Color.accentColor)
                                .cornerRadius(3.0)
                        } onTap: {
                            withAnimation {
                                suggestions.removeAll(where: { $0.id == movie.id })
                                pinned.append(movie)
                            }
                        }

                    }
                }
            }
            .listStyle(InsetGroupedListStyle())
            .navigationTitle("Movies")
        }
    }
}

extension MovieList {
    struct ListItem<Label: View>: View {
        let movie: Movie
        let label: Label
        let onTap: () -> Void
        init(movie: Movie,
             @ViewBuilder label: () -> Label,
             onTap: @escaping () -> Void) {
            self.movie = movie
            self.label = label()
            self.onTap = onTap
        }
        var body: some View {
            HStack {
                Image(systemName: "film")
                    .imageScale(.medium)
                    .padding()
                    .overlay(
                        Circle()
                            .stroke(Color.secondary)
                    )
                VStack(alignment: .leading, spacing: 0) {
                    Text(movie.title)

                    Text(movie.year)
                        .foregroundColor(.secondary)
                        .padding(.top, 5)
                }
                .padding(.leading)
                Spacer()
                Button(action: onTap) {
                    label
                }
                .buttonStyle(PlainButtonStyle())

            }
            .padding()
        }
    }
}

Running the view in live preview mode, we will notice that applying our state updates within a withAnimation closure will automatically animate our views using some attractive default animations.

animation-async.gif

Async interactions

Let’s assume the act of pinning is a bit more complex and requires some server logic to persist the change. We’ll wrap that logic into a view model to make It more modular and testable.

extension MovieList {

    class ViewModel: ObservableObject {
        @Published
        var pinned: [Movie] = [
            Movie(title: "Inception", year: "2010")
        ]
        @Published
        var suggestions: [Movie] = [
            Movie(title: "Arrival", year: "2016"),
            Movie(title: "Edge of Tomorrow", year: "2014"),
            Movie(title: "Palm Springs", year: "2020")
        ]
        private var cancellables = Set<AnyCancellable>()

        func pinMovie(_ movie: Movie) {
            doSomeAsyncWork()
                .sink { [unowned self] _ in
                    self.suggestions.removeAll(where: { $0.id == movie.id })
                    self.pinned.append(movie)
                }
                .store(in: &cancellables)

        }

        func unpinMovie(_ movie: Movie) {
            doSomeAsyncWork()
                .sink { [unowned self] _ in
                    self.pinned.removeAll(where: { $0.id == movie.id })
                }
                .store(in: &cancellables)
        }

        private func doSomeAsyncWork() -> AnyPublisher<Void, Never> {
            Deferred {
                Future { promise in
                    Thread.sleep(forTimeInterval: 0.3)
                    promise(.success(()))
                }
            }
            .subscribe(on: DispatchQueue.global())
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
        }
    }
}

Above we've put together a view model that publishes a list of pinned and suggested movies. We've created two methods for pinning and unpinning movies, which prior to making any updates does some async work on a background thread via the doSomeAsyncWork publisher.

Let's update our view to observe our view model instead of a local state:

struct MovieList: View {
    @StateObject
    private var viewModel = ViewModel()
    var body: some View {
        NavigationView {
            List {
                Section(header: Text("Pinned")) {
                    ForEach(viewModel.pinned) { movie in
                        ListItem(movie: movie) {
                            Image(systemName: "xmark")
                                .imageScale(.small)
                        } onTap: {
                            withAnimation {
                                viewModel.unpinMovie(movie)
                            }
                        }
                    }
                }
                Section(header: Text("Suggestions")) {
                    ForEach(viewModel.suggestions) { movie in
                        ListItem(movie: movie) {
                            Image(systemName: "pin.fill")
                                .imageScale(.small)
                                .foregroundColor(.white)
                                .padding(8)
                                .background(Color.accentColor)
                                .cornerRadius(3.0)
                        } onTap: {
                            withAnimation {
                                viewModel.pinMovie(movie)
                            }
                        }

                    }
                }
            }
            .listStyle(InsetGroupedListStyle())
            .navigationTitle("Movies")
        }
    }
}

If we were to run the app and start pinning movies, we'd notice that the change will not animate despite calling our view model’s method within the withAnimation block. This is because updates to the view model’s published property happen after some async logic is executed on a separate Transaction context. As a result, the view isn't able to associate the update with the Transaction we defined in the withAnimation block

If were to time our withAnimation invocation along with the updates after the async operation completes, we’d see that our animations are back to a working state:

extension MovieList {

    class ViewModel: ObservableObject {
        //..

        func pinMovie(_ movie: Movie) {
            doSomeAsyncWork()
                .sink { [unowned self] _ in
                    withAnimation {
                        self.suggestions.removeAll(where: { $0.id == movie.id })
                        self.pinned.append(movie)
                    }
                }
                .store(in: &cancellables)

        }

        func unpinMovie(_ movie: Movie) {
            doSomeAsyncWork()
                .sink { [unowned self] _ in
                    withAnimation {
                        self.pinned.removeAll(where: { $0.id == movie.id })
                    }
                }
                .store(in: &cancellables)
        }

        //..
    }
}

While the above works, animation and UI specific logic seem out of a place within a view model. It is best practice to avoid having any UI layer specific code in our view models, making them more modular and testable.

How can we go about leveraging the power of withAnimation in an async context without leaking it to our view model? There are a couple of approaches

The Optimistic Approach

If we were to view the experience from a user’s point of view, wether or not the view updates occurs prior or after the async work is irrelevant. One might even argue that if feedback for user input is immediate, the end result would be a much more engaging experience.

This is often referred to as optimistic rendering and is quite common in today's applications. The idea behind this approach is that in most cases, the async work should succeed and as a result, there is no reason for a user to wait for async work to complete. In the rare event that an error does occur, the app can handle and revert the experience, notifying the user of the error.

We can leverage optimistic rendering in our view model and update our published properties immediately, maintaining the animation experience:

extension MovieList {

    class ViewModel: ObservableObject {
        // ..

        func pinMovie(_ movie: Movie) {
            self.suggestions.removeAll(where: { $0.id == movie.id })
            self.pinned.append(movie)
            doSomeAsyncWork()
                .sink { _ in
                }
                .store(in: &cancellables)

        }

        func unpinMovie(_ movie: Movie) {
            self.pinned.removeAll(where: { $0.id == movie.id })
            doSomeAsyncWork()
                .sink { _ in

                }
                .store(in: &cancellables)
        }

        // ...
    }
}

We can now move back our withAnimation invocation to our View when calling the view model's methods.

The Local Replication Approach

It’s likely there will be some experiences in or apps where an optimistic approach does not apply. Let’s assume we do want our users to wait till the async operation completes. How can we communicate the update to our view in a way where it would animate?

We could maintain the animation experience if we were to make a replicable copy of our view model’s published properties and map them to our view’s local state. Using the onReceive modifier, our views can react to the updates and apply relevant side effects:

extension MovieList {

    class ViewModel: ObservableObject {
        //..
        func pinMovie(_ movie: Movie) {
            doSomeAsyncWork()
                .sink { [unowned self] _ in
                    self.suggestions.removeAll(where: { $0.id == movie.id })
                    self.pinned.append(movie)
                }
                .store(in: &cancellables)

        }

        func unpinMovie(_ movie: Movie) {
            doSomeAsyncWork()
                .sink { [unowned self] _ in
                    self.pinned.removeAll(where: { $0.id == movie.id })
                }
                .store(in: &cancellables)
        }

        //..
    }
}

struct MovieList: View {
    @StateObject
    private var viewModel = ViewModel()
    @State
    private var pinned: [Movie] = []
    @State
    private var suggestions: [Movie] = []
    var body: some View {
        NavigationView {
            ScrollView {
                LazyVStack {
                    Text("Pinned")
                    ForEach(pinned) { movie in
                        ListItem(movie: movie) {
                            Image(systemName: "xmark")
                                .imageScale(.small)
                        } onTap: {
                            viewModel.unpinMovie(movie)
                        }
                        .id("\(movie.id)-pinned")
                    }
                    Text("Suggestions")
                    ForEach(suggestions) { movie in
                        ListItem(movie: movie) {
                            Image(systemName: "pin.fill")
                                .imageScale(.small)
                                .foregroundColor(.white)
                                .padding(8)
                                .background(Color.accentColor)
                                .cornerRadius(3.0)
                        } onTap: {
                            viewModel.pinMovie(movie)
                        }
                        .id("\(movie.id)-suggested")
                    }
                }
            }
            .navigationTitle("Movies")
        }
        .onReceive(viewModel.$pinned) { pinned in
            withAnimation {
                self.pinned = pinned
            }
        }
        .onReceive(viewModel.$suggestions) { suggestions in
            withAnimation {
                self.suggestions = suggestions
            }
        }
    }
}

Above we created a local state representation of our view model’s pinned and suggested movie publisher properties. Whenever those properties in our view model update, those changes are replicated locally and channeled through a withAnimation closure, resulting in an animated experience.

Note above we switched from a List to a custom LazyVStack as using a List crashes the app due to a SwiftUI bug that is still happening as of iOS 14.6

Conclusion

SwiftUI’s withAnimation function provides a quick and convenient method for adding animatable experiences to our apps' interactions. For more complex views that involve async operations, leveraging those animations can be tricky. In this article we learned a couple of approaches that insure animation updates in an async context are channeled through a single Transaction to maintain an animated experience.

I hope you enjoyed this article and found it useful. If you have any questions, comments or feedback i'd love to hear them. Contact me or follow me on Twitter

Â