Pull to Refresh in SwiftUI
Deprecation warning
2021-06-11: Please be aware that with the introduction of iOS 15 and the SwiftUI additions for 2021, this functionality is build into SwiftUI: refreshable(action:)
Yesterday I wrote an article about creating a CircularProgressView which could show the progress. Today I wanted to write about how you could use that ProgressViewStyle
in creating a ScrollView with pull to request functionality.
I wanted to create this pull to refresh functionality after reading about tracking the scroll offset in the ScrollView
.
Using a PreferenceKey to track offset in ScrollView
To track the offset in the ScrollView we need to introduce a PreferenceKey
.
private struct OffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
Which we can then use to track the offset from a GeometryReader
in the background of a ScrollView
.
ScrollView(.vertical) {
content
.background(
GeometryReader { geometry in
Color.clear
.preference(
key: OffsetPreferenceKey.self,
value: geometry.frame(in: .named("pullToRefresh")).origin.y
)
}
)
}
.coordinateSpace(name: "pullToRefresh")
.onPreferenceChange(OffsetPreferenceKey.self, perform: { _ in })
The content
in the code above comes from a @ViewBuilder
that we will initialize our view with. To track the offset, the next step is to implement the function that is triggered by .onPreferenceChange()
.
Show indicator when refresh will be triggered
To show the user how much further he has to move the ScrollView
to trigger a refresh, we can use the ProgressView
in an overlay with the CircularWithValueProgressViewStyle
. The value of the ProgressView
can be stored in a @State
variable. This way we can update the value based on the change in our OffsetPreferenceKey
.
@State private var progress: Double = .zero
ScrollView(.vertical) {
// ...
}
.overlay(ProgressView(value: progress).progressViewStyle(CircularWithValueProgressViewStyle()), alignment: .top)
.onPreferenceChange(OffsetPreferenceKey.self, perform: { offset in
progress = // ... Calculate based on offset
})
Creating a PullToRefeshView
The new view we are creating should act similar to a ScrollView
. We can achieve that by using a @ViewBuilder
to provide the content to the view.
We also want to make sure that the PullToRefeshView
does not know about any of the logic to actually refresh. We can do this by adding a callback function to our view. The callback will be called once the user has triggered the refresh. The callback also should provide another function as a parameter that allows the containing view to signal that it is done.
PullToRefreshView() {
Text("Pull to Refresh")
} onRefresh: { done in
// Here refresh action can be implemented
// And should call `done()` once it has completed
}
To implement this in a view introduces a new @State
variable to store whether the view is refreshing.
For the done
function we want to return on the onRefresh
, we can use a typealias
to make it a bit easier to read. Both functions are @escaping
because it can perform asynchronous work.
struct PullToRefreshView<Content: View>: View {
typealias DoneFunction = () -> Void // Make it easier to type the return function
private let onRefresh: (@escaping DoneFunction) -> Void
private let content: Content
@State private var isRefreshing = false
@State private var progress: Double = .zero
init(
@ViewBuilder content: () -> Content,
onRefresh: @escaping (@escaping DoneFunction) -> Void
) {
self.onRefresh = onRefresh
self.content = content()
}
var body: some View {
ScrollView(.vertical) {
content
// ...
}
.onPreferenceChange(OffsetPreferenceKey.self, perform: onOffsetChange)
}
private func onOffsetChange(offset: CGFloat) {
progress = calculateProgress(from: offset)
if !isRefreshing && offset > 100 {
isRefreshing = true
// Here we call back to the provided onRefresh function
// The 1 argument we pass is what the done function in `onRefresh: { done in`
onRefresh({
isRefreshing = false
}
}
}
private func calculateProgress(from offset: CGFloat) -> Double {
if isRefreshing || offset >= 100 {
return 1
} else if offset <= 0 {
return 0
} else {
return Double(offset / 100)
}
}
Finalizing the view
In the above code most of the functionality is there, but to finish up we can add a a few features:
- Minimum distance: To only show the progress indicator once a certain threshold has been passed
- Haptic feedback: To let the user know that the view has started with refreshing
- Offset the
ScrollView
: To make sure that theProgressView
does not overlap with the content of theScrollView
we should add some offset to the top
PullToRefreshView
The final code for our PullToRefreshView
will then look like this.
private struct OffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
struct PullToRefreshView<Content: View>: View {
typealias DoneFunction = () -> Void // Make it easier to type the return function
private let refreshDistance: CGFloat = 100
private let offsetWhileLoading: CGFloat = 36
private let coordinateSpaceName = "pullToRefresh"
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
private let minimumDistance: CGFloat
private let onRefresh: (@escaping DoneFunction) -> Void
private let content: Content
@State private var isRefreshing = false
@State private var progress: Double = .zero
init(
minimumDistance: CGFloat = 10,
@ViewBuilder content: () -> Content,
onRefresh: @escaping (@escaping DoneFunction) -> Void
) {
self.minimumDistance = minimumDistance
self.onRefresh = onRefresh
self.content = content()
}
var body: some View {
ScrollView(.vertical) {
content
.background(
GeometryReader { geometry in
Color.clear
.preference(
key: OffsetPreferenceKey.self,
value: geometry.frame(in: .named(coordinateSpaceName)).origin.y
)
}
)
.offset(y: isRefreshing ? offsetWhileLoading : 0)
}
.coordinateSpace(name: coordinateSpaceName)
.overlay(ProgressView(value: progress).progressViewStyle(CircularWithValueProgressViewStyle()), alignment: .top)
.onPreferenceChange(OffsetPreferenceKey.self, perform: onOffsetChange)
}
private func onOffsetChange(offset: CGFloat) {
DispatchQueue.main.async {
progress = calculateProgress(from: offset)
if !isRefreshing && offset > refreshDistance {
withAnimation(.easeOut) {
isRefreshing = true
}
feedbackGenerator.impactOccurred()
onRefresh({
withAnimation {
isRefreshing = false
}
})
}
}
}
private func calculateProgress(from offset: CGFloat) -> Double {
if isRefreshing || offset >= refreshDistance {
return 1
} else if offset <= minimumDistance {
return 0
} else {
return Double(offset / refreshDistance)
}
}
}
The following view was used to create the above GIF.
struct ContentView: View {
static private let initialNames = ["Jarl", "Ehecatl", "Jayanti", "Surendra", "Medeia"]
@State var names = ContentView.initialNames
var body: some View {
PullToRefreshView() {
Text("Names").font(.system(.largeTitle, design: .rounded))
.padding()
Divider()
ForEach(names, id: \.self) { name in
Text(name).padding()
}
} onRefresh: { done in
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: {
withAnimation {
names.insert(contentsOf: ["Nephele", "Vesta"], at: 0)
}
done()
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: {
withAnimation {
names = ContentView.initialNames
}
})
})
}
}
}