-
-
Save swiftui-lab/3de557a513fbdb2d8fced41e40347e01 to your computer and use it in GitHub Desktop.
// 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() | |
} | |
} |
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.
Hi there, what license is this code available with?
Hi there, what license is this code available with?
MIT License ;-)
Does not work on iOS 14 (beta 2).
Working on iOS 14 beta 4!
My view resizes to be way too big sometimes when I am refreshing. Any idea on how to prevent that?
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.
fantastic work friend!it seems to be the result of your countless attempts.
It totally works!
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, 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 !
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.
Hi, I have an issue, when I push scroll and left it before data are completely loaded, I have the small bounce of the scroll view content and the loader is showing under the content, all the time when data are coming from the ajax call?
I got it to work just fine when I commented out these lines of code:
if self.previousScrollOffset > self.threshold && self.scrollOffset <= self.threshold {
self.frozen = true
}
So I am not adding extra space because data are coming faster than space is created and I have a small bounce when the loader is shown.
Do you have any suggestions?
Thank you.
Does not work well with NavigationView's large navigation title
This doesn't work, with the error told: [Argument passed to call that takes no arguments].
Even I copied the three files