Refresh SwiftUI views

gualtierofr

Gualtiero Frigerio

Posted on August 2, 2021

Refresh SwiftUI views

A nice addition to SwiftUI at WWDC21 is the new refreshable modifier to refresh view contents. This new feature is powered by maybe the biggest announcement at WWDC21: the new async/await pattern introduced in Swift 5.5.
While this isn’t an article about this specific topic is important to know that what we’re going to see here is powered by it, thus is not backwards compatible with older versions of SwiftUI.

One of the features iOS users love it the ability to pull down a list of content to refresh it. Up to this year, there wasn’t a first party API to implement this functionality, and if you’re interested in a solution compatible with the first version of SwiftUI you can check out my article about pull down to refresh.
We finally have that official API, is a modifier called refreshable and I’m going to show you how to use it on a List, and how to apply it to a custom view. As usual the code is available on GitHub
If you want to add the refreshable scroll view to your project via SwiftPM you can use this repository.

Refresh a List

Applying the new modifier to a List is straightforward

List(posts) { post in
    PostView(post: post)
}
.refreshable {
    await refreshListAsync()
}
Enter fullscreen mode Exit fullscreen mode

you add the modifier .refreshable and you provide a function to refresh the content inside the closure.
As I told you, this feature is powered by async/await, so we need the new await keyword before the function call.
Let’s have a look at the declaration of refreshable

func refreshable(action: @escaping () async -> Void) -> some View
Enter fullscreen mode Exit fullscreen mode

the action we are providing (our closure in the example above) is marked as async, that’s the reason why we need await. I haven’t written an article about async/await yet, but you’ll find plenty of them if you want to understand exactly what is going on.
For now, this is all you need to do to implement pull down to refresh with the new modifier. That’s because List implements the whole functionality and is able to display an indicator and hide it once the reload function (the one you implemented in the closure) ends.

Refresh a custom view

All right, refreshing a List is really easy but you may wonder how to use the new modifier in your view. Maybe you cannot use a List and you have an array of elements you display in a ForEach, the good news is that with a few more lines of code you can implement the same feature.
I’m going to show you a simple example from the repository I linked before, starting with my custom ScrollView implementing pull down to refresh. See the code here

struct ScrollViewPullRefresh<Content:View>: View {
    @Environment(\.refresh) var refreshAction: RefreshAction?

    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }

    var body: some View {
        VStack {
            if isRefreshing {
                ProgressView()
            }
        }
        GeometryReader { geometry in
            ScrollView {
                content()
                    .anchorPreference(key: OffsetPreferenceKey.self, value: .top) {
                        geometry[$0].y
                    }
            }
            .onPreferenceChange(OffsetPreferenceKey.self) { offset in
                if offset > threshold && isRefreshing == false {
                    if let action = refreshAction {
                        Task {
                            isRefreshing = true
                            await action()
                            withAnimation {
                                isRefreshing = false
                            }
                        }
                    }
                }
            }
        }
    }

    private var content: () -> Content
    @State private var isRefreshing = false
    private let threshold = 50.0
}
Enter fullscreen mode Exit fullscreen mode

this is the entire implementation. I won’t explain how to actually implement the pull down to refresh part, the onPreferenceChange modifier I applied to a ScrollView. Please refer to my previous article to find out more. In a nutshell, when the scroll view offset goes over a defined threshold I can perform an action, in this case the async action configured by the caller.
But how do I now what action to execute?
Alongside the refreshable modifier SwiftUI introduced a new environment value, called refresh

@Environment(\.refresh) var refreshAction: RefreshAction?
Enter fullscreen mode Exit fullscreen mode

by referring to this value, we can call the async function passed to refreshable.
What is RefreshAction? Let’s take a look at its definition

public struct RefreshAction {
    ...
    public func callAsFunction() async
}
Enter fullscreen mode Exit fullscreen mode

if you’re not familiar with callAsFunction, it is a way to treat objects as functions. In this case, you can call refreshAction() and execute the code passed to the closure of refreshable. It is not importa to know that, but I pasted the definition in case you were curious 🙂
Ok let’s get back to our example. We have refreshAction, so we know what to call when the user pulls down the ScrollView.

.onPreferenceChange(OffsetPreferenceKey.self) { offset in
    if offset > threshold && isRefreshing == false {
        if let action = refreshAction {
            Task {
                isRefreshing = true
                await action()
                withAnimation {
                    isRefreshing = false
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

his is the modifier. It is important to keep a @State variable to know whether we’re executing the refresh operation, otherwise we’d continue to call the async function as the user pulls down.
If we’re not refreshing and the action is set, we can call the async function.
What is Task? I won’t go into details, but tt is a way to execute asynchronous code inside a synchronous context. In this case we set the isRefreshing value to true, then we execute the async function (so we need the await keyword) and then set the refreshing value to false. I use withAnimation so the ProgressView disappears with an animation, otherwise you’d see the ScrollView jump at the top.

Let’s see how to refresh our custom ScrollView, full implementation here.

var body: some View {
    ScrollViewPullRefresh {
        VStack {
            ForEach(posts) { post in
                PostView(post: post)
            }
        }
    }
    .task {
        posts = await getPosts()
    }
    .refreshable {
        posts = await shufflePosts()
    }
}
Enter fullscreen mode Exit fullscreen mode

As you see I added the refreshable modifier and inside I assign the posts variable the result of an async function to simply shuffle the posts. Let’s take a look at it

private func shufflePosts() async -> [Post] {
    await Task.sleep(2_000_000_000)
    return viewModel.allPosts.shuffled()
}
Enter fullscreen mode Exit fullscreen mode

In order to simulate a network call, I use Task.sleep. As the name suggest, the function does nothing and sleeps for the given amount of microseconds. I find it useful to experiment with async await without setting up an API to retrieve data, you can simply have a JSON inside the project and await for the result.

Before ending, you may have noticed the .task modifier I applied to the custom view before refreshable.
This is another addition to SwiftUI, and gives us the ability to perform an asynchronous task when the view appears. In this example I load the list of posts when the view appears, and refresh them every time the user pulls down to refresh.
Of course you can implement alternative ways to refresh a list of contents. For example you can use a Button, like this

struct RefreshableView: View {
    @ObservedObject var viewModel: ListViewModel
    @Environment(\.refresh) var refreshAction: RefreshAction?

    var body: some View {
        VStack {
            Button {
                if let action = refreshAction {
                    Task {
                        await action()
                    }
                }
            } label: {
                Image(systemName: "arrow.counterclockwise")
            }
            ScrollView {
                ForEach(viewModel.beers) { beer in
                    HStack {
                        CustomImageView(url: URL(string: beer.imageUrl),
                                        placeHolder: Image(systemName: "xmark.octagon"))
                            .frame(width: 100, height: 100)
                        Text(beer.name)
                        Spacer()
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

and call the async function inside the Button’s action, wrapping it with Task.

That’s it, I really like the new API and hope you’ll find it easy to adopt in your projects.
Happy coding 🙂

Original article

💖 💪 🙅 🚩
gualtierofr
Gualtiero Frigerio

Posted on August 2, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related