UPDATE: WWDC21 introduced a new way to support pull to refresh, see the example at the end of the post. My custom implementation is still needed if you want to target iOS 13 and 14
I wrote a new article about pull down to refresh with the new additions.
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.
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()
}
}
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)
}
}
}
}
// the async refresh function
@available(iOS 15.0, *)
private func refreshListAsync() async {
if isRefreshing == false {
isRefreshing = true
self.posts = await shufflePosts(posts)
isRefreshing = false
}
}
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 🙂
Top comments (1)
Great article! Thank you.