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:
- refreshable-scrollview-example.swift
- refreshable-scrollview-model.swift
- refreshable-scrollview-implementation.swift
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…
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
Hi Zhouhua, it may have been a temporary issue on github.com, I just checked and the links are working fine.
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
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.
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. π
Thank you!
It doesn’t appear to work on iOS 14 (beta 2).
Thanks for letting me know, I’ll investigate as soon as I have some time.
Working great on iOS 14 beta 4! Thank you for this fantastic implementation of pull to refresh.
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.
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.
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!!
Having the exact same issue. Pull to refresh and also a paging list view…
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γ
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
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).
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))`
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?
Hi! Sorry can’t give an estimate, I have a backlog that is preventing me from reviewing this article.
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:
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.
this seem to broke in iOS 17, the content is not scrolling anymore, the moving view seems to be stuck for some reason.
very sluggish with LazyVStack on iOS17 beta.