На момент публикации — 10 мая 2022, SwiftUI имеет всего лишь refreshable(action:) модификатор для List компонента, чтобы пользователь имел возможность обновить контент на экране (так называемый pull to refresh). Очевидно, что если разработчику потребуется отобразить список в виде отличном, от того, который предоставляет List (например, в несколько колон), то, к сожалению, придется смириться с тем, что контент на экране обновить с помощью pull to refresh будет нельзя. Или же все-таки можно?…
Задача
Требуется некоторый компонент, который бы отображал в scroll view контент и выполнял некоторую функцию при оттягивании контента вниз, аналогично pull to refresh технологии. При этом предпочтительно использовать новую систему concurrency, которая дебютировала в iOS 15. Компонент должен быть реализован в виде отдельного модуля, формирующего некоторую абстракцию для того, чтобы его легче было поддерживать. Другими словами, компонент должен распространяться в некоторой библиотеке и иметь возможность быть добавленным в проект с помощью менеджера зависимостей.
RefreshableScrollView
Не стоит тянуть время, сразу к source коду:
import SwiftUI public struct RefreshableScrollView<Content>: View where Content: View { // MARK: - Types private enum RefreshState { case ready case refreshing } public typealias Completion = () async -> Void // MARK: - Properties private let feedbackGenerator = UIImpactFeedbackGenerator() @State private var state: RefreshState = .ready @State private var canChangeState = true @State private var refreshing = false @State private var iconRotation: Angle = .degrees(0) @State private var pullProgress: CGFloat = 0 @State private var pullOffset: CGFloat = 0 private let onRefresh: (@escaping Completion) async -> Void @ViewBuilder private let content: () -> Content private var isReady: Bool { return state == .ready && canChangeState } public var body: some View { GeometryReader { proxy in let pullThreshold = proxy.size.height * 0.15 ZStack(alignment: .top) { ScrollView { content() .background { GeometryReader { proxy in let frame = proxy.frame(in: .named("scrollView")) Color.clear.preference(key: OffsetPreferenceKey.self, value: frame.origin) } } } .onPreferenceChange(OffsetPreferenceKey.self) { offset in canChangeState = canChangeState || offset.y <= 0 pullProgress = pullProgress(offset: offset, threshold: pullThreshold) pullOffset = offset.y - pullThreshold if isReady && pullProgress == 1 { refresh() } refreshing = !isReady } PullToRefresh( iconSystemName: "arrow.triangle.2.circlepath.circle", progress: pullProgress, refreshing: refreshing ) .frame(height: pullThreshold) .offset(y: pullOffset) } .coordinateSpace(name: "scrollView") } } // MARK: - Lifecycle public init(onRefresh: @escaping (@escaping Completion) async -> Void, @ViewBuilder content: @escaping () -> Content) { self.onRefresh = onRefresh self.content = content } // MARK: - Methods private func pullProgress(offset: CGPoint, threshold: CGFloat) -> CGFloat { let progress = offset.y / threshold return progress.bounded(bottom: 0, top: 1) } private func refresh() { feedbackGenerator.impactOccurred() state = .refreshing canChangeState = false Task { await onRefresh { await MainActor.run { state = .ready } } } } }
По порядку. При инициализации компонента передается closure, которое будет вызвано в момент срабатывания pull to refresh, а также контент в виде @ViewBuilder,который встраивается в ScrollView. При этом в ScrollView добавляется PullToRefresh view, при необходимости которому задается offset, равный offset контента минус собственная высота. Таким образом, при отображении экрана PullToRefresh view как бы находится за границей ScrollView, а при скроле появляется.
Стоит разъяснить наличие canChangeState свойства. Дело в том, что в случае, если onRefresh функция заканчивает свое выполнение до того, как пользователь отпустил палец, то без проверки canChangeState свойства, было бы вызвано повторное срабатывание onRefresh функции. Имея такое свойство, до тех пор, пока PullToRefresh view не вернется на исходную позицию, не возможно будет повторить onRefresh.
В остальном предельно ясно что и для чего используется. Метод pullProgress(offset:threshold:) рассчитывает прогресс срабатывания (pullProgress) от 0 до 1, а метод refresh(), собственно, запускает анимацию обновления, вызывает haptic engine и выполняет onRefresh функцию.
Наверняка, внимательного читателя может заинтересовать метод bounded(bottom:top:), который используется при расчете pullProgress свойства. Код представлен ниже.
public extension Comparable { func bounded(bottom lowerBound: Self, top upperBound: Self) -> Self { var value = max(lowerBound, self) value = min(upperBound, self) return value } }
В настоящее время все еще нет у SwiftUI собственного решения для определения contentOffset в ScrollView, поэтому для этих целей используется OffsetPreferenceKey, представленный ниже.
import SwiftUI public struct OffsetPreferenceKey: PreferenceKey { public static var defaultValue: CGPoint = .zero public static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { let newValue = nextValue() value.x += newValue.x value.y += newValue.y } }
В отдельный компонент выделен PullToRefresh view, как раз таки для того, чтобы его легко можно было заменить/поддерживать.
import SwiftUI public struct PullToRefresh: View { // MARK: - Properties private let iconSystemName: String private let progress: CGFloat private let refreshing: Bool public var body: some View { VStack(spacing: 8) { RotatingView(progress: progress, animating: refreshing) { Image(systemName: iconSystemName) .resizable() .scaledToFit() .frame(width: 24, height: 24) } Text(refreshing ? "PullToRefresh.Refreshing" : "PullToRefresh.Ready") .font(.system(size: 11)) } .foregroundColor(Color(.systemGray)) .opacity(progress) } // MARK: - Lifecycle public init(iconSystemName: String, progress: CGFloat, refreshing: Bool) { self.iconSystemName = iconSystemName self.progress = progress self.refreshing = refreshing } }
Все достаточно тривиально. Некоторое изображение или иконка и текст под ним, которые меняются в зависимости от скрола (progress свойство) и выполнения onRefresh функции (refreshing свойство).
Собственно для того, чтобы анимация выполнялась плавно, и не было лишних изменений, вызванных скролом во время выполнения onRefresh функции разработан RotatingView компонент.
import SwiftUI public struct RotatingView<Content: View>: View { // MARK: - Properties @State private var rotation: Angle = .degrees(0) @State private var animatedRotation: Angle = .degrees(0) private let progress: CGFloat private let animating: Bool @ViewBuilder private let content: () -> Content public var body: some View { if animating { content() .rotationEffect(animatedRotation) .onAppear { withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: false)) { animatedRotation = .degrees(360) } } .onDisappear { animatedRotation = .degrees(0) } } else { content() .rotationEffect(rotation) .onChange(of: progress) { newValue in rotation = .degrees(360 * progress) } } } // MARK: - Lifecycle public init(progress: CGFloat, animating: Bool, content: @escaping () -> Content) { self.progress = progress self.animating = animating self.content = content } }
Стоит обратить внимание, что анимация реализована с помощью withAnimation(_:_:) функции. Поскольку offset данного компонента меняется и во время onRefresh функции, то использовать модификатор animation(_:) не представляется возможным, так как в этом случае анимируется еще и offset компонента.
Заключение
На носу WWDC 2022. Вполне возможно, что в новом обновлении SwiftUI будет представлено решение от Apple. Тем не менее, на сегодняшний день существует не так много библиотек, предоставляющих схожий механизм обновления.
ссылка на оригинал статьи https://habr.com/ru/post/666410/
Добавить комментарий