Pull to refresh в SwiftUI

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

На момент публикации - 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/

Источник: https://habr.com/ru/post/666410/


Интересные статьи

Интересные статьи

Всем привет! Не так давно на работе в рамках тестирования нового бизнес-процесса мне понадобилась возможность авторизации под разными пользователями. Переход в соответствующий р...
Предыстория Когда-то у меня возникла необходимость проверять наличие неотправленных сообщений в «1С-Битрикс: Управление сайтом» (далее Битрикс) и получать уведомления об этом. Пробле...
Всем привет. Когда я искал информацию о журналировании (аудите событий) в Bitrix, на Хабре не было ни чего, в остальном рунете кое что было, но кто же там найдёт? Для пополнения базы знаний...
Возможность интеграции с «1С» — это ключевое преимущество «1С-Битрикс» для всех, кто профессионально занимается продажами в интернете, особенно для масштабных интернет-магазинов.
Магия SwiftUI Вы пробовали добавить в VStack больше 10 вьюх? var body: some View { VStack { Text("Placeholder1") Text("Placeholder2") // ... тут вь...