Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
// Authoer: The SwiftUI Lab
// Full article: https://swiftui-lab.com/scrollview-pull-to-refresh/
import SwiftUI
struct RefreshableScrollView<Content: View>: View {
@State private var previousScrollOffset: CGFloat = 0
@State private var scrollOffset: CGFloat = 0
@State private var frozen: Bool = false
@State private var rotation: Angle = .degrees(0)
var threshold: CGFloat = 80
@Binding var refreshing: Bool
let content: Content
init(height: CGFloat = 80, refreshing: Binding<Bool>, @ViewBuilder content: () -> Content) {
self.threshold = height
self._refreshing = refreshing
self.content = content()
}
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)
}
}
}
func refreshLogic(values: [RefreshableKeyTypes.PrefData]) {
DispatchQueue.main.async {
// 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
}
}
func symbolRotation(_ scrollOffset: CGFloat) -> Angle {
// We will begin rotation, only after we have passed
// 60% of the way of reaching the threshold.
if scrollOffset < self.threshold * 0.60 {
return .degrees(0)
} else {
// Calculate rotation, based on the amount of scroll offset
let h = Double(self.threshold)
let d = Double(scrollOffset)
let v = max(min(d - (h * 0.6), h * 0.4), 0)
return .degrees(180 * v / (h * 0.4))
}
}
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))
}
}
}
}
struct MovingView: View {
var body: some View {
GeometryReader { proxy in
Color.clear.preference(key: RefreshableKeyTypes.PrefKey.self, value: [RefreshableKeyTypes.PrefData(vType: .movingView, bounds: proxy.frame(in: .global))])
}.frame(height: 0)
}
}
struct FixedView: View {
var body: some View {
GeometryReader { proxy in
Color.clear.preference(key: RefreshableKeyTypes.PrefKey.self, value: [RefreshableKeyTypes.PrefData(vType: .fixedView, bounds: proxy.frame(in: .global))])
}
}
}
}
struct RefreshableKeyTypes {
enum ViewType: Int {
case movingView
case fixedView
}
struct PrefData: Equatable {
let vType: ViewType
let bounds: CGRect
}
struct PrefKey: PreferenceKey {
static var defaultValue: [PrefData] = []
static func reduce(value: inout [PrefData], nextValue: () -> [PrefData]) {
value.append(contentsOf: nextValue())
}
typealias Value = [PrefData]
}
}
struct ActivityRep: UIViewRepresentable {
func makeUIView(context: UIViewRepresentableContext<ActivityRep>) -> UIActivityIndicatorView {
return UIActivityIndicatorView()
}
func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<ActivityRep>) {
uiView.startAnimating()
}
}
@Jooc

This comment has been minimized.

Copy link

@Jooc Jooc commented May 25, 2020

This doesn't work, with the error told: [Argument passed to call that takes no arguments].
Even I copied the three files

@swiftui-lab

This comment has been minimized.

Copy link
Owner Author

@swiftui-lab swiftui-lab commented May 29, 2020

This doesn't work, with the error told: [Argument passed to call that takes no arguments].
Even I copied the three files

I don't see that error. I just tried it and works fine with Xcode 10.4.1. If you have other code in the same project, note that sometimes SwiftUI errors may show in places completely unrelated to the cause. I suggest you try this example in a new empty project.

@DmitryBespalov

This comment has been minimized.

Copy link

@DmitryBespalov DmitryBespalov commented Jul 1, 2020

Hi there, what license is this code available with?

@swiftui-lab

This comment has been minimized.

Copy link
Owner Author

@swiftui-lab swiftui-lab commented Jul 7, 2020

Hi there, what license is this code available with?

MIT License ;-)

@cjhodge

This comment has been minimized.

Copy link

@cjhodge cjhodge commented Jul 21, 2020

Does not work on iOS 14 (beta 2).

@cjhodge

This comment has been minimized.

Copy link

@cjhodge cjhodge commented Aug 6, 2020

Working on iOS 14 beta 4!

@Pyroscout

This comment has been minimized.

Copy link

@Pyroscout Pyroscout commented Aug 10, 2020

My view resizes to be way too big sometimes when I am refreshing. Any idea on how to prevent that?

@DmitryBespalov

This comment has been minimized.

Copy link

@DmitryBespalov DmitryBespalov commented Oct 6, 2020

Just FYI, we are using this solution in most of the screens that use "List" but it brought crashes when we released to production. Currently, the only option I found to fix those crashes is to implement refreshing without pull to refresh - just using a "reload" button.

@syasann

This comment has been minimized.

Copy link

@syasann syasann commented Oct 16, 2020

fantastic work friend!it seems to be the result of your countless attempts.

@alexxcheung

This comment has been minimized.

Copy link

@alexxcheung alexxcheung commented Dec 16, 2020

It totally works!

@kutakmir

This comment has been minimized.

Copy link

@kutakmir kutakmir commented Feb 6, 2021

Hey, please don't use this code. If you keep on refreshing the view hierarchy like this it will cause you performance issues.

Use Swift introspect instead + the underlying UIScrollView. Much better performance.

These are the limitations of SwiftUI we have to live with at the moment. Peace.

@ouabbas

This comment has been minimized.

Copy link

@ouabbas ouabbas commented Feb 27, 2021

Hey, please don't use this code. If you keep on refreshing the view hierarchy like this it will cause you performance issues.

Use Swift introspect instead + the underlying UIScrollView. Much better performance.

These are the limitations of SwiftUI we have to live with at the moment. Peace.

Hey, you're right ! My app lag very much... Do you have a github repo where i can see how you do for "the pull to refresh" feature please please thanks !

@plaps153

This comment has been minimized.

Copy link

@plaps153 plaps153 commented Apr 1, 2021

Hey, please don't use this code. If you keep on refreshing the view hierarchy like this it will cause you performance issues.

Use Swift introspect instead + the underlying UIScrollView. Much better performance.

These are the limitations of SwiftUI we have to live with at the moment. Peace.

But you can't use LayVStack, LazyHStack with UIScrollView. That's why I can't go back to UIScrollView.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment