diff --git a/README.md b/README.md index b27e698..fa7b7cd 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Thanks! It requires iOS 13 and xCode 11! -In xCode got to `File -> Swift Packages -> Add Package Dependency` and paste inthe repo's url: `https://github.com/AppPear/SwiftUI-PullToRefresh` +In xCode got to `File -> Swift Packages -> Add Package Dependency` and paste inthe repo's url: `https://github.com/phuhuynh2411/SwiftUI-PullToRefresh.git` ## Usage: You need to add `RefreshableNavigationView(title: String, action: () -> Void, content: () -> View)` to your View. Title is the navigationView title, and the action takes the refresh function. RefreshableNavigationView already encapsulates a List() so in the content you only need to define your cells. If you want TableViewCellSeparators don't forget to add a `Divider()` at the bottom of your cell. @@ -21,10 +21,15 @@ Example: ```swift struct ContentView: View { @State var numbers:[Int] = [23,45,76,54,76,3465,24,423] + @State var showRefreshView: Bool = false var body: some View { - RefreshableNavigationView(title: "Numbers", action:{ + RefreshableNavigationView(title: "Testing", showRefreshView: $showRefreshView, displayMode: .inline, action:{ self.numbers = self.generateRandomNumbers() + // Remember to set the showRefreshView to false + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.showRefreshView = false + } }){ ForEach(self.numbers, id: \.self){ number in VStack(alignment: .leading){ @@ -45,4 +50,111 @@ struct ContentView: View { } ``` +## Support Inline Navigation Bar +Change the displayMode to .inline if you want to display the list like below. + +![Inline](inline.gif) + +```swift +RefreshableNavigationView(title: "Testing", showRefreshView: $showRefreshView, displayMode: .inline, action:{ + self.numbers = self.generateRandomNumbers() + // Remember to set the showRefreshView to false + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.showRefreshView = false + } +}){ + ForEach(self.numbers, id: \.self){ number in + VStack(alignment: .leading){ + Text("\(number)") + Divider() + } + } +} +``` + +## Use without NavigationView +In some cases, you want to add the pull to refresh to the List without the NavigationView. Here you go. Just need to add the RefreshableList like below. Make sure to set the displayMode = .inline. The .large does not work without the NavigationView. + + +```swift +struct ContentView: View { + @State var numbers:[Int] = [23,45,76,54,76,3465,24,423] + @State var showRefreshView: Bool = false + + var body: some View { + RefreshableList(showRefreshView: $showRefreshView, action:{ + self.numbers = self.generateRandomNumbers() + // Remember to set the showRefreshView to false + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.showRefreshView = false + } + }){ + ForEach(self.numbers, id: \.self){ number in + VStack(alignment: .leading){ + Text("\(number)") + //.padding() + Divider() + } + } + } + } + + func generateRandomNumbers() -> [Int] { + var sequence = [Int]() + for _ in 0...30 { + sequence.append(Int.random(in: 0 ..< 100)) + } + return sequence + } +} +``` + +## Use with NavigationView +The RefreshableList can be embeded in the NavigationView. + +```swift +NavigationView { + RefreshableList(showRefreshView: $showRefreshView, action:{ + // your refresh code + // Remember to set the showRefreshView to false + self.showRefreshView = false + }){ + ForEach(self.numbers, id: \.self){ number in + VStack(alignment: .leading){ + Text("\(number)") + Divider() + } + } + } + .navigationBarTitle("Testing", displayMode: .inline) +} +``` + +## Perform an action when scrolling to the last row +Sometimes, you prefer to perform an action when scrolling to the last row e.g. loading more data. You can do that by adding the following function +```swift +.onLastPerform { +// add your method here +} +``` +Here is an example. +```swift +RefreshableList(showRefreshView: $showRefreshView, action:{ + self.numbers = self.generateRandomNumbers() + // Remember to set the showRefreshView to false + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.showRefreshView = false + } +}){ + ForEach(self.numbers, id: \.self){ number in + VStack(alignment: .leading){ + Text("\(number)") + Divider() + } + } +} +.onLastPerform { + // Add your method here. +} +``` diff --git a/Sources/SwiftUIPullToRefresh/PullToRefresh.swift b/Sources/SwiftUIPullToRefresh/PullToRefresh.swift index 1c19968..6da3e0a 100644 --- a/Sources/SwiftUIPullToRefresh/PullToRefresh.swift +++ b/Sources/SwiftUIPullToRefresh/PullToRefresh.swift @@ -11,23 +11,29 @@ import SwiftUI public struct RefreshableNavigationView: View { let content: () -> Content let action: () -> Void - @State public var showRefreshView: Bool = false + @Binding public var showRefreshView: Bool @State public var pullStatus: CGFloat = 0 - private var title: String + var displayMode: NavigationDisplayMode + var title: String + var offsetY: CGFloat { + self.displayMode == .large ? 34 : 0 + } - public init(title:String, action: @escaping () -> Void ,@ViewBuilder content: @escaping () -> Content) { - self.title = title + public init(title: String, showRefreshView: Binding, displayMode: NavigationDisplayMode = .large, action: @escaping () -> Void ,@ViewBuilder content: @escaping () -> Content) { self.action = action self.content = content + self._showRefreshView = showRefreshView + self.displayMode = displayMode + self.title = title } public var body: some View { - NavigationView{ - RefreshableList(showRefreshView: $showRefreshView, pullStatus: $pullStatus, action: self.action) { + NavigationView { + RefreshableList(showRefreshView: $showRefreshView, displayMode: self.displayMode, action: self.action) { self.content() - }.navigationBarTitle(title) + } + .navigationBarTitle("\(self.title)", displayMode: self.displayMode == .large ? .large : .inline) } - .offset(x: 0, y: self.showRefreshView ? 34 : 0) .onAppear{ UITableView.appearance().separatorColor = .clear } @@ -36,38 +42,86 @@ public struct RefreshableNavigationView: View { public struct RefreshableList: View { @Binding var showRefreshView: Bool - @Binding var pullStatus: CGFloat + @State var pullStatus: CGFloat = 0.0 let action: () -> Void let content: () -> Content - init(showRefreshView: Binding, pullStatus: Binding, action: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content) { + let displayMode: NavigationDisplayMode + var threshold: CGFloat = 100.0 + + @State private var previousScrollOffset: CGFloat = 0 + @State private var scrollOffset: CGFloat = 0 + @State private var frozen: Bool = false + @ObservedObject var obAction: ObservableAction = ObservableAction(onLast: nil) + + public init(showRefreshView: Binding, displayMode: NavigationDisplayMode = .inline, action: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content) { self._showRefreshView = showRefreshView - self._pullStatus = pullStatus self.action = action self.content = content + self.displayMode = displayMode + + UITableView.appearance().separatorColor = .clear } public var body: some View { - List{ + + ZStack(alignment: .top) { + List{ + MovingView() + if self.showRefreshView && self.frozen && self.displayMode == .inline { + EmptyRow() + } + content() + + Color + .clear + .frame(height: 0) + .onAppear { + // only perform the action when scrolling. + if self.scrollOffset < 0 { self.obAction.onLast?() } + } + } + .background(FixedView()) + .environment(\.defaultMinListRowHeight, 0) + .onPreferenceChange(RefreshableKeyTypes.PrefKey.self) { values in + self.refreshLogic(values: values) + } PullToRefreshView(showRefreshView: $showRefreshView, pullStatus: $pullStatus) - content() + .offset(y: self.displayMode == .large ? -90 : 0) + .frame(height: self.scrollOffset > 0 || self.showRefreshView ? 60 : 0) } - .onPreferenceChange(RefreshableKeyTypes.PrefKey.self) { values in - guard let bounds = values.first?.bounds else { return } - self.pullStatus = CGFloat((bounds.origin.y - 106) / 80) - self.refresh(offset: bounds.origin.y) - }.offset(x: 0, y: -40) + } - func refresh(offset: CGFloat) { - if(offset > 185 && self.showRefreshView == false) { - self.showRefreshView = true - DispatchQueue.main.async { - self.action() - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.showRefreshView = false + 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 + + // 6 is the list top padding + self.scrollOffset = movingBounds.minY - fixedBounds.minY - 6 + self.pullStatus = self.scrollOffset / 100 + + // Crossing the threshold on the way down, we start the refresh process + if !self.showRefreshView && (self.scrollOffset > self.threshold && self.previousScrollOffset <= self.threshold) { + self.showRefreshView = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.action() + } + } + + if self.showRefreshView { + // 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 first empty row inside the list view. + self.frozen = false } + // Update last scroll offset + self.previousScrollOffset = self.scrollOffset } } } @@ -75,16 +129,18 @@ public struct RefreshableList: View { struct Spinner: View { @Binding var percentage: CGFloat var body: some View { - GeometryReader{ geometry in - ForEach(1...10, id: \.self) { i in - Rectangle() - .fill(Color.gray) - .cornerRadius(1) - .frame(width: 2.5, height: 8) - .opacity(self.percentage * 10 >= CGFloat(i) ? Double(i)/10.0 : 0) - .offset(x: 0, y: -8) - .rotationEffect(.degrees(Double(36 * i)), anchor: .bottom) - }.offset(x: 20, y: 12) + GeometryReader { proxy in + ZStack { + ForEach(1...12, id: \.self) { i in + Rectangle() + .fill(Color.gray) + .cornerRadius(1) + .frame(width: proxy.frame(in: .local).width/12, height: proxy.frame(in: .local).height/4) + .opacity(self.percentage * 12 >= CGFloat(i) ? Double(i)/12 : 0) + .offset(y: -proxy.frame(in: .local).width/3) + .rotationEffect(.degrees(Double(30 * i)), anchor: .center) + } + } }.frame(width: 40, height: 40) } } @@ -92,34 +148,55 @@ struct Spinner: View { struct RefreshView: View { @Binding var isRefreshing:Bool @Binding var status: CGFloat + @State var scale: CGFloat = 1.0 var body: some View { - HStack{ - Spacer() - VStack(alignment: .center){ - if (!isRefreshing) { - Spinner(percentage: $status) - }else{ - ActivityIndicator(isAnimating: .constant(true), style: .large) - } - Text(isRefreshing ? "Loading" : "Pull to refresh").font(.caption) + ZStack{ + if (!isRefreshing) { + Spinner(percentage: $status) } - Spacer() + ActivityIndicator(isAnimating: .constant(true), style: .large) + .scaleEffect(self.isRefreshing ? 1 : 0.1) + .opacity(self.isRefreshing ? 1 : 0) + .animation(self.isRefreshing ? nil : .easeInOut) } + .frame(height: 60) + } +} + +struct EmptyRow: View { + var body: some View { + Color.clear + .frame(height: 60) } } struct PullToRefreshView: View { @Binding var showRefreshView: Bool @Binding var pullStatus: CGFloat + var body: some View { - GeometryReader{ geometry in - RefreshView(isRefreshing: self.$showRefreshView, status: self.$pullStatus) - .opacity(Double((geometry.frame(in: CoordinateSpace.global).origin.y - 106) / 80)).preference(key: RefreshableKeyTypes.PrefKey.self, value: [RefreshableKeyTypes.PrefData(bounds: geometry.frame(in: CoordinateSpace.global))]) - .offset(x: 0, y: -90) + RefreshView(isRefreshing: self.$showRefreshView, status: self.$pullStatus) + } +} + +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 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 ActivityIndicator: UIViewRepresentable { @Binding var isAnimating: Bool @@ -135,8 +212,13 @@ struct ActivityIndicator: UIViewRepresentable { } struct RefreshableKeyTypes { + enum ViewType: Int { + case movingView + case fixedView + } struct PrefData: Equatable { + let vType: ViewType let bounds: CGRect } @@ -151,8 +233,27 @@ struct RefreshableKeyTypes { } } -struct Spinner_Previews: PreviewProvider { - static var previews: some View { - Spinner(percentage: .constant(1)) +public enum NavigationDisplayMode { + case inline + case large +} + +extension RefreshableList { + public func onLastPerform(_ action: @escaping () -> Void) -> some View { + self.obAction.onLast = action + return overlay( + Color + .clear + .frame(width: 0, height: 0) + ) + } + +} + +class ObservableAction: ObservableObject { + var onLast: (()-> Void)? + + init(onLast: (()->Void)? = nil) { + self.onLast = onLast } } diff --git a/Tests/SwiftUIPullToRefreshTests/SwiftUIPullToRefreshTests.swift b/Tests/SwiftUIPullToRefreshTests/SwiftUIPullToRefreshTests.swift index a354121..b30c598 100644 --- a/Tests/SwiftUIPullToRefreshTests/SwiftUIPullToRefreshTests.swift +++ b/Tests/SwiftUIPullToRefreshTests/SwiftUIPullToRefreshTests.swift @@ -6,7 +6,7 @@ final class SwiftUIPullToRefreshTests: XCTestCase { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct // results. - XCTAssertEqual(SwiftUIPullToRefresh().text, "Hello, World!") + //XCTAssertEqual(SwiftUIPullToRefresh().text, "Hello, World!") } static var allTests = [ diff --git a/inline.gif b/inline.gif new file mode 100644 index 0000000..850cbbe Binary files /dev/null and b/inline.gif differ