├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── SwiftUIPullToRefresh │ └── SwiftUIPullToRefresh.swift └── SwiftUI-Pull-To-Refresh.podspec /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Gordan Glavaš 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftUIPullToRefresh", 8 | platforms: [ 9 | .iOS(.v13) 10 | ], 11 | products: [ 12 | .library( 13 | name: "SwiftUIPullToRefresh", 14 | targets: ["SwiftUIPullToRefresh"]), 15 | ], 16 | dependencies: [ 17 | ], 18 | targets: [ 19 | .target( 20 | name: "SwiftUIPullToRefresh", 21 | dependencies: []) 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUIPullToRefresh 2 | 3 | Pull to refresh is a common UI pattern, supported in UIKit via UIRefreshControl. (Un)surprisingly, it's also unavailable in SwiftUI prior to version 3, and even then [it's a bit lackluster](https://swiftuirecipes.com/blog/pull-to-refresh-with-swiftui-scrollview#drawbacks). 4 | 5 | This package contains a component - `RefreshableScrollView` - that enables this functionality with **any `ScrollView`**. It also **doesn't rely on `UIViewRepresentable`**, and works with **any iOS version**. The end result looks like this: 6 | 7 | ![in action](https://swiftuirecipes.com/user/pages/01.blog/pull-to-refresh-with-swiftui-scrollview/ezgif-4-bf1673b185d4.gif) 8 | 9 | ## Features 10 | 11 | * Works on any `ScrollView`. 12 | * Customizable progress indicator, with a default `RefreshActivityIndicator` spinner that works on any SwiftUI version. 13 | * Specify refresh operation and choose when it ends. 14 | * Support for Swift 5.5 `async` blocks. 15 | * Compatibility `refreshCompat` modifier to deliver a drop-in replacement for iOS 15 `refreshable`. 16 | * Built-in haptic feedback, just like regular `List` with `refreshable` has. 17 | * Additional optional customizations: 18 | + `showsIndicators` to allow for showing/hiding `ScrollView` indicators. 19 | + `loadingViewBackgroundColor` to specify the background color of the progress indicator. 20 | + `threshold` that indicates how much does the user how to pull before triggering refresh. 21 | 22 | ## Installation 23 | 24 | This component is distrubuted as a **Swift package**. Just add this URL to your package list: 25 | 26 | ```text 27 | https://github.com/globulus/swiftui-pull-to-refresh 28 | ``` 29 | 30 | You can also use **CocoaPods**: 31 | 32 | ```ruby 33 | pod 'SwiftUI-Pull-To-Refresh', '~> 1.1.9' 34 | ``` 35 | 36 | ## Sample usage 37 | 38 | ### Bread & butter 39 | 40 | ```swift 41 | struct TestView: View { 42 | @State private var now = Date() 43 | 44 | var body: some View { 45 | RefreshableScrollView(onRefresh: { done in 46 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 47 | self.now = Date() 48 | done() 49 | } 50 | }) { 51 | VStack { 52 | ForEach(1..<20) { 53 | Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)") 54 | .padding(.bottom, 10) 55 | } 56 | }.padding() 57 | } 58 | } 59 | } 60 | } 61 | ``` 62 | 63 | ### Custom progress view 64 | 65 | ```swift 66 | RefreshableScrollView(onRefresh: { done in 67 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 68 | self.now = Date() 69 | done() 70 | } 71 | }, 72 | progress: { state in // HERE 73 | if state == .waiting { 74 | Text("Pull me down...") 75 | } else if state == .primed { 76 | Text("Now release!") 77 | } else { 78 | Text("Working...") 79 | } 80 | }) { 81 | VStack { 82 | ForEach(1..<20) { 83 | Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)") 84 | .padding(.bottom, 10) 85 | } 86 | }.padding() 87 | } 88 | ``` 89 | 90 | ### Using async block 91 | 92 | ```swift 93 | RefreshableScrollView(action: { // HERE 94 | try? await Task.sleep(nanoseconds: 3_000_000_000) 95 | now = Date() 96 | }, progress: { state in 97 | RefreshActivityIndicator(isAnimating: state == .loading) { 98 | $0.hidesWhenStopped = false 99 | } 100 | }) { 101 | VStack { 102 | ForEach(1..<20) { 103 | Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)") 104 | .padding(.bottom, 10) 105 | } 106 | }.padding() 107 | } 108 | } 109 | ``` 110 | 111 | ### Compatibility mode 112 | 113 | ```swift 114 | VStack { 115 | ForEach(1..<20) { 116 | Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)") 117 | .padding(.bottom, 10) 118 | } 119 | } 120 | .refreshableCompat { done in // HERE 121 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 122 | self.now = Date() 123 | done() 124 | } 125 | } progress: { state in 126 | RefreshActivityIndicator(isAnimating: state == .loading) { 127 | $0.hidesWhenStopped = false 128 | } 129 | } 130 | ``` 131 | 132 | ## Recipe 133 | 134 | Check out [this recipe](https://swiftuirecipes.com/blog/pull-to-refresh-with-swiftui-scrollview) for in-depth description of the component and its code. Check out [SwiftUIRecipes.com](https://swiftuirecipes.com) for more **SwiftUI recipes**! 135 | 136 | ## Changelog 137 | 138 | * 1.1.9 - Reworked haptic feedback, added haptic feedback as optional. 139 | * 1.1.8 - Fixed crash when doing two pulls quickly in succession. 140 | * 1.1.7 - Updated haptic feedback. Increased Swift version for Podspec. 141 | * 1.1.6 - Fixed issue where content wouldn't swipe up while in refresh state. 142 | * 1.1.5 - Added smooth animation when loading pull is released. 143 | * 1.1.4 - Added `threshold` and `loadingViewBackgroundColor` customizations. 144 | * 1.1.3 - Add haptic feedback & increase offset a bit to fix indicator being visible on certain iPad Pro models. 145 | * 1.1.2 - Increase offset to fix UI bug occurring on iPhones without notch. 146 | * 1.1.1 - Added `showsIndicators` to allow for showing/hiding `ScrollView` indicators. 147 | * 1.1.0 - Added ability to specify custom progress view, iOS 15 support, async block support and compatibility mode. 148 | * 1.0.0 - Initial release. 149 | -------------------------------------------------------------------------------- /Sources/SwiftUIPullToRefresh/SwiftUIPullToRefresh.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // There are two type of positioning views - one that scrolls with the content, 4 | // and one that stays fixed 5 | private enum PositionType { 6 | case fixed, moving 7 | } 8 | 9 | // This struct is the currency of the Preferences, and has a type 10 | // (fixed or moving) and the actual Y-axis value. 11 | // It's Equatable because Swift requires it to be. 12 | private struct Position: Equatable { 13 | let type: PositionType 14 | let y: CGFloat 15 | } 16 | 17 | // This might seem weird, but it's necessary due to the funny nature of 18 | // how Preferences work. We can't just store the last position and merge 19 | // it with the next one - instead we have a queue of all the latest positions. 20 | private struct PositionPreferenceKey: PreferenceKey { 21 | typealias Value = [Position] 22 | 23 | static var defaultValue = [Position]() 24 | 25 | static func reduce(value: inout [Position], nextValue: () -> [Position]) { 26 | value.append(contentsOf: nextValue()) 27 | } 28 | } 29 | 30 | private struct PositionIndicator: View { 31 | let type: PositionType 32 | 33 | var body: some View { 34 | GeometryReader { proxy in 35 | // the View itself is an invisible Shape that fills as much as possible 36 | Color.clear 37 | // Compute the top Y position and emit it to the Preferences queue 38 | .preference(key: PositionPreferenceKey.self, value: [Position(type: type, y: proxy.frame(in: .global).minY)]) 39 | } 40 | } 41 | } 42 | 43 | // Callback that'll trigger once refreshing is done 44 | public typealias RefreshComplete = () -> Void 45 | 46 | // The actual refresh action that's called once refreshing starts. It has the 47 | // RefreshComplete callback to let the refresh action let the View know 48 | // once it's done refreshing. 49 | public typealias OnRefresh = (@escaping RefreshComplete) -> Void 50 | 51 | // The offset threshold. 68 is a good number, but you can play 52 | // with it to your liking. 53 | public let defaultRefreshThreshold: CGFloat = 68 54 | 55 | // Tracks the state of the RefreshableScrollView - it's either: 56 | // 1. waiting for a scroll to happen 57 | // 2. has been primed by pulling down beyond THRESHOLD 58 | // 3. is doing the refreshing. 59 | public enum RefreshState { 60 | case waiting, primed, loading 61 | } 62 | 63 | // ViewBuilder for the custom progress View, that may render itself 64 | // based on the current RefreshState. 65 | public typealias RefreshProgressBuilder = (RefreshState) -> Progress 66 | 67 | // Default color of the rectangle behind the progress spinner 68 | public let defaultLoadingViewBackgroundColor = Color(UIColor.systemBackground) 69 | 70 | public struct RefreshableScrollView: View where Progress: View, Content: View { 71 | let showsIndicators: Bool // if the ScrollView should show indicators 72 | let shouldTriggerHapticFeedback: Bool // if key actions should trigger haptic feedback 73 | let loadingViewBackgroundColor: Color 74 | let threshold: CGFloat // what height do you have to pull down to trigger the refresh 75 | let onRefresh: OnRefresh // the refreshing action 76 | let progress: RefreshProgressBuilder // custom progress view 77 | let content: () -> Content // the ScrollView content 78 | @State private var offset: CGFloat = 0 79 | @State private var state = RefreshState.waiting // the current state 80 | 81 | // Haptic Feedback 82 | let finishedReloadingFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) 83 | let primedFeedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) 84 | 85 | // We use a custom constructor to allow for usage of a @ViewBuilder for the content 86 | public init(showsIndicators: Bool = true, 87 | shouldTriggerHapticFeedback: Bool = false, 88 | loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, 89 | threshold: CGFloat = defaultRefreshThreshold, 90 | onRefresh: @escaping OnRefresh, 91 | @ViewBuilder progress: @escaping RefreshProgressBuilder, 92 | @ViewBuilder content: @escaping () -> Content) { 93 | self.showsIndicators = showsIndicators 94 | self.shouldTriggerHapticFeedback = shouldTriggerHapticFeedback 95 | self.loadingViewBackgroundColor = loadingViewBackgroundColor 96 | self.threshold = threshold 97 | self.onRefresh = onRefresh 98 | self.progress = progress 99 | self.content = content 100 | } 101 | 102 | public var body: some View { 103 | // The root view is a regular ScrollView 104 | ScrollView(showsIndicators: showsIndicators) { 105 | // The ZStack allows us to position the PositionIndicator, 106 | // the content and the loading view, all on top of each other. 107 | ZStack(alignment: .top) { 108 | // The moving positioning indicator, that sits at the top 109 | // of the ScrollView and scrolls down with the content 110 | PositionIndicator(type: .moving) 111 | .frame(height: 0) 112 | 113 | // Your ScrollView content. If we're loading, we want 114 | // to keep it below the loading view, hence the alignmentGuide. 115 | content() 116 | .alignmentGuide(.top, computeValue: { _ in 117 | (state == .loading) ? -threshold + max(0, offset) : 0 118 | }) 119 | 120 | // The loading view. It's offset to the top of the content unless we're loading. 121 | ZStack { 122 | Rectangle() 123 | .foregroundColor(loadingViewBackgroundColor) 124 | .frame(height: threshold) 125 | progress(state) 126 | }.offset(y: (state == .loading) ? -max(0, offset) : -threshold) 127 | } 128 | } 129 | // Put a fixed PositionIndicator in the background so that we have 130 | // a reference point to compute the scroll offset. 131 | .background(PositionIndicator(type: .fixed)) 132 | // Once the scrolling offset changes, we want to see if there should 133 | // be a state change. 134 | .onPreferenceChange(PositionPreferenceKey.self) { values in 135 | DispatchQueue.main.async { 136 | // Compute the offset between the moving and fixed PositionIndicators 137 | let movingY = values.first { $0.type == .moving }?.y ?? 0 138 | let fixedY = values.first { $0.type == .fixed }?.y ?? 0 139 | offset = movingY - fixedY 140 | if state != .loading { // If we're already loading, ignore everything 141 | // Map the preference change action to the UI thread 142 | 143 | // If the user pulled down below the threshold, prime the view 144 | if offset > threshold && state == .waiting { 145 | state = .primed 146 | if shouldTriggerHapticFeedback { 147 | self.primedFeedbackGenerator.impactOccurred() 148 | } 149 | 150 | // If the view is primed and we've crossed the threshold again on the 151 | // way back, trigger the refresh 152 | } else if offset < threshold && state == .primed { 153 | state = .loading 154 | onRefresh { // trigger the refreshing callback 155 | // once refreshing is done, smoothly move the loading view 156 | // back to the offset position 157 | withAnimation { 158 | self.state = .waiting 159 | } 160 | if shouldTriggerHapticFeedback { 161 | self.finishedReloadingFeedbackGenerator.impactOccurred() 162 | } 163 | } 164 | } 165 | } 166 | } 167 | } 168 | } 169 | } 170 | 171 | // Extension that uses default RefreshActivityIndicator so that you don't have to 172 | // specify it every time. 173 | public extension RefreshableScrollView where Progress == RefreshActivityIndicator { 174 | init(showsIndicators: Bool = true, 175 | loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, 176 | threshold: CGFloat = defaultRefreshThreshold, 177 | onRefresh: @escaping OnRefresh, 178 | @ViewBuilder content: @escaping () -> Content) { 179 | self.init(showsIndicators: showsIndicators, 180 | loadingViewBackgroundColor: loadingViewBackgroundColor, 181 | threshold: threshold, 182 | onRefresh: onRefresh, 183 | progress: { state in 184 | RefreshActivityIndicator(isAnimating: state == .loading) { 185 | $0.hidesWhenStopped = false 186 | } 187 | }, 188 | content: content) 189 | } 190 | } 191 | 192 | // Wraps a UIActivityIndicatorView as a loading spinner that works on all SwiftUI versions. 193 | public struct RefreshActivityIndicator: UIViewRepresentable { 194 | public typealias UIView = UIActivityIndicatorView 195 | public var isAnimating: Bool = true 196 | public var configuration = { (indicator: UIView) in } 197 | 198 | public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) { 199 | self.isAnimating = isAnimating 200 | if let configuration = configuration { 201 | self.configuration = configuration 202 | } 203 | } 204 | 205 | public func makeUIView(context: UIViewRepresentableContext) -> UIView { 206 | UIView() 207 | } 208 | 209 | public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) { 210 | isAnimating ? uiView.startAnimating() : uiView.stopAnimating() 211 | configuration(uiView) 212 | } 213 | } 214 | 215 | #if compiler(>=5.5) 216 | // Allows using RefreshableScrollView with an async block. 217 | @available(iOS 15.0, *) 218 | public extension RefreshableScrollView { 219 | init(showsIndicators: Bool = true, 220 | loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, 221 | threshold: CGFloat = defaultRefreshThreshold, 222 | action: @escaping @Sendable () async -> Void, 223 | @ViewBuilder progress: @escaping RefreshProgressBuilder, 224 | @ViewBuilder content: @escaping () -> Content) { 225 | self.init(showsIndicators: showsIndicators, 226 | loadingViewBackgroundColor: loadingViewBackgroundColor, 227 | threshold: threshold, 228 | onRefresh: { refreshComplete in 229 | Task { 230 | await action() 231 | refreshComplete() 232 | } 233 | }, 234 | progress: progress, 235 | content: content) 236 | } 237 | } 238 | #endif 239 | 240 | public struct RefreshableCompat: ViewModifier where Progress: View { 241 | private let showsIndicators: Bool 242 | private let loadingViewBackgroundColor: Color 243 | private let threshold: CGFloat 244 | private let onRefresh: OnRefresh 245 | private let progress: RefreshProgressBuilder 246 | 247 | public init(showsIndicators: Bool = true, 248 | loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, 249 | threshold: CGFloat = defaultRefreshThreshold, 250 | onRefresh: @escaping OnRefresh, 251 | @ViewBuilder progress: @escaping RefreshProgressBuilder) { 252 | self.showsIndicators = showsIndicators 253 | self.loadingViewBackgroundColor = loadingViewBackgroundColor 254 | self.threshold = threshold 255 | self.onRefresh = onRefresh 256 | self.progress = progress 257 | } 258 | 259 | public func body(content: Content) -> some View { 260 | RefreshableScrollView(showsIndicators: showsIndicators, 261 | loadingViewBackgroundColor: loadingViewBackgroundColor, 262 | threshold: threshold, 263 | onRefresh: onRefresh, 264 | progress: progress) { 265 | content 266 | } 267 | } 268 | } 269 | 270 | #if compiler(>=5.5) 271 | @available(iOS 15.0, *) 272 | public extension List { 273 | @ViewBuilder func refreshableCompat(showsIndicators: Bool = true, 274 | loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, 275 | threshold: CGFloat = defaultRefreshThreshold, 276 | onRefresh: @escaping OnRefresh, 277 | @ViewBuilder progress: @escaping RefreshProgressBuilder) -> some View { 278 | if #available(iOS 15.0, macOS 12.0, *) { 279 | self.refreshable { 280 | await withCheckedContinuation { cont in 281 | onRefresh { 282 | cont.resume() 283 | } 284 | } 285 | } 286 | } else { 287 | self.modifier(RefreshableCompat(showsIndicators: showsIndicators, 288 | loadingViewBackgroundColor: loadingViewBackgroundColor, 289 | threshold: threshold, 290 | onRefresh: onRefresh, 291 | progress: progress)) 292 | } 293 | } 294 | } 295 | #endif 296 | 297 | public extension View { 298 | @ViewBuilder func refreshableCompat(showsIndicators: Bool = true, 299 | loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, 300 | threshold: CGFloat = defaultRefreshThreshold, 301 | onRefresh: @escaping OnRefresh, 302 | @ViewBuilder progress: @escaping RefreshProgressBuilder) -> some View { 303 | self.modifier(RefreshableCompat(showsIndicators: showsIndicators, 304 | loadingViewBackgroundColor: loadingViewBackgroundColor, 305 | threshold: threshold, 306 | onRefresh: onRefresh, 307 | progress: progress)) 308 | } 309 | } 310 | 311 | struct TestView: View { 312 | @State private var now = Date() 313 | 314 | var body: some View { 315 | RefreshableScrollView( 316 | onRefresh: { done in 317 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 318 | self.now = Date() 319 | done() 320 | } 321 | }) { 322 | VStack { 323 | ForEach(1..<20) { 324 | Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)") 325 | .padding(.bottom, 10) 326 | } 327 | }.padding() 328 | } 329 | } 330 | } 331 | 332 | struct TestViewWithLargerThreshold: View { 333 | @State private var now = Date() 334 | 335 | var body: some View { 336 | RefreshableScrollView( 337 | threshold: defaultRefreshThreshold * 3, 338 | onRefresh: { done in 339 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 340 | self.now = Date() 341 | done() 342 | } 343 | }) { 344 | VStack { 345 | ForEach(1..<20) { 346 | Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)") 347 | .padding(.bottom, 10) 348 | } 349 | }.padding() 350 | } 351 | } 352 | } 353 | 354 | struct TestViewWithCustomProgress: View { 355 | @State private var now = Date() 356 | 357 | var body: some View { 358 | RefreshableScrollView(onRefresh: { done in 359 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 360 | self.now = Date() 361 | done() 362 | } 363 | }, 364 | progress: { state in 365 | if state == .waiting { 366 | Text("Pull me down...") 367 | } else if state == .primed { 368 | Text("Now release!") 369 | } else { 370 | Text("Working...") 371 | } 372 | } 373 | ) { 374 | VStack { 375 | ForEach(1..<20) { 376 | Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)") 377 | .padding(.bottom, 10) 378 | } 379 | }.padding() 380 | } 381 | } 382 | } 383 | 384 | #if compiler(>=5.5) 385 | @available(iOS 15, *) 386 | struct TestViewWithAsync: View { 387 | @State private var now = Date() 388 | 389 | var body: some View { 390 | RefreshableScrollView(action: { 391 | try? await Task.sleep(nanoseconds: 3_000_000_000) 392 | now = Date() 393 | }, progress: { state in 394 | RefreshActivityIndicator(isAnimating: state == .loading) { 395 | $0.hidesWhenStopped = false 396 | } 397 | }) { 398 | VStack { 399 | ForEach(1..<20) { 400 | Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)") 401 | .padding(.bottom, 10) 402 | } 403 | }.padding() 404 | } 405 | } 406 | } 407 | #endif 408 | 409 | struct TestViewCompat: View { 410 | @State private var now = Date() 411 | 412 | var body: some View { 413 | VStack { 414 | ForEach(1..<20) { 415 | Text("\(Calendar.current.date(byAdding: .hour, value: $0, to: now)!)") 416 | .padding(.bottom, 10) 417 | } 418 | } 419 | .refreshableCompat(showsIndicators: false, 420 | onRefresh: { done in 421 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 422 | self.now = Date() 423 | done() 424 | } 425 | }, progress: { state in 426 | RefreshActivityIndicator(isAnimating: state == .loading) { 427 | $0.hidesWhenStopped = false 428 | } 429 | }) 430 | 431 | } 432 | } 433 | 434 | struct TestView_Previews: PreviewProvider { 435 | static var previews: some View { 436 | TestView() 437 | } 438 | } 439 | 440 | struct TestViewWithLargerThreshold_Previews: PreviewProvider { 441 | static var previews: some View { 442 | TestViewWithLargerThreshold() 443 | } 444 | } 445 | 446 | struct TestViewWithCustomProgress_Previews: PreviewProvider { 447 | static var previews: some View { 448 | TestViewWithCustomProgress() 449 | } 450 | } 451 | 452 | #if compiler(>=5.5) 453 | @available(iOS 15, *) 454 | struct TestViewWithAsync_Previews: PreviewProvider { 455 | static var previews: some View { 456 | TestViewWithAsync() 457 | } 458 | } 459 | #endif 460 | 461 | struct TestViewCompat_Previews: PreviewProvider { 462 | static var previews: some View { 463 | TestViewCompat() 464 | } 465 | } 466 | -------------------------------------------------------------------------------- /SwiftUI-Pull-To-Refresh.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'SwiftUI-Pull-To-Refresh' 3 | s.version = '1.1.9' 4 | s.summary = 'Pull to refresh on any SwiftUI Scroll View.' 5 | s.homepage = 'https://github.com/globulus/swiftui-pull-to-refresh' 6 | s.license = { :type => 'MIT', :file => 'LICENSE' } 7 | s.author = { 'Gordan Glavaš' => 'gordan.glavas@gmail.com' } 8 | s.source = { :git => 'https://github.com/globulus/swiftui-pull-to-refresh.git', :tag => s.version.to_s } 9 | s.ios.deployment_target = '13.0' 10 | s.swift_version = '5.0' 11 | s.source_files = 'Sources/SwiftUIPullToRefresh/**/*' 12 | end 13 | --------------------------------------------------------------------------------