Articles, podcasts and news about Swift development, by John Sundell.

Making SwiftUI views refreshable

Published on 24 Jun 2021
Discover page available: SwiftUI

At WWDC21, Apple introduced a new SwiftUI API that enables us to attach refresh actions to any view, which in turn now gives us native support for the very popular pull-to-refresh mechanism. Let’s take a look at how this new API works, and how it also enables us to build completely custom refreshing logic as well.

Powered by async/await

To be able to tell when a refresh operation was completed, SwiftUI uses the async/await pattern, which is being introduced in Swift 5.5 (and is expected to be released alongside Apple’s new operating systems later this year). So before we can start adopting the new refreshing API, we’ll need an async-marked function that we can call whenever our view’s refresh action will be triggered.

As an example, let’s say that we’re working on an app that includes some form of bookmarking feature, and that we’ve built a BookmarkListViewModel that’s responsible for providing our bookmark list UI with its data. To then enable that data to be refreshed, we’ve added an asynchronous reload method, which in turn calls a DatabaseController in order to fetch an array of Bookmark models:

class BookmarkListViewModel: ObservableObject {
    @Published private(set) var bookmarks: [Bookmark]
    private let databaseController: DatabaseController
    ...

    func reload() async {
        bookmarks = await databaseController.loadAllModels(
            ofType: Bookmark.self
        )
    }
}

Now that we have an async function that can be called to refresh our view’s data, let’s apply the new refreshable modifier within our BookmarkList view — like this:

struct BookmarkList: View {
    @ObservedObject var viewModel: BookmarkListViewModel

    var body: some View {
        List(viewModel.bookmarks) { bookmark in
            ...
        }
        .refreshable {
    await viewModel.reload()
}
    }
}

Just by doing that, our List-powered UI will now support pull-to-refresh. SwiftUI will automatically hide and show a loading spinner as our refresh action is being performed, and will even ensure that no duplicate refresh actions are being performed at the same time. Really cool!

As an added bonus — given that Swift supports first class functions — we can even pass our view model’s reload method directly to the refreshable modifier, which gives us a slightly more compact implementation:

struct BookmarkList: View {
    @ObservedObject var viewModel: BookmarkListViewModel

    var body: some View {
        List(viewModel.bookmarks) { bookmark in
            ...
        }
        .refreshable(action: viewModel.reload)
    }
}

That’s really all there is to it when it comes to basic pull-to-refresh support. But that’s just the beginning — let’s keep exploring!

Error handling

When it comes to loading actions, it’s very common that those can end up throwing an error, which we’ll need to handle one way or another. For example, if the underlying loadAllModels API that our view model calls was a throwing function, then we’d have to call it using the try keyword in order to handle any error that could be thrown. One way to do that would be to simply propagate any such errors to our view, by making our top-level reload method capable of throwing as well:

class BookmarkListViewModel: ObservableObject {
    ...

    func reload() async throws {
        bookmarks = try await databaseController.loadAllModels(
            ofType: Bookmark.self
        )
    }
}

However, with the above change in place, our previous BookmarkList view code no longer compiles, since the refreshable modifier only accepts non-throwing async closures. To fix that we could, for example, wrap the call to our view model’s reload method in a do/catch statement — which would let us catch any thrown errors in order to display them using something like an ErrorView overlay:

struct BookmarkList: View {
    @ObservedObject var viewModel: BookmarkListViewModel
    @State private var error: Error?

    var body: some View {
        List(viewModel.bookmarks) { bookmark in
            ...
        }
        .overlay(alignment: .top) {
            if error != nil {
    ErrorView(error: $error)
}
        }
        .refreshable {
            do {
    try await viewModel.reload()
    error = nil
} catch {
    self.error = error
}
        }
    }
}

The reason that our ErrorView accepts a binding to an error, rather than just a plain Error value, is because we want that view to be able to dismiss itself by setting our error property to nil. To learn more, check out my guide to SwiftUI’s state management system.

While the above implementation does work, it would arguably be better to encapsulate all of our view state (including any thrown errors) within our view model, which would allow our view to focus on just rendering the data that our view model gives it. To make that happen, let’s start by moving the above do/catch statement into our view model instead — like this:

class BookmarkListViewModel: ObservableObject {
    @Published private(set) var bookmarks: [Bookmark]
    @Published var error: Error?
    ...

    func reload() async {
        do {
            bookmarks = try await databaseController.loadAllModels(
                ofType: Bookmark.self
            )
            error = nil
        } catch {
            self.error = error
        }
    }
}

With the above change in place, we can now make our view much simpler, since the fact that our reload method can throw errors now sort of becomes an implementation detail of our view model. All that our view now needs to know is that there’s an error property that it can use to display any error that was encountered (for any reason):

struct BookmarkList: View {
    @ObservedObject var viewModel: BookmarkListViewModel

    var body: some View {
        List(viewModel.bookmarks) { bookmark in
            ...
        }
        .overlay(alignment: .top) {
            if viewModel.error != nil {
                ErrorView(error: $viewModel.error)
            }
        }
        .refreshable {
            await viewModel.reload()
        }
    }
}

Very nice. But perhaps the most interesting aspect of this new refreshable modifier is that it’s not just limited to the built-in pull-to-refresh functionality that SwiftUI ships with. In fact, we can use it to power our very own, completely custom refreshing logic as well.

Custom refreshing logic

To be able to more easily build custom refreshing features, let’s start by creating a dedicated class that’ll perform our refresh actions. When passed a system-provided RefreshAction value, it’ll set an isPerforming property to true while the action is being performed, which in turn will enable us to observe that piece of state within any custom refreshing UIs that we’re looking to build:

class RefreshActionPerformer: ObservableObject {
    @Published private(set) var isPerforming = false

    func perform(_ action: RefreshAction) async {
        guard !isPerforming else { return }
        isPerforming = true
        await action()
        isPerforming = false
    }
}

Next, let’s build a RetryButton that will enable our users to retry a given refresh action if it ended up failing. To do that, we’ll use the new refresh environment value, which gives us access to any RefreshAction that was injected into our view hierarchy using the refreshable modifier. We’ll then pass any such action to an instance of our newly created RefreshActionPerformer — like this:

struct RetryButton: View {
    var title: LocalizedStringKey = "Retry"
    
    @Environment(\.refresh) private var action
    @StateObject private var actionPerformer = RefreshActionPerformer()

    var body: some View {
        if let action = action {
            Button(
                role: nil,
                action: {
                    await actionPerformer.perform(action)
                },
                label: {
                    ZStack {
                        if actionPerformer.isPerforming {
                            Text(title).hidden()
                            ProgressView()
                        } else {
                            Text(title)
                        }
                    }
                }
            )
            .disabled(actionPerformer.isPerforming)
        }
    }
}

Note how we’re rendering a hidden version of our label while our loading spinner is being displayed. That’s to prevent the button’s size from changing as it transitions between its idle and loading states.

The fact that SwiftUI inserts our refresh actions into the environment is incredibly powerful, as that lets us define a single action that can then be picked up and used by any view that’s within that particular view hierarchy. So, without making any changes to our BookmarkList view, if we now simply insert our new RetryButton into our ErrorView, then it’ll be able to perform the exact same refreshing action as our List uses — simply because that action exists within our view hierarchy’s environment:

struct ErrorView: View {
    @Binding var error: Error?

    var body: some View {
        if let error = error {
            VStack {
                Text(error.localizedDescription)
                    .bold()
                HStack {
                    Button("Dismiss") {
                        self.error = nil
                    }
                    RetryButton()
                }
            }
            .padding()
            .background(Color.red)
            .foregroundColor(.white)
            .cornerRadius(10)
        }
    }
}

How cool isn’t that? I love when Apple places data like this in the SwiftUI environment, and makes it publicly accessible, since that opens up so many powerful ways to build custom UIs and logic, like I think the above example shows.

Conclusion

So that’s the new refreshable modifier and how it can both be used to implement system-provided UI patterns (like pull-to-refresh), and how we can also use it to build completely custom reloading logic as well.

I hope you found this article useful, and if you have any questions, comments, or feedback, then feel free to reach out via either Twitter or email. And, if you did find this article useful, then please share it with a friend, since that really helps support me and my work.

Thanks for reading!