Pull to Refresh Control and One Time Data Fetch Task in SwiftUI

iOS 15 introduced two new APIs to simplify data load and refresh actions. These APIs will allow users to trigger the data refresh on request and allow one-time data load execution on the very first run - For example, when the view has been loaded in the memory for the first time. Let's take a look at each of them.

Pull to Refresh Control

There are many use-cases where you're showing some data to users and it keeps updating in the background. Eventually, this data becomes stale after some period of time. What's an intuitive way to force trigger the data refresh, and update the UI? The answer is adding pull to refresh control.

SwiftUI introduced a new API called refreshable which refreshes the underlying data when users pull the list to refresh (It is automatically built-in for lists on iPhone and iPad). refreshable API takes a closure as an argument and any action that needs to be executed can be placed inside the closure.

For example, imagine you're showing the list of random images on the first app load. This data becomes stale after some time. You can put a function call to re-request the download and update the list in the closure provided by this API.

Let's take a look at the example. I have an app that has a list of Photo objects where each object stores the id and the URL of the image. I have a list that stores the Photo objects and shows the in the list using AsyncImage (Also introduced in iOS 15) and ListAPIs.


struct Photo: Identifiable {
    let id: String
    let url: URL?
}

struct ContentView: View {
    
    @State private var photos: [Photo] = []
    
    init() {
        for i in 0...10 {
            photos.append(Photo(id: String(i), url: URL(string: "https://picsum.photos/200/300")))
        }
    }
    
    var body: some View {
        List(photos) { photo in
            AsyncImage(url: photo.url) { image in
                image.aspectRatio(contentMode: .fit)
            } placeholder: {
                Color.red
            }.frame(maxWidth: .infinity).padding(.vertical, 10)
        }
    }
}

0:00
/

However, the product manager comes to me with one more requirement that, this list of photos is ever-changing and we need a way to reflect those changes in the list without having to go and come back on this screen. Here, we will use the refreshable API thereby passing the closure which will automatically call the function to update the list of new photos in the photos array.

To simplify things, we will load photos array with an initial list of photos in the initializer and load the new phots in additional populatePhotos method which gets called on request when pull to refresh action is triggered.


struct ContentView: View {
    
    @State private var imageCounter: Int
    @State private var photos: [Photo] = []

    init() {
        
        var tempPhotos: [Photo] = []

        for i in 0...10 {
            tempPhotos.append(Photo(id: String(i), url: URL(string: "https://picsum.photos/200/300")))
        }
        
        _photos = State(initialValue: tempPhotos)
        _imageCounter = State(initialValue: tempPhotos.count)
    }
    
    var body: some View {
        List(photos) { photo in
            AsyncImage(url: photo.url) { image in
                image.aspectRatio(contentMode: .fit)
            } placeholder: {
                Color.red
            }
            .frame(maxWidth: .infinity)
            .padding(.vertical, 10)
        }.refreshable {
            self.photos = populatePhotos()
        }
    }
    
    func populatePhotos() -> [Photo] {
        var tempPhotos: [Photo] = []
        for _ in 0...10 {
            imageCounter += 1
            tempPhotos.append(Photo(id: String(imageCounter), url: URL(string: "https://picsum.photos/200/300")))
        }
        return tempPhotos
    }
}

💡
Please note that as we are updating the photos list, we are also updating the unique id associated with each Photo object. This is because id is used to uniquely identify each object while populating List view. If there is no chance in ids, the list won't be refreshed. Always make sure to assign an id that is unique to a given object.

If I run the app now, I can pull to refresh the list of images,

0:00
/

One-Time Data Fetch Task

In the last section, we looked at the API that allows users to refresh the list on request. In this section, we are going to take a look at the opposite API which loads the list just once per every view lifetime.

This API is called a task modifier. This API lets us attach the async task to the lifetime of the view no matter how many times that view re-appears on the scene.

The task is kicked off once the view loads and is automatically removed when the view is removed from the rendering tree. Let's use this API and refactor the above code to load the first batch of photos.


struct ContentView: View {
    
    @State private var imageCounter: Int
    @State private var photos: [Photo]

    init() {
        _photos = State(initialValue: [])
        _imageCounter = State(initialValue: 0)
    }
    
    var body: some View {
        List(photos) { photo in
            AsyncImage(url: photo.url) { image in
                image.aspectRatio(contentMode: .fit)
            } placeholder: {
                Color.red
            }
            .frame(maxWidth: .infinity)
            .padding(.vertical, 10)
        }.task {
            self.photos = populateInitialBatch()
            self.imageCounter = self.photos.count
        }.refreshable {
            self.photos = populatePhotos()
        }
    }
    
    func populateInitialBatch() -> [Photo] {
        var tempPhotos: [Photo] = []

        for i in 0...10 {
            tempPhotos.append(Photo(id: String(i), url: URL(string: "https://picsum.photos/200/300")))
        }
        return tempPhotos
    }
    
    func populatePhotos() -> [Photo] {
        var tempPhotos: [Photo] = []
        for _ in 0...10 {
            imageCounter += 1
            tempPhotos.append(Photo(id: String(imageCounter), url: URL(string: "https://picsum.photos/200/300")))
        }
        return tempPhotos
    }
}

We will also highlight the changed code during this refactor,

If I run the app now, there is seemingly no change in the external behavior. The only change I did is, moved the initial data load function from the view initializer to the task modifier closure.

0:00
/
And that's all! Your app is all set to support one-time load tasks and pull to refresh functionality with just a few code changes

Summary

It's a welcome change in the SwiftUI framework that we got two data load-related APIs in one iOS release. One-time data load tasks are quite useful when you want to execute the data load task just once per lifetime and you cannot put it into the initializer due to other limitations. (Such as calling self when self might not have been initialized before the instance has been properly initialized).

It's also great to see a pull to refresh integration with just a few lines of code. I remember the pain we had to go through while integrating it into the UIKit. As Apple is moving towards a async future, it's no surprise that they both allow async task, and they indirectly implying to developers to use the async APIs for data load purposes.

It's been a year since the iOS 15 release. Are you already using or planning to use these APIs in your app? If you're already using them in the production app, how is your experience dealing with them? Please let me know on Twitter @jayeshkawli or send your feedback by using the feedback form on the Contact Me page.

References:

What’s new in SwiftUI - WWDC21 - Videos - Apple Developer
There’s never been a better time to develop your apps with SwiftUI. Discover the latest updates to the UI framework — including lists,...