На момент публикации - 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. Тем не менее, на сегодняшний день существует не так много библиотек, предоставляющих схожий механизм обновления.

  1. https://github.com/globulus/swiftui-pull-to-refresh

  2. https://github.com/AppPear/SwiftUI-PullToRefresh

  3. https://github.com/wxxsw/Refresh

  4. https://blckbirds.com/post/mastering-pull-to-refresh-in-swiftui/

  5. https://developer.apple.com/wwdc22/

Комментарии (1)


  1. mamkin_developer
    18.05.2022 23:28

    Неплохо. А что делать будете с двумя такими scroll view? OffsetPreferenceKey то для них будет один и тот же.