Pull down to refresh in SwiftUI

UPDATE: WWDC21 introduced refreshable, a view modifier to implement pull to refresh in a List, see the example at the end. This article is still valid if you want to target iOS 13 and 14 as the new modifier is only iOS 15 compatible.

Pull down to refresh a list is something quite common for an iOS app. We got used to that gesture over the years and I find it a quick and intuitive way to perform the task.
With the increasing adoption of SwiftUI people are looking at ways to implement the same mechanism, and this post is about my implementation of this very gesture.
The code is available on GitHub as usual, I updated an old repository I mentioned in my previous post about lazy loading.
If you’re interested in adding my refreshable scroll view to your project via Swift Package Manager you can use this repository.
I’m going to show you two ways of implementing the same thing, the first putting your content inside a particular view and the second is via a ViewModifier.

RefreshableScrollView

Let’s start with the first approach, putting your content inside a view. I called it RefreshableScrollView and you can find the implementation here.
The view has to be configured with an action, a function called when the user pulls down over a certain threshold (in my example I set 50 pixels). The component doesn’t show a progress view, so you can fully customise that part.


RefreshableScrollView(action: refreshList) {
    if isLoading {
        VStack {
            ProgressView()
            Text("loading...")
        }
    }
    LazyVStack {
        ForEach(posts) { post in
            PostView(post: post)
        }
    }
}

private func refreshList() {
    isLoading = true
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
        isLoading = false
    }
}

The example is very simple, I mimic the reloading of a set of data by calling asyncAfter to wait for a second. You’d likely have to interact with a view model to ask to fetch data again, and if the user pulls while you’re loading you may want to avoid fetching again, but you get the point.
Let’s see how RefreshableScrollView actually works


struct RefreshableScrollView<content:view>: View {
    init(action: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content) {
        self.content = content
        self.refreshAction = action
    }
    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                content()
                    .anchorPreference(key: OffsetPreferenceKey.self, value: .top) {
                        geometry[$0].y
                    }
            }
            .onPreferenceChange(OffsetPreferenceKey.self) { offset in
                if offset > threshold {
                    refreshAction()
                }
            }
        }
    }

    // MARK: - Private

    private var content: () -> Content
    private var refreshAction: () -> Void
    private let threshold:CGFloat = 50.0
}

fileprivate struct OffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}
</content:view>

As you can see I’m wrapping the content inside a ScrollView with a GeometryView.
The ScrollView is necessary to be able to pull down the content.
In my tests I found out that having a List inside a ScrollView doesn’t work, so use ForEach.
GeometryReader is necessary to compute the offset.
In order to get the offset, we need to use a PreferenceKey. You can see I implemented a struct OffsetPreferenceKey for that purpose, we need to provide a defaultValue and reduce, what reduce does in our example is basically keeping the value updated.
In order to use our PreferenceKey and get the offset we have to set .anchorPreference on our content. Unfortunately there isn’t documentation about this method (the notorious no overview available). You don’t need to know the details though, all you have to know is to set this preference and to implement .onPreferenceChange, where you can get the value from GeometryProxy. We asked for the .top value, and we get the y coordinate via subscript. We get a CGPoint, so we have x and y.
If the offset is bigger than the defined threshold, we can call the action to refresh the content.

RefreshableScrollViewModifier

The second approach is a ViewModifier. Internally this view modifier uses RefreshableScrollView


struct RefreshableScrollViewModifier: ViewModifier {
    var action: () -> Void

    func body(content: Content) -> some View {
        RefreshableScrollView(action: action) {
            content
        }
    }
}

and this is how to use it in your view


var body: some View {
    LazyVStack {
        if isLoading {
            ProgressView()
        }
        ForEach(posts) { post in
            PostView(post: post)
        }
    }
    .modifier(RefreshableScrollViewModifier(action: refreshAction))
}

I think I like using the RefreshableScrollView directly more than implementing the modifier, but it is up to you.

refreshable

Apple introduced a new view modifier called refreshable at WWDC21 to provide the pull to refresh animation.
The modifier takes care of implementing the drag gesture and placing an activity indicator above the content of the List, all you have to do is provide a closure with a async function to call to refresh the content.
This is an example


var body: some View {
    VStack {
        Text("there are \(posts.count) posts")
        if #available(iOS 15.0, *) {
            List(posts) { post in
                PostView(post: post)
            }
            .refreshable {
                await refreshListAsync()
            }
        } else {
            List(posts) { post in
                PostView(post: post)
            }
        }
    }
}

My function doesn’t do anything special, is just shuffles the array, but you can put an async function there to retrieve data from the network. The function doesn’t have to be async, but if you provide an async function the indicator should stay there until the operation is finished, otherwise the function is called but the indicator is removed as soon as you complete the drag gesture.

Hope you’ll have fun implementing pull down to refresh in your SwiftUI apps, happy coding 🙂