Making SwiftUI views refreshable
Discover page available: SwiftUIAt 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!