SwiftUI gets better and better every year, and in WWDC 2021 we heard about some great new additions and improvements. One such addition that this post is focusing on is the pull to refresh control; a missing UI element familiar from the UITableView in UIKit, but not available in the first two versions of SwiftUI.
Putting the pull to refresh functionality in action is pretty easy in SwiftUI. We only have to apply a specific view modifier on a List view, and keep one or two things in mind, but crucial to the app’s proper behavior. Let’s go through them.
Using pull to refresh
Consider the following simple example, where we have a list with country names and their flags:
For the sake of the demonstration here, the data is retrieved from a local, custom JSON file that contains a few European countries with URLs to their flag images. Even though there are just a few countries listed initially, the purpose is to add a new country to the top every time we pull to refresh the list.
In order to achieve that, we firstly need a source of truth for the data that we’ll present in the list:
1 2 3 |
@ObservedObject var countriesModel = CountriesModel() |
CountriesModel
is an ObservableObject type that implements all the necessary logic that makes the specific example work. There is also an auxiliary type, called CountryData
that keeps the actual data for a single country; its name, the URL to the flag image, as well as a unique identifier, because it conforms to the Identifiable protocol.
Note: You can download the demo project of this post to find out all implementation details.
The following snippet creates the actual list:
1 2 3 4 5 |
List(countriesModel.countries, id: \.id) { country in CountryView(country: country) } |
CountryView
is a SwiftUI view that contains an AsyncImage view and a Text view that display the data in each row.
In order to enable the pull to refresh functionality, we’ll apply a brand new view modifier to the List; it’s called refreshable()
:
1 2 3 4 5 6 |
List(countriesModel.countries, id: \.id) { … } .refreshable { } |
The closure’s body is the place to call any code that will update the list’s datasource, and therefore update the displayed elements:
1 2 3 4 5 6 |
List(countriesModel.countries, id: \.id) { … } .refreshable { countriesModel.updateCountries() } |
With this small addition only, the list can now be refreshed when pulling it downwards. Obviously, the actual operation that will update the data and will trigger a UI update is totally dependent on the app. However, the above snippet clearly demonstrates you to deal with the refreshable()
view modifier.
The result of the above is shown right next:
Update on background but perform UI changes on main thread
In the “What’s new in SwiftUI” session of WWDC 2021, the demonstrated code makes use of the new concurrency tools made available this year (async/await
). Of course, it’s not mandatory to use that with the refreshable()
modifier, however there is one thing to keep in mind:
You should always update the data after a pull to refresh action on the background, leaving the main thread free so the app remains responsive. Use the main thread only to update the source of truth that feeds the list with the displayed data.
In order to make that more clear, let’s take a look at the implementation of the updateCountries()
method used in the previous section:
1 2 3 4 5 6 7 |
func updateCountries() { guard allCountries.count > 0, nextCountryIndex < allCountries.count else { return } countries.insert(allCountries[nextCountryIndex], at: 0) nextCountryIndex += 1 } |
What happens in the above three lines is simple:
- At first we make sure that the
allCountries
array that contains all available countries loaded from JSON file is not empty, and that the pointer to the next country to show is within the proper range. - The next available country is added to the
countries
array as its first element.countries
property is marked with the @Published property wrapper and constitutes the source of truth for the data displayed on the list. - The pointer to the next country is increased by one.
That piece of code has little real value out of the scope of this post. Disregarding that fact though, it’s obvious that this code is not being executed on any background thread. On the other hand, knowing that we have just a fistful of data already loaded into the memory, then reasonably we don’t expect any delay when updating the displayed countries.
Let’s change that, and let’s use an unorthodox way to prove the point here. Let’s add a for-in
loop that will perform 1000000 repetitions before the countries
array gets updated:
1 2 3 4 5 6 7 8 9 10 11 12 |
func updateCountries() { guard allCountries.count > 0, nextCountryIndex < allCountries.count else { return } for _ in 0..<1000000 { continue } countries.insert(allCountries[nextCountryIndex], at: 0) nextCountryIndex += 1 } |
By running the app again now, you can see that it remains unresponsive for a few moments:
That (totally unnecessary) loop is running on the main thread, so the UI freezes until the execution of the above method is finished. This is a totally unaccepted behavior for the app, and a really bad user experience.
To fix that, let’s move everything into a background queue:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
func updateCountries() { DispatchQueue.global().async { guard self.allCountries.count > 0, self.nextCountryIndex < self.allCountries.count else { return } for _ in 0..<1000000 { continue } self.countries.insert(self.allCountries[self.nextCountryIndex], at: 0) self.nextCountryIndex += 1 } } |
By running the app once again a new problem comes up:
What the message says is that we’re publishing updates from a background thread, but that’s something not allowed. countries
property, marked with the @Published property, is a publisher and the source of truth for the list’s data. So, in practical words, countries
should be updated and therefore publish new values on the main thread; always.
With that in mind, it’s necessary to include the execution of the last two lines in the main thread:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
func updateCountries() { DispatchQueue.global().async { guard self.allCountries.count > 0, self.nextCountryIndex < self.allCountries.count else { return } for _ in 0..<1000000 { continue } DispatchQueue.main.async { self.countries.insert(self.allCountries[self.nextCountryIndex], at: 0) self.nextCountryIndex += 1 } } } |
This time the list is refreshed properly. See next that even though there is a small delay until the next country appears (because of the loop), the UI remains totally responsive.
Summary
Adding a pull to refresh control to a SwiftUI list in order to update the displayed data is pretty simple. However, it’s important to remember that the update should not block the main thread and the UI under any circumstances. That’s something that I hope I managed to showcase through the simple, and probably not so elegant example above, but undoubtedly into the point. In any case, thanks for reading!
Note: You may download the demo project from this link.
This post has also been published on Medium.