ScrollView – Pull to Refresh

At the time of writing, ScrollView in SwiftUI is not very customizable. A feature many may be missing, is the ability to refresh its contents when the view is pulled. Fortunately, using view preferences, we can manage to add such behaviour.

Once we finished coding our new view, using refreshable ScrollViews will be very easy, and they will look like this:

To apply this behavior, we’ll need to use RefreshableScrollView instead of ScrollView directly. We will have a boolean binding variable to indicate if the model is loading more data. The RefreshableScrollView will change the binding to true, when the view needs refreshing. And the model will put it back to false, when the refresh has ended.

RefreshableScrollView(refreshing: self.$model.loading) {                
    // Scrollable contents go here
    ...
}
class MyModel: ObservableObject {
    @Published var loading: Bool = false {
        didSet {
            if oldValue == false && loading == true {                
                // Do async stuff here
                ...

                // When finished loading (must be done on the main thread)
                self.loading = false
            }
        }
    }
}

RefreshableScrollView Implementation

Here I will include only the most relevant parts of the code. However, the full implementation can be found in the following gist files:

In order to implement the refresh control, we first need to find a way of obtaining the scroll offset of our ScrollView. This is where View Preferences come handy. If you are not familiar with View Preferences, I recommend you read the “Inspecting the View Tree” series.

In order to get the offset, we will place two invisible views. One will be at the top of the scrolling contents (MovingView()), and the other fixed at the top of the ScrollView (FixedView()). The difference in their Y position, will be the scrolled offset.

struct RefreshableScrollView<Content: View>: View {
    
    ...
    
    var body: some View {
        return VStack {
            ScrollView {
                ZStack(alignment: .top) {
                    MovingView()
                    
                    VStack { self.content }.alignmentGuide(.top, computeValue: { d in (self.refreshing && self.frozen) ? -self.threshold : 0.0 })
                    
                    SymbolView(height: self.threshold, loading: self.refreshing, frozen: self.frozen, rotation: self.rotation)
                }
            }
            .background(FixedView())
            .onPreferenceChange(RefreshableKeyTypes.PrefKey.self) { values in
                self.refreshLogic(values: values)
            }
        }
    }

    ...
}

These views serve only one purpose, setting a preference with their own position:

struct MovingView: View {
    var body: some View {
        GeometryReader { proxy in
            Color.clear.preference(key: RefreshableKeyTypes.PrefKey.self, value: [RefreshableKeyTypes.PrefData(vType: .scrollViewTop, bounds: proxy.frame(in: .global))])
        }.frame(height: 0)
    }
}
struct FixView: View {
    var body: some View {
        GeometryReader { proxy in
            Color.clear.preference(key: RefreshableKeyTypes.PrefKey.self, value: [RefreshableKeyTypes.PrefData(vType: .scrollViewContainer, bounds: proxy.frame(in: .global))])
        }
    }
}

Note that it should have been possible to achieve the same result, using a single view and calling the proxy(.frame(in: .named()). Unfortunately, there seems to be a bug with named coordinate spaces at the moment, and that is why we need two separate reference views.

Refreshing Feedback

We will check the scroll offset to determine wether we have crossed a certain threshold beyond the top. If we have, a content refresh will trigger. We also want to show an animated arrow that give us some feedback on how far from the triggering point we are. And once we have reached it, replace the arrow with an Activity Indicator. The arrow and activity indicator are implemented inside SymbolView:

struct SymbolView: View {
    var height: CGFloat
    var loading: Bool
    var frozen: Bool
    var rotation: Angle
    
    
    var body: some View {
        Group {
            if self.loading { // If loading, show the activity control
                VStack {
                    Spacer()
                    ActivityRep()
                    Spacer()
                }.frame(height: height).fixedSize()
                 .offset(y: -height + (self.loading && self.frozen ? height : 0.0))
            } else {
                Image(systemName: "arrow.down") // If not loading, show the arrow
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: height * 0.25, height: height * 0.25).fixedSize()
                    .padding(height * 0.375)
                    .rotationEffect(rotation)
                    .offset(y: -height + (loading && frozen ? +height : 0.0))
            }
        }
    }
}

All the logic to calculate when the threshold is crossed, and how much the arrow must be rotated, is done in the .onPreferenceChange() closure:

// Calculate scroll offset
let movingBounds = values.first { $0.vType == .movingView }?.bounds ?? .zero
let fixedBounds = values.first { $0.vType == .fixedView }?.bounds ?? .zero
self.scrollOffset  = movingBounds.minY - fixedBounds.minY
self.rotation = self.symbolRotation(self.scrollOffset)

// Crossing the threshold on the way down, we start the refresh process
if !self.refreshing && (self.scrollOffset > self.threshold && self.previousScrollOffset <= self.threshold) {
    self.refreshing = true
}
if self.refreshing {
    // Crossing the threshold on the way up, we add a space at the top of the scrollview
    if self.previousScrollOffset > self.threshold && self.scrollOffset <= self.threshold {
        self.frozen = true
    }
} else {
    // remove the sapce at the top of the scroll view
    self.frozen = false
}
// Update last scroll offset
self.previousScrollOffset = self.scrollOffset

Also note that we are using .alignmentGuide() on the ScrollView contents, to displace them temporarily, while the refresh is in progress.

Summary

In this article we have found yet another application of using View Preferences. I encourage you to get familiar with the full code in the gist files, if you are planning on adding that code to your own project.

Please feel free to comment below, and follow me on twitter to be notified when new articles are release. Until then…

23 thoughts on “ScrollView – Pull to Refresh”

  1. Some thing may be wrong, the three file can’t be found on github, please take a look, thanks.
    refreshable-scrollview-example.swift
    refreshable-scrollview-model.swift
    refreshable-scrollview-implementation.swift

    Reply
  2. Thank you for this article and your great website.

    I understand how to get the scrolled offset. But, how can I set programaticaly a new position for the scrollview ?
    Thank’s

    Reply
    • For the time being, SwiftUI does not seem to provide a native method to scroll programmatically. If you need such functionality, you may need to consider using UIViewRepresentable/UIViewControllerRepresentable.

      Reply
  3. Thank you for this great article!
    It shows, again how useful preferences can be, and how skilful you are at grasping how concepts can be applied.
    Your articles take a different approach from the other SwiftUI tutorials, really inspired me to study more SwiftUI. πŸ‘

    Reply
  4. This is great code and mostly works the way I want, but I’ve added in a LazyVStack in order to use .onAppear() with the bottom of the scrollview to trigger a load more function, and it totally throws off the pull to refresh functionality, leaving a very choppy interface, if not an infinite loop somewhere that I haven’t found yet.

    Can we not have both pull to refresh and infinite scrolling together as one? It’s sad that this wasn’t built into SwiftUI from the beginning.

    Reply
    • I did not try that. It is in my plans to review this article, and see if the code can be improve to take advantage of the new additions in WWDC2020.

      Reply
      • Admittedly, I hadn’t paid much attention to the original date of the article. This is still easily the best implementation I’ve come across so far. It seems like you’ve got this figured out way better than I do, so here’s wishing you luck updating the code. I’ll be watching for any updates. Thanks much!!

        Reply
  5. It’s a very good attempt. i have make a Pull up to refresh depend on some of originality in your code . The only small problem that affects the usage is that if you pull down the screen and push it back to the top without releasing your hand, the content will be blocked once by the aglimentguide setting of it。

    Reply
  6. Javier,
    This is really fantastic article and implementation of the Refresh view. I have a slight problem when I embed a Navigation view inside the RefreshScrollableView. The Arrow image is displayed constantly, even without doing anything. Tried to debug but could not pin point the problem. This however works perfectly if I don’t use a Navigation View. Any ideas?

    -Ravi

    Reply
    • I don’t have any pointers at the moment. This article was written back in 2019. In my to-do list I would like to revisit this and see if it can be improved with the WWDC2020 additions to the SwiftUI framework (specifically matchedGeometryEffect).

      Reply
    • I have this working perfectly inside a NavigationView, although with several changes.
      My main change was to update the opacity inside `refreshLogic()` which makes the arrow fade in when pulled
      `@State private var opacity: Double = 0`
      `self.opacity = Double((self.scrollOffset – self.threshold * 0.1)/(self.threshold * 0.4))`

      Reply
  7. Nice implementation of pull-to-refresh. However, I think it should be refactored to use the new ScrollViewReader?

    By the way, I’m getting this message on the console “Bound preference PrefKey tried to update multiple times per frame.”

    Any estimate when you’ll update this implementation?

    Reply
  8. Thanks for your inspiration, this is a clever way using pure SwiftUI concepts.

    One little bug: if you *set* the (initial) refreshing to true, this will not be visible on screen (iOS 14.3, Xcode 12.3). One thing is to add “self.frozen = .init(initialValue: refreshing.wrappedValue)” in the init as this satisfies the preview, but in an app, setting refreshing to true will still not work (probably due to the GeometryReader life cycles).

    Sample code to show the problem:

    
    struct ContentView: View {
        @State var refresh = false
    
        var body: some View {
            VStack {
                Text("Sticky header")
                    .padding()
                RefreshableScrollView(refreshing: $refresh) {
                    VStack {
                        Text("Hello World")
                        Button(action: { refresh.toggle() }, label: {
                            Text("Toggle refresh")
                        }).padding()
                        Text("Hello again ")
                    }
                }
            }
        }
    }
    
    Reply
  9. Any thoughts on using a similar technique to build a custom pull-to-refresh for Lists?

    I have a need to use the client’s design language for their pull-refresh control, which currently means sticking with UIKit. I could use this “spy view” technique to add my own refreshing behavior to a non-refreshable list, but I can’t figure out a reliable place to insert the Moving View so that it ends up a known distance from the FixedView.

    List is way too magical. Too much stuff all together.

    Reply

Leave a Comment

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close