From 7bc93b972005432c97c82b32c00d3b8cab62c28f Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 15 Sep 2023 17:04:33 +0300 Subject: [PATCH] Update refreshable ScrollView to native for iOS 16+ (#75) * Update refreshable ScrollView to native for iOS 16+ --- Core/Core.xcodeproj/project.pbxproj | 4 + .../View/Base/RefreshableScrollView.swift | 526 ++++++++++-------- .../Base/RefreshableScrollViewCompat.swift | 39 ++ .../Details/CourseDetailsView.swift | 11 +- .../Outline/CourseOutlineView.swift | 9 +- .../Presentation/DashboardView.swift | 11 +- .../Presentation/DiscoveryView.swift | 87 ++- .../Comments/Responses/ResponsesView.swift | 18 +- .../Responses/ResponsesViewModel.swift | 6 +- .../Comments/Thread/ThreadView.swift | 10 +- .../Comments/Thread/ThreadViewModel.swift | 6 +- .../DiscussionTopicsView.swift | 11 +- .../Presentation/Posts/PostsView.swift | 99 ++-- .../Presentation/Profile/ProfileView.swift | 11 +- 14 files changed, 486 insertions(+), 362 deletions(-) create mode 100644 Core/Core/View/Base/RefreshableScrollViewCompat.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index a9463cdcc..eed739e2e 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -68,6 +68,7 @@ 02A4833C29B8C57800D33F33 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833B29B8C57800D33F33 /* DownloadView.swift */; }; 02B2B594295C5C7A00914876 /* Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2B593295C5C7A00914876 /* Thread.swift */; }; 02B3E3B32930198600A50475 /* AVPlayerViewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */; }; + 02B3F16E2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */; }; 02C2DC0829B63D6200F4445D /* WebViewHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C2DC0729B63D6200F4445D /* WebViewHTML.swift */; }; 02C917F029CDA99E00DBB8BD /* Data_Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C917EF29CDA99E00DBB8BD /* Data_Dashboard.swift */; }; 02CF46C829546AA200A698EE /* NoCachedDataError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CF46C729546AA200A698EE /* NoCachedDataError.swift */; }; @@ -186,6 +187,7 @@ 02A4833B29B8C57800D33F33 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; 02B2B593295C5C7A00914876 /* Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thread.swift; sourceTree = ""; }; 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayerViewControllerExtension.swift; sourceTree = ""; }; + 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollViewCompat.swift; sourceTree = ""; }; 02C2DC0729B63D6200F4445D /* WebViewHTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewHTML.swift; sourceTree = ""; }; 02C917EF29CDA99E00DBB8BD /* Data_Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Dashboard.swift; sourceTree = ""; }; 02CF46C729546AA200A698EE /* NoCachedDataError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCachedDataError.swift; sourceTree = ""; }; @@ -524,6 +526,7 @@ 02C2DC0729B63D6200F4445D /* WebViewHTML.swift */, 021D925628DCF12900ACC565 /* AlertView.swift */, 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */, + 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */, 0236F3B628F4351E0050F09B /* CourseButton.swift */, 0241666A28F5A78B00082765 /* HTMLFormattedText.swift */, 0282DA7228F98CC9003C3F07 /* WebUnitView.swift */, @@ -821,6 +824,7 @@ 028CE96929858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift in Sources */, 027BD3A92909474200392132 /* KeyboardAvoidingViewControllerRepr.swift in Sources */, 02F98A7F28F81EE900DE94C0 /* Container+App.swift in Sources */, + 02B3F16E2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift in Sources */, 0727877B28D24A1D002E9142 /* HeadersRedirectHandler.swift in Sources */, 0236961B28F9A28B00EEF206 /* AuthInteractor.swift in Sources */, 0770DE3028D09793006D8A5D /* EndPointType.swift in Sources */, diff --git a/Core/Core/View/Base/RefreshableScrollView.swift b/Core/Core/View/Base/RefreshableScrollView.swift index d72580b5e..0905bdba6 100644 --- a/Core/Core/View/Base/RefreshableScrollView.swift +++ b/Core/Core/View/Base/RefreshableScrollView.swift @@ -6,249 +6,337 @@ // import SwiftUI -import Combine -public struct RefreshableScrollView: View { - @StateObject private var viewModel = RefreshableScrollViewModel() - - private let content: () -> Content - private let showsIndicators: Bool - private let onRefresh: () async -> Void - - public init(showsIndicators: Bool = true, - @ViewBuilder content: @escaping () -> Content, - onRefresh: @escaping () async -> Void) { - self.content = content - self.showsIndicators = showsIndicators - self.onRefresh = onRefresh - } - - private var topGeometryReader: some View { - GeometryReader { geometry in - Color.clear - .framePreferenceKey(geometry.frame(in: .global)) { frame in - self.viewModel.update(topFrame: frame) - } - } - } - - private var scrollViewGeometryReader: some View { - GeometryReader { geometry in - Color.clear - .framePreferenceKey(geometry.frame(in: .global)) { frame in - self.viewModel.update(scrollFrame: frame) - } - } - } - - public var body: some View { - VStack() { -// ProgressView() -// .progressViewStyle(.circular) -// .opacity(self.viewModel.isRefreshing ? 1 : 0) -// Activity - ActivityIndicator(size: self.$viewModel.progressViewHeight, isAnimating: self.$viewModel.isRefreshing) - .frame(width: self.viewModel.progressViewHeight, height: self.viewModel.progressViewHeight) - .background { self.topGeometryReader } - - ScrollView(.vertical, showsIndicators: self.showsIndicators) { - self.content() - .background { self.scrollViewGeometryReader } - } - } - .onChange(of: self.viewModel.isRefreshing) { isRefreshing in - guard isRefreshing else { return } - - Task { - await self.onRefresh() - - // In case the async method returns quickly. - // We want to keep it refreshing for some time so it is smooth. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.viewModel.endRefreshing() - } - } - } - } +// There are two type of positioning views - one that scrolls with the content, +// and one that stays fixed +private enum PositionType { + case fixed, moving } -struct RefreshableScrollView_Previews: PreviewProvider { - static var previews: some View { - RefreshableScrollView(showsIndicators: true) { - Text("Hi") - Text("World") - Text("Hello") - } onRefresh: { - print("Refreshing") +// 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: PositionType + let 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: PositionType + + var body: some View { + GeometryReader { proxy in + // the View itself is an invisible Shape that fills as much as possible + Color.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 done +public 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 = (RefreshState) -> Progress + +// Default color of the rectangle behind the progress spinner +public let defaultLoadingViewBackgroundColor = Color(UIColor.clear) + +public struct RefreshableScrollView: View where Progress: View, Content: View { + let showsIndicators: Bool // if the ScrollView should show indicators + let shouldTriggerHapticFeedback: Bool // if key actions should trigger haptic feedback + let loadingViewBackgroundColor: Color + let threshold: CGFloat // what height do you have to pull down to trigger the refresh + let onRefresh: OnRefresh // the refreshing action + let progress: RefreshProgressBuilder // custom progress view + let content: () -> Content // the ScrollView content + @State private var offset: CGFloat = 0 + @State private var state = RefreshState.waiting // the current state + // Haptic Feedback + let finishedReloadingFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) + let primedFeedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) + + // We use a custom constructor to allow for usage of a @ViewBuilder for the content + public init(showsIndicators: Bool = true, + shouldTriggerHapticFeedback: Bool = false, + loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, + threshold: CGFloat = defaultRefreshThreshold, + onRefresh: @escaping OnRefresh, + @ViewBuilder progress: @escaping RefreshProgressBuilder, + @ViewBuilder content: @escaping () -> Content) { + self.showsIndicators = showsIndicators + self.shouldTriggerHapticFeedback = shouldTriggerHapticFeedback + self.loadingViewBackgroundColor = loadingViewBackgroundColor + self.threshold = threshold + self.onRefresh = onRefresh + self.progress = progress + self.content = content + } + + public var body: some View { + // The root view is a regular ScrollView + ScrollView(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 content + PositionIndicator(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 + max(0, 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) ? -max(0, 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 + DispatchQueue.main.async { + // Compute the offset between the moving and fixed PositionIndicators + let movingY = values.first { $0.type == .moving }?.y ?? 0 + let fixedY = values.first { $0.type == .fixed }?.y ?? 0 + offset = movingY - fixedY + if state != .loading { // If we're already loading, ignore everything + // Map the preference change action to the UI thread + // If the user pulled down below the threshold, prime the view + if offset > threshold && state == .waiting { + state = .primed + if shouldTriggerHapticFeedback { + self.primedFeedbackGenerator.impactOccurred() + } + + // 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 = .loading + onRefresh { // trigger the refreshing callback + // once refreshing is done, smoothly move the loading view + // back to the offset position + withAnimation { + self.state = .waiting + } + if shouldTriggerHapticFeedback { + self.finishedReloadingFeedbackGenerator.impactOccurred() + } + } + } + } + } + } + } } -final class RefreshableScrollViewModel: ObservableObject { - @Published var progressViewHeight: CGFloat = 0 - @Published var isRefreshing = false - - let progressViewMaxHeight: CGFloat - private let scrollPositionSubject = CurrentValueSubject(0) - private let closingAnimationDuration: Double = 0.15 - private var subscriptions: Set = [] - - private var topYValue: CGFloat? - private var scrollYValue: CGFloat? - private var startingDistance: CGFloat? - private var isClosing = false - - /// - Parameter activityIndicatorStyle: Used to derive the size of the indicator. Might be better to get in another way. In case Apple changes the sizes - init(activityIndicatorStyle: UIActivityIndicatorView.Style = .medium) { - self.progressViewMaxHeight = activityIndicatorStyle == .large ? 35 : 27 - self.reactToScrollEnding() - } - - private func reactToScrollEnding() { - self.scrollPositionSubject - .debounce(for: 0.1, scheduler: RunLoop.main, options: nil) - .sink { [weak self] _ in - guard self?.progressViewHeight != 0, - self?.isRefreshing != true - else { return } - - self?.reset() - } - .store(in: &self.subscriptions) +// 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 in + RefreshActivityIndicator(isAnimating: state == .loading) { + $0.hidesWhenStopped = false + } + }, + content: content) } - - /// Updates the progressViewHeight and progressViewIsAnimating properties based on the given topFrame and any existing scrollYValue, if any - /// - Parameter topFrame: CGRect - func update(topFrame: CGRect) { - let topY = topFrame.minY - self.topYValue = topY - guard let scrollY = self.scrollYValue else { return } - - self.update(topY: topY, scrollY: scrollY) - } - - /// Updates the progressViewHeight and progressViewIsAnimating properties based on the given scrollFrame and any existing topYValue, if any - /// - Parameter scrollFrame: CGRect - func update(scrollFrame: CGRect) { - let scrollY = scrollFrame.minY - self.scrollYValue = scrollY - self.scrollPositionSubject.send(scrollY) - guard let topY = self.topYValue else { return } - - self.update(topY: topY, scrollY: scrollY) +} + +// Wraps a UIActivityIndicatorView as a loading spinner that works on all SwiftUI versions. +public struct RefreshActivityIndicator: UIViewRepresentable { + public typealias UIView = UIActivityIndicatorView + public var isAnimating: Bool = true + public var configuration = { (indicator: UIView) in } + + public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) { + self.isAnimating = isAnimating + if let configuration = configuration { + self.configuration = configuration } - - /// Stops refreshing and hides the progress view - func endRefreshing() { - self.reset() - - DispatchQueue.main.asyncAfter(deadline: .now() + self.closingAnimationDuration) { - self.isRefreshing = false - } + } + + public func makeUIView(context: UIViewRepresentableContext) -> UIView { + UIView() + } + + public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) { + 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, + @ViewBuilder content: @escaping () -> Content) { + self.init(showsIndicators: showsIndicators, + loadingViewBackgroundColor: loadingViewBackgroundColor, + threshold: threshold, + onRefresh: { refreshComplete in + Task { + await action() + refreshComplete() + } + }, + progress: progress, + content: content) } - - private func reset() { - self.isClosing = true - let topY = self.topYValue ?? 0 - let startDistance = self.startingDistance ?? 0 - let startingScrollYValue = topY + startDistance - self.scrollYValue = startingScrollYValue - - withAnimation(.linear(duration: self.closingAnimationDuration)) { - self.progressViewHeight = 0 - } - - DispatchQueue.main.asyncAfter(deadline: .now() + self.closingAnimationDuration) { - self.isClosing = false - } +} +#endif + +public struct RefreshableCompat: ViewModifier where Progress: View { + private let showsIndicators: Bool + private let loadingViewBackgroundColor: Color + private let threshold: CGFloat + private let onRefresh: OnRefresh + private let progress: RefreshProgressBuilder + + public init(showsIndicators: Bool = true, + loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, + threshold: CGFloat = defaultRefreshThreshold, + onRefresh: @escaping OnRefresh, + @ViewBuilder progress: @escaping RefreshProgressBuilder) { + self.showsIndicators = showsIndicators + self.loadingViewBackgroundColor = loadingViewBackgroundColor + self.threshold = threshold + self.onRefresh = onRefresh + self.progress = progress } - - private func update(topY: CGFloat, scrollY: CGFloat) { - // Don't react to updates while animating closed - guard !self.isClosing else { return } - - let newDistance = max(scrollY - topY, 0) - - if self.startingDistance == nil { - self.startingDistance = newDistance - } - - let differenceFromStart = newDistance - self.startingDistance! - let constrainedDifference = min(max(differenceFromStart, 0), self.progressViewMaxHeight) - - // Don't change the height of the progress view if we are refreshing - guard !isRefreshing else { return } - - DispatchQueue.main.async { - self.progressViewHeight = constrainedDifference - self.isRefreshing = constrainedDifference == self.progressViewMaxHeight + + public func body(content: Content) -> some View { + RefreshableScrollView(showsIndicators: showsIndicators, + loadingViewBackgroundColor: loadingViewBackgroundColor, + threshold: threshold, + onRefresh: onRefresh, + progress: progress) { + content } } } -struct FramePreferenceKey: PreferenceKey { - static var defaultValue: CGRect = .zero - - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() +#if compiler(>=5.5) +@available(iOS 15.0, *) +public extension List { + @ViewBuilder func refreshableCompat(showsIndicators: Bool = true, + loadingViewBackgroundColor: + Color = defaultLoadingViewBackgroundColor, + threshold: CGFloat = defaultRefreshThreshold, + onRefresh: @escaping OnRefresh, + @ViewBuilder progress: + @escaping RefreshProgressBuilder) -> some View { + if #available(iOS 15.0, macOS 12.0, *) { + self.refreshable { + await withCheckedContinuation { cont in + onRefresh { + cont.resume() + } + } + } + } else { + self.modifier(RefreshableCompat(showsIndicators: showsIndicators, + loadingViewBackgroundColor: loadingViewBackgroundColor, + threshold: threshold, + onRefresh: onRefresh, + progress: progress)) + } } } +#endif -extension View { - func framePreferenceKey(_ value: CGRect, onFrameChange: @escaping (CGRect) -> Void) -> some View { - self - .preference(key: FramePreferenceKey.self, value: value) - .onPreferenceChange(FramePreferenceKey.self, perform: onFrameChange) +public extension View { + @ViewBuilder func refreshableCompat(showsIndicators: Bool = true, + loadingViewBackgroundColor: + Color = defaultLoadingViewBackgroundColor, + threshold: CGFloat = defaultRefreshThreshold, + onRefresh: @escaping OnRefresh, + @ViewBuilder progress: + @escaping RefreshProgressBuilder) -> some View { + self.modifier(RefreshableCompat(showsIndicators: showsIndicators, + loadingViewBackgroundColor: loadingViewBackgroundColor, + threshold: threshold, + onRefresh: onRefresh, + progress: progress)) } } struct ActivityIndicator: UIViewRepresentable { - @Binding var size: CGFloat - @Binding var isAnimating: Bool - private let style: UIActivityIndicatorView.Style + public typealias UIView = UIActivityIndicatorView + public var isAnimating: Bool = true + public var configuration = { (indicator: UIView) in } - init(style: UIActivityIndicatorView.Style = .medium, size: Binding, isAnimating: Binding) { - self._size = size - self._isAnimating = isAnimating - self.style = style + public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) { + self.isAnimating = isAnimating + if let configuration = configuration { + self.configuration = configuration + } } - func makeUIView(context: Context) -> UIView { - let activityIndicator = UIActivityIndicatorView(style: self.style) - activityIndicator.hidesWhenStopped = false - - if self.isAnimating { - activityIndicator.startAnimating() - } - - let containerView = UIView() - containerView.layer.cornerRadius = self.size / 2 - containerView.clipsToBounds = true - - containerView.addSubview(activityIndicator) - activityIndicator.translatesAutoresizingMaskIntoConstraints = false - activityIndicator - .centerXAnchor - .constraint(equalTo: containerView.centerXAnchor) - .isActive = true - activityIndicator - .centerYAnchor - .constraint(equalTo: containerView.centerYAnchor) - .isActive = true - - return containerView + public func makeUIView(context: UIViewRepresentableContext) -> UIView { + let uiView = UIView() + uiView.startAnimating() + return uiView } - func updateUIView(_ uiView: UIView, context: Context) { - uiView.layer.cornerRadius = self.size / 2 - - guard let activityIndicator = uiView.subviews.first(where: { $0 is UIActivityIndicatorView }) as? UIActivityIndicatorView - else { return } - - if self.isAnimating { - activityIndicator.startAnimating() - } else { - activityIndicator.stopAnimating() - } + public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) { +// isAnimating ? uiView.startAnimating() : uiView.stopAnimating() + configuration(uiView) } -} + } diff --git a/Core/Core/View/Base/RefreshableScrollViewCompat.swift b/Core/Core/View/Base/RefreshableScrollViewCompat.swift new file mode 100644 index 000000000..768aa08b9 --- /dev/null +++ b/Core/Core/View/Base/RefreshableScrollViewCompat.swift @@ -0,0 +1,39 @@ +// +// RefreshableScrollViewCompat.swift +// Core +// +// Created by  Stepanok Ivan on 15.09.2023. +// + +import SwiftUI + +public struct RefreshableScrollViewCompat: View where Content: View { + private let content: () -> Content + private let action: () async -> Void + + public init(action: @escaping () async -> Void, @ViewBuilder content: @escaping () -> Content) { + self.action = action + self.content = content + } + + public var body: some View { + if #available(iOS 16.0, *) { + return ScrollView { + content() + }.refreshable { + Task { + await action() + } + } + } else { + return RefreshableScrollView(onRefresh: { done in + Task { + await action() + done() + } + }) { + content() + } + } + } +} diff --git a/Course/Course/Presentation/Details/CourseDetailsView.swift b/Course/Course/Presentation/Details/CourseDetailsView.swift index 5c5edada2..168dd8de1 100644 --- a/Course/Course/Presentation/Details/CourseDetailsView.swift +++ b/Course/Course/Presentation/Details/CourseDetailsView.swift @@ -47,7 +47,9 @@ public struct CourseDetailsView: View { .padding(.horizontal) }.frame(width: proxy.size.width) } else { - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + await viewModel.getCourseDetail(courseID: courseID, withProgress: false) + }) { VStack(alignment: .leading) { if let courseDetails = viewModel.courseDetails { @@ -131,12 +133,7 @@ public struct CourseDetailsView: View { } } } - } onRefresh: { - Task { - await viewModel.getCourseDetail(courseID: courseID, withProgress: false) - } - }.coordinateSpace(name: "pullToRefresh") - .frameLimit() + }.frameLimit() .onRightSwipeGesture { viewModel.router.back() } diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index c07019c96..f58b2438e 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -37,7 +37,9 @@ public struct CourseOutlineView: View { GeometryReader { proxy in VStack(alignment: .center) { // MARK: - Page Body - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) + }) { VStack(alignment: .leading) { ZStack { // MARK: - Course Banner @@ -134,10 +136,7 @@ public struct CourseOutlineView: View { } Spacer(minLength: 84) } - } onRefresh: { - await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) - }.coordinateSpace(name: "pullToRefresh") - .frameLimit() + }.frameLimit() .onRightSwipeGesture { viewModel.router.back() } diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/DashboardView.swift index 59747da5d..4be6e62e7 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/DashboardView.swift @@ -33,7 +33,9 @@ public struct DashboardView: View { // MARK: - Page body VStack(alignment: .center) { - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + await viewModel.getMyCourses(page: 1, refresh: true) + }) { Group { if viewModel.courses.isEmpty && !viewModel.fetchInProgress { EmptyPageIcon() @@ -89,12 +91,7 @@ public struct DashboardView: View { } } } - } onRefresh: { - Task { - await viewModel.getMyCourses(page: 1, refresh: true) - } - }.coordinateSpace(name: "pullToRefresh") - .frameLimit() + }.frameLimit() }.padding(.top, 8) // MARK: - Offline mode SnackBar diff --git a/Discovery/Discovery/Presentation/DiscoveryView.swift b/Discovery/Discovery/Presentation/DiscoveryView.swift index e8f6f52d4..8ca698572 100644 --- a/Discovery/Discovery/Presentation/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/DiscoveryView.swift @@ -66,56 +66,55 @@ public struct DiscoveryView: View { .padding(.bottom, 20) ZStack { - RefreshableScrollView { - LazyVStack(spacing: 0) { - HStack { - discoveryNew - .padding(.horizontal, 20) - .padding(.bottom, 20) - Spacer() - }.padding(.leading, 10) - ForEach(Array(viewModel.courses.enumerated()), id: \.offset) { index, course in - CourseCellView( - model: course, - type: .discovery, - index: index, - cellsCount: viewModel.courses.count - ).padding(.horizontal, 24) - .onAppear { - Task { - await viewModel.getDiscoveryCourses(index: index) - } - } - .onTapGesture { - viewModel.discoveryCourseClicked( - courseID: course.courseID, - courseName: course.name - ) - router.showCourseDetais( - courseID: course.courseID, - title: course.name - ) - } - } - - // MARK: - ProgressBar - if viewModel.nextPage <= viewModel.totalPages { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) - } - VStack {}.frame(height: 40) - } - } onRefresh: { + RefreshableScrollViewCompat(action: { viewModel.totalPages = 1 viewModel.nextPage = 1 Task { await viewModel.discovery(page: 1, withProgress: false) } + }) { + LazyVStack(spacing: 0) { + HStack { + discoveryNew + .padding(.horizontal, 20) + .padding(.bottom, 20) + Spacer() + }.padding(.leading, 10) + ForEach(Array(viewModel.courses.enumerated()), id: \.offset) { index, course in + CourseCellView( + model: course, + type: .discovery, + index: index, + cellsCount: viewModel.courses.count + ).padding(.horizontal, 24) + .onAppear { + Task { + await viewModel.getDiscoveryCourses(index: index) + } + } + .onTapGesture { + viewModel.discoveryCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + router.showCourseDetais( + courseID: course.courseID, + title: course.name + ) + } + } + + // MARK: - ProgressBar + if viewModel.nextPage <= viewModel.totalPages { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) + } + VStack {}.frame(height: 40) + } }.frameLimit() - .coordinateSpace(name: "pullToRefresh") } }.padding(.top, 8) diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index b079cb94c..69e666844 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -40,7 +40,14 @@ public struct ResponsesView: View { ScrollViewReader { scroll in VStack { ZStack(alignment: .top) { - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + viewModel.comments = [] + _ = await viewModel.getComments( + commentID: commentID, + parentComment: parentComment, + page: 1 + ) + }) { VStack { if let comments = viewModel.postComments { ParentCommentView( @@ -134,14 +141,7 @@ public struct ResponsesView: View { .onRightSwipeGesture { viewModel.router.back() } - } onRefresh: { - viewModel.comments = [] - Task { - _ = await viewModel.getComments(commentID: commentID, - parentComment: parentComment, page: 1) - } - }.coordinateSpace(name: "pullToRefresh") - .frameLimit() + }.frameLimit() if !parentComment.closed { FlexibleKeyboardInputView( diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift index fc2012e6e..92555f692 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift @@ -95,7 +95,11 @@ public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { .getCommentResponses(commentID: commentID, page: page) self.totalPages = pagination.numPages self.itemsCount = pagination.count - self.comments += comments + if page == 1 { + self.comments = comments + } else { + self.comments += comments + } postComments = generateCommentsResponses(comments: self.comments, parentComment: parentComment) return true } catch let error { diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 7bbbdae0a..bdc5ae96a 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -34,7 +34,9 @@ public struct ThreadView: View { ScrollViewReader { scroll in VStack { ZStack(alignment: .top) { - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + _ = await viewModel.getPosts(thread: thread, page: 1) + }) { VStack { if let comments = viewModel.postComments { ParentCommentView( @@ -140,11 +142,7 @@ public struct ThreadView: View { onBackTapped() viewModel.sendUpdateUnreadState() } - } onRefresh: { - Task { - _ = await viewModel.getPosts(thread: thread, page: 1) - } - }.coordinateSpace(name: "pullToRefresh") + } if !thread.closed { FlexibleKeyboardInputView( hint: DiscussionLocalization.Thread.addResponse, diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift index 35870b6ee..db10d8039 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift @@ -146,7 +146,11 @@ public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { .getDiscussionComments(threadID: thread.id, page: page) self.totalPages = pagination.numPages self.itemsCount = pagination.count - self.comments += comments + if page == 1 { + self.comments = comments + } else { + self.comments += comments + } postComments = generateComments(comments: self.comments, thread: thread) } fetchInProgress = false diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index 4629b9956..722e1b9fd 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -53,7 +53,9 @@ public struct DiscussionTopicsView: View { // MARK: - Page Body VStack { ZStack(alignment: .top) { - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + await viewModel.getTopics(courseID: self.courseID, withProgress: false) + }) { VStack { if let topics = viewModel.discussionTopics { HStack { @@ -125,12 +127,7 @@ public struct DiscussionTopicsView: View { } Spacer(minLength: 84) } - } onRefresh: { - Task { - await viewModel.getTopics(courseID: self.courseID, withProgress: false) - } - }.coordinateSpace(name: "pullToRefresh") - .frameLimit() + }.frameLimit() .onRightSwipeGesture { router.back() } diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 55f0d09ab..d88441939 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -51,39 +51,47 @@ public struct PostsView: View { VStack { ZStack(alignment: .top) { VStack { - VStack { - HStack { - Group { - Button(action: { - listAnimation = .easeIn - viewModel.generateButtons(type: .filter) - showingAlert = true - }, label: { - CoreAssets.filter.swiftUIImage - Text(viewModel.filterTitle.localizedValue) - }) - Spacer() - Button(action: { - listAnimation = .easeIn - viewModel.generateButtons(type: .sort) - showingAlert = true - }, label: { - CoreAssets.sort.swiftUIImage - Text(viewModel.sortTitle.localizedValue) - }) - }.foregroundColor(Theme.Colors.accentColor) - } .font(Theme.Fonts.labelMedium) - .padding(.horizontal, 24) - .padding(.vertical, 12) - .shadow(color: Theme.Colors.shadowColor, - radius: 12, y: 4) - .background( - Theme.Colors.background - ) - Divider().offset(y: -8) - } + VStack { + HStack { + Group { + Button(action: { + listAnimation = .easeIn + viewModel.generateButtons(type: .filter) + showingAlert = true + }, label: { + CoreAssets.filter.swiftUIImage + Text(viewModel.filterTitle.localizedValue) + }) + Spacer() + Button(action: { + listAnimation = .easeIn + viewModel.generateButtons(type: .sort) + showingAlert = true + }, label: { + CoreAssets.sort.swiftUIImage + Text(viewModel.sortTitle.localizedValue) + }) + }.foregroundColor(Theme.Colors.accentColor) + } .font(Theme.Fonts.labelMedium) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .shadow(color: Theme.Colors.shadowColor, + radius: 12, y: 4) + .background( + Theme.Colors.background + ) + Divider().offset(y: -8) + } .frameLimit() - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + listAnimation = nil + viewModel.resetPosts() + _ = await viewModel.getPosts( + courseID: courseID, + pageNumber: 1, + withProgress: false + ) + }) { let posts = Array(viewModel.filteredPosts.enumerated()) if posts.count >= 1 { LazyVStack { @@ -95,15 +103,16 @@ public struct PostsView: View { .foregroundColor(Theme.Colors.textPrimary) Spacer() Button(action: { - router.createNewThread(courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } + router.createNewThread( + courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage(onSuccess: { + withAnimation { + scroll.scrollTo(1) + } + }) }) - }) }, label: { VStack { CoreAssets.addComment.swiftUIImage @@ -168,15 +177,7 @@ public struct PostsView: View { .padding(.top, 100) } } - } onRefresh: { - listAnimation = nil - viewModel.resetPosts() - Task { - _ = await viewModel.getPosts(courseID: courseID, - pageNumber: 1, - withProgress: false) - } - }.coordinateSpace(name: "pullToRefresh") + } }.frameLimit() .animation(listAnimation) .onRightSwipeGesture { diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index fca2e192f..2d61ed18c 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -22,7 +22,9 @@ public struct ProfileView: View { public var body: some View { ZStack(alignment: .top) { // MARK: - Page Body - RefreshableScrollView { + RefreshableScrollViewCompat(action: { + await viewModel.getMyProfile(withProgress: false) + }) { VStack { if viewModel.isShowProgress { ProgressBar(size: 40, lineWidth: 8) @@ -190,12 +192,7 @@ public struct ProfileView: View { Spacer() } } - } onRefresh: { - Task { - await viewModel.getMyProfile(withProgress: false) - } - }.coordinateSpace(name: "pullToRefresh") - .frameLimit(sizePortrait: 420) + }.frameLimit(sizePortrait: 420) .padding(.top, 8) .onChange(of: settingsTapped, perform: { _ in if let userModel = viewModel.userModel {