AutorÃa | Ultima modificación | Ver Log |
//// RefreshableScrollView.swift// twogetskills//// Created by Efrain Yanez Recanatini on 6/21/22.//import SwiftUI// There are two type of positioning views - one that scrolls with the content,// and one that stays fixedprivate enum PositionType {case fixed, moving}// This struct is the currency of the Preferences, and has a type// (fixed or moving) and the actual Y-axis value.// It's Equatable because Swift requires it to be.private struct Position: Equatable {let type: PositionTypelet y: CGFloat}// This might seem weird, but it's necessary due to the funny nature of// how Preferences work. We can't just store the last position and merge// it with the next one - instead we have a queue of all the latest positions.private struct PositionPreferenceKey: PreferenceKey {typealias Value = [Position]static var defaultValue = [Position]()static func reduce(value: inout [Position], nextValue: () -> [Position]) {value.append(contentsOf: nextValue())}}private struct PositionIndicator: View {let type: PositionTypevar body: some View {GeometryReader { proxy in// the View itself is an invisible Shape that fills as much as possibleColor.clear// Compute the top Y position and emit it to the Preferences queue.preference(key: PositionPreferenceKey.self, value: [Position(type: type, y: proxy.frame(in: .global).minY)])}}}// Callback that'll trigger once refreshing is donepublic typealias RefreshComplete = () -> Void// The actual refresh action that's called once refreshing starts. It has the// RefreshComplete callback to let the refresh action let the View know// once it's done refreshing.public typealias OnRefresh = (@escaping RefreshComplete) -> Void// The offset threshold. 68 is a good number, but you can play// with it to your liking.public let defaultRefreshThreshold: CGFloat = 68// Tracks the state of the RefreshableScrollView - it's either:// 1. waiting for a scroll to happen// 2. has been primed by pulling down beyond THRESHOLD// 3. is doing the refreshing.public enum RefreshState {case waiting, primed, loading}// ViewBuilder for the custom progress View, that may render itself// based on the current RefreshState.public typealias RefreshProgressBuilder<Progress: View> = (RefreshState) -> Progress// Default color of the rectangle behind the progress spinnerpublic let defaultLoadingViewBackgroundColor =Color(UIColor(hex: Config.COLOR_APP_WINDOW_BACKGROUND) ?? UIColor.systemBackground)//Color(UIColor.systemBackground)public struct RefreshableScrollView<Progress, Content>: View where Progress: View, Content: View {let showsIndicators: Bool // if the ScrollView should show indicatorslet loadingViewBackgroundColor: Colorlet threshold: CGFloat // what height do you have to pull down to trigger the refreshlet onRefresh: OnRefresh // the refreshing actionlet progress: RefreshProgressBuilder<Progress> // custom progress viewlet content: () -> Content // the ScrollView content@State private var offset: CGFloat = 0@State private var state = RefreshState.waiting // the current statelet feedbackGenerator = UINotificationFeedbackGenerator() // haptic feedback// We use a custom constructor to allow for usage of a @ViewBuilder for the contentpublic init(showsIndicators: Bool = true,loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor,threshold: CGFloat = defaultRefreshThreshold,onRefresh: @escaping OnRefresh,@ViewBuilder progress: @escaping RefreshProgressBuilder<Progress>,@ViewBuilder content: @escaping () -> Content) {self.showsIndicators = showsIndicatorsself.loadingViewBackgroundColor = loadingViewBackgroundColorself.threshold = thresholdself.onRefresh = onRefreshself.progress = progressself.content = content}public var body: some View {// The root view is a regular ScrollViewScrollView(showsIndicators: showsIndicators) {// The ZStack allows us to position the PositionIndicator,// the content and the loading view, all on top of each other.ZStack(alignment: .top) {// The moving positioning indicator, that sits at the top// of the ScrollView and scrolls down with the contentPositionIndicator(type: .moving).frame(height: 0)// Your ScrollView content. If we're loading, we want// to keep it below the loading view, hence the alignmentGuide.content().alignmentGuide(.top, computeValue: { _ in(state == .loading) ? -threshold + offset : 0})// The loading view. It's offset to the top of the content unless we're loading.ZStack {Rectangle().foregroundColor(loadingViewBackgroundColor).frame(height: threshold)progress(state)}.offset(y: (state == .loading) ? -offset : -threshold)}}// Put a fixed PositionIndicator in the background so that we have// a reference point to compute the scroll offset..background(PositionIndicator(type: .fixed))// Once the scrolling offset changes, we want to see if there should// be a state change..onPreferenceChange(PositionPreferenceKey.self) { values in// Compute the offset between the moving and fixed PositionIndicatorslet movingY = values.first { $0.type == .moving }?.y ?? 0let fixedY = values.first { $0.type == .fixed }?.y ?? 0offset = movingY - fixedYif state != .loading { // If we're already loading, ignore everything// Map the preference change action to the UI threadDispatchQueue.main.async {// If the user pulled down below the threshold, prime the viewif offset > threshold && state == .waiting {state = .primedself.feedbackGenerator.notificationOccurred(.success)// If the view is primed and we've crossed the threshold again on the// way back, trigger the refresh} else if offset < threshold && state == .primed {state = .loadingonRefresh { // trigger the refreshing callback// once refreshing is done, smoothly move the loading view// back to the offset positionwithAnimation {self.state = .waiting}}}}}}}}// Extension that uses default RefreshActivityIndicator so that you don't have to// specify it every time.public extension RefreshableScrollView where Progress == RefreshActivityIndicator {init(showsIndicators: Bool = true,loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor,threshold: CGFloat = defaultRefreshThreshold,onRefresh: @escaping OnRefresh,@ViewBuilder content: @escaping () -> Content) {self.init(showsIndicators: showsIndicators,loadingViewBackgroundColor: loadingViewBackgroundColor,threshold: threshold,onRefresh: onRefresh,progress: { state inRefreshActivityIndicator(isAnimating: state == .loading) {$0.hidesWhenStopped = false}},content: content)}}// Wraps a UIActivityIndicatorView as a loading spinner that works on all SwiftUI versions.public struct RefreshActivityIndicator: UIViewRepresentable {public typealias UIView = UIActivityIndicatorViewpublic var isAnimating: Bool = truepublic var configuration = { (indicator: UIView) in }public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) {self.isAnimating = isAnimatingif let configuration = configuration {self.configuration = configuration}}public func makeUIView(context: UIViewRepresentableContext<Self>) -> UIView {UIView()}public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<Self>) {isAnimating ? uiView.startAnimating() : uiView.stopAnimating()configuration(uiView)}}#if compiler(>=5.5)// Allows using RefreshableScrollView with an async block.@available(iOS 15.0, *)public extension RefreshableScrollView {init(showsIndicators: Bool = true,loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor,threshold: CGFloat = defaultRefreshThreshold,action: @escaping @Sendable () async -> Void,@ViewBuilder progress: @escaping RefreshProgressBuilder<Progress>,@ViewBuilder content: @escaping () -> Content) {self.init(showsIndicators: showsIndicators,loadingViewBackgroundColor: loadingViewBackgroundColor,threshold: threshold,onRefresh: { refreshComplete inTask {await action()refreshComplete()}},progress: progress,content: content)}}#endifpublic struct RefreshableCompat<Progress>: ViewModifier where Progress: View {private let showsIndicators: Boolprivate let loadingViewBackgroundColor: Colorprivate let threshold: CGFloatprivate let onRefresh: OnRefreshprivate let progress: RefreshProgressBuilder<Progress>public init(showsIndicators: Bool = true,loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor,threshold: CGFloat = defaultRefreshThreshold,onRefresh: @escaping OnRefresh,@ViewBuilder progress: @escaping RefreshProgressBuilder<Progress>) {self.showsIndicators = showsIndicatorsself.loadingViewBackgroundColor = loadingViewBackgroundColorself.threshold = thresholdself.onRefresh = onRefreshself.progress = progress}public func body(content: Content) -> some View {RefreshableScrollView(showsIndicators: showsIndicators,loadingViewBackgroundColor: loadingViewBackgroundColor,threshold: threshold,onRefresh: onRefresh,progress: progress) {content}}}#if compiler(>=5.5)@available(iOS 15.0, *)public extension List {@ViewBuilder func refreshableCompat<Progress: View>(showsIndicators: Bool = true,loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor,threshold: CGFloat = defaultRefreshThreshold,onRefresh: @escaping OnRefresh,@ViewBuilder progress: @escaping RefreshProgressBuilder<Progress>) -> some View {if #available(iOS 15.0, macOS 12.0, *) {self.refreshable {await withCheckedContinuation { cont inonRefresh {cont.resume()}}}} else {self.modifier(RefreshableCompat(showsIndicators: showsIndicators,loadingViewBackgroundColor: loadingViewBackgroundColor,threshold: threshold,onRefresh: onRefresh,progress: progress))}}}#endifpublic extension View {@ViewBuilder func refreshableCompat<Progress: View>(showsIndicators: Bool = true,loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor,threshold: CGFloat = defaultRefreshThreshold,onRefresh: @escaping OnRefresh,@ViewBuilder progress: @escaping RefreshProgressBuilder<Progress>) -> some View {self.modifier(RefreshableCompat(showsIndicators: showsIndicators,loadingViewBackgroundColor: loadingViewBackgroundColor,threshold: threshold,onRefresh: onRefresh,progress: progress))}}struct TestView: View {@State private var now = Date()var body: some View {RefreshableScrollView(onRefresh: { done inDispatchQueue.main.asyncAfter(deadline: .now() + 3) {self.now = Date()done()}}) {VStack {ForEach(1..<20) {Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)").padding(.bottom, 10)}}.padding()}}}struct TestViewWithLargerThreshold: View {@State private var now = Date()var body: some View {RefreshableScrollView(threshold: defaultRefreshThreshold * 3,onRefresh: { done inDispatchQueue.main.asyncAfter(deadline: .now() + 3) {self.now = Date()done()}}) {VStack {ForEach(1..<20) {Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)").padding(.bottom, 10)}}.padding()}}}struct TestViewWithCustomProgress: View {@State private var now = Date()var body: some View {RefreshableScrollView(onRefresh: { done inDispatchQueue.main.asyncAfter(deadline: .now() + 3) {self.now = Date()done()}},progress: { state inif state == .waiting {Text("Pull me down...")} else if state == .primed {Text("Now release!")} else {Text("Working...")}}) {VStack {ForEach(1..<20) {Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)").padding(.bottom, 10)}}.padding()}}}#if compiler(>=5.5)@available(iOS 15, *)struct TestViewWithAsync: View {@State private var now = Date()var body: some View {RefreshableScrollView(action: {await Task.sleep(3_000_000_000)now = Date()}, progress: { state inRefreshActivityIndicator(isAnimating: state == .loading) {$0.hidesWhenStopped = false}}) {VStack {ForEach(1..<20) {Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)").padding(.bottom, 10)}}.padding()}}}#endifstruct TestViewCompat: View {@State private var now = Date()var body: some View {VStack {ForEach(1..<20) {Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)").padding(.bottom, 10)}}.refreshableCompat(showsIndicators: false,onRefresh: { done inDispatchQueue.main.asyncAfter(deadline: .now() + 3) {self.now = Date()done()}}, progress: { state inRefreshActivityIndicator(isAnimating: state == .loading) {$0.hidesWhenStopped = false}})}}struct TestView_Previews: PreviewProvider {static var previews: some View {TestView()}}struct TestViewWithLargerThreshold_Previews: PreviewProvider {static var previews: some View {TestViewWithLargerThreshold()}}struct TestViewWithCustomProgress_Previews: PreviewProvider {static var previews: some View {TestViewWithCustomProgress()}}#if compiler(>=5.5)@available(iOS 15, *)struct TestViewWithAsync_Previews: PreviewProvider {static var previews: some View {TestViewWithAsync()}}#endifstruct TestViewCompat_Previews: PreviewProvider {static var previews: some View {TestViewCompat()}}