При создании различных типов прокручиваемых пользовательских интерфейсов очень часто требуется наблюдать за текущей позицией прокрутки (или смещением содержимого, как это называет UIScrollView), чтобы инициировать изменения макета, загружать дополнительные данные при необходимости или выполнять другие виды действий в зависимости от того, какой контент просматривает пользователь в данный момент.
Однако, когда дело доходит до ScrollView SwiftUI, в настоящее время (на момент написания) не существует встроенного способа выполнения таких наблюдений за прокруткой. Хотя встраивание ScrollViewReader во view (представление, вью, вьюшка) прокрутки позволяет нам изменять положение прокрутки в коде, оно, как ни странно (особенно учитывая его имя), не позволяет нам каким‑либо образом считывать текущее смещение содержимого.
Один из способов решить эту проблему — использовать богатые возможности UIKit UIScrollView, который благодаря протоколу делегата и методу scrollViewDidScroll обеспечивает простой способ получать уведомления всякий раз, когда происходит прокрутка. Однако, несмотря на то, что я обычно большой поклонник использования UIViewRepresentable и других механизмов взаимодействия SwiftUI/UIKit, в этом случае нам пришлось бы написать довольно много дополнительного кода, чтобы преодолеть разрыв между двумя фреймворками.
Это в основном потому, что, по крайней мере, на iOS мы можем встраивать контент SwiftUI только в UIHostingController, а не в самоуправляемый UIView. Итак, если бы мы хотели создать пользовательскую, наблюдаемую версию ScrollView с использованием UIScrollView, нам пришлось бы обернуть эту реализацию во view контроллер, а затем управлять взаимосвязью между нашим UIHostingController и такими компонентами, как клавиатура, размер содержимого view прокрутки, вставки безопасных зон и так далее. Это возможно, но, тем не менее, добавляется изрядное количество дополнительной работы и сложности.
Итак, давайте вместо этого посмотрим, сможем ли мы найти полностью нативный для SwiftUI способ выполнения таких наблюдений за смещением контента.
Разрешение фреймов с помощью GeometryReader
Одна вещь, которую важно понять, прежде чем мы начнём, заключается в том, что и UIScrollView, и SwiftUI ScrollView выполняют свою прокрутку, смещая контейнер, в котором размещён наш реальный прокручиваемый контент. Затем они прикрепляют этот контейнер к своим границам, чтобы создать иллюзию движения области просмотра. Так что, если мы сможем найти способ наблюдать за фреймом этого контейнера, то, по сути, мы найдём способ наблюдать за смещением содержимого прокрутки.
Вот тут‑то и появляется наш старый добрый друг GeometryReader (без него не было бы подходящего обходного пути для макета SwiftUI, верно?). Хотя GeometryReader в основном используется для доступа к размеру view, в котором оно размещено (или, точнее, к предлагаемому размеру этого view), у него также есть еще один хитрый трюк в рукаве — его можно попросить прочитать кадр view, текущий вид относительно заданной системы координат.
Чтобы использовать эту возможность, давайте начнём с создания PositionObservingView, который позволит нам привязать значение CGPoint к текущей позиции этого view относительно CoordinateSpace, которое мы также передадим в качестве аргумента. Затем наше новое view встроит GeometryReader в качестве фона (что заставит это средство чтения геометрии иметь тот же размер, что и само view) и назначит исходную точку разрешённого кадра в качестве нашего смещения с помощью ключа настройки, например, так:
struct PositionObservingView: View {
var coordinateSpace: CoordinateSpace
@Binding var position: CGPoint
@ViewBuilder var content: () -> Content
var body: some View {
content()
.background(GeometryReader { geometry in
Color.clear.preference(
key: PreferenceKey.self,
value: geometry.frame(in: coordinateSpace).origin
)
})
.onPreferenceChange(PreferenceKey.self) { position in
self.position = position
}
}
}
Чтобы узнать больше о том, как атрибут @ViewBuilder можно использовать при создании пользовательских view контейнера SwiftUI, ознакомьтесь с этой статьей.
Причина, по которой мы используем описанную выше систему предпочтений SwiftUI, заключается в том, что наш GeometryReader будет вызываться как часть процесса обновления view, и нам не разрешено напрямую изменять состояние нашего view во время этого процесса. Таким образом, используя предпочтение вместо этого, мы можем доставлять наши значения CGPoint в наше view асинхронным образом, что затем позволяет нам назначать эти значения для нашей привязки позиции.
Теперь всё, что нам необходимо сделать, это реализовать тип PreferenceKey, который использовался выше, и всё готово:
private extension PositionObservingView {
struct PreferenceKey: SwiftUI.PreferenceKey {
static var defaultValue: CGPoint { .zero }
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
value = nextValue()
}
}
}
Нам не требуется реализовывать какой‑либо сложный алгоритм сокращения, описанный выше, поскольку у нас будет только одно view, доставляющее значения с использованием этого ключа предпочтения в любой заданной иерархии (поскольку наша реализация полностью содержится в нашем view PositionObservingView).
Итак, теперь у нас есть view, способное считывать и отслеживать своё собственное положение в заданной системе координат. Давайте теперь используем это view для создания оболочки ScrollView, которая позволит нам достичь нашей первоначальной цели — иметь возможность считывать текущее смещение содержимого в таком view прокрутки.
От позиции до смещения контента
Наша новая оболочка ScrollView, по сути, будет иметь две обязанности: во‑первых, ей необходимо преобразовать позицию нашего внутреннего объекта PositionObservingView в текущую позицию прокрутки (или смещение содержимого), а во‑вторых, ей также потребуется определить CoordinateSpace, которая внутреннее view может использовать для разрешения своей позиции. Кроме того, она просто перенаправит свои параметры конфигурации в базовый ScrollView, чтобы мы могли решить, с какими осями мы работаем, чтобы работало каждое view прокрутки, и чтобы мы могли решить, отображать ли какие‑либо индикаторы прокрутки.
Хорошей новостью является то, что преобразовать положение нашего внутреннего view в смещение содержимого так же просто, как инвертировать оба компонента x и y этих значений CGPoint. Это связано с тем, что, как обсуждалось ранее, смещение содержимого view прокрутки — это, по сути, просто расстояние, на которое контейнер был перемещён относительно границ view прокрутки.
Итак, давайте продолжим и реализуем наше пользовательское view прокрутки, которое мы назовем OffsetObservingScrollView (в данном случае указание ContentOffset кажется слишком подробным):
struct OffsetObservingScrollView: View {
var axes: Axis.Set = [.vertical]
var showsIndicators = true
@Binding var offset: CGPoint
@ViewBuilder var content: () -> Content
// The name of our coordinate space doesn't have to be
// stable between view updates (it just needs to be
// consistent within this view), so we'll simply use a
// plain UUID for it:
private let coordinateSpaceName = UUID()
var body: some View {
ScrollView(axes, showsIndicators: showsIndicators) {
PositionObservingView(
coordinateSpace: .named(coordinateSpaceName),
position: Binding(
get: { offset },
set: { newOffset in
offset = CGPoint(
x: -newOffset.x,
y: -newOffset.y
)
}
),
content: content
)
}
.coordinateSpace(name: coordinateSpaceName)
}
}
Обратите внимание, как мы можем создать полностью пользовательскую Binding (привязку) для параметра позиции нашего внутреннего view, определив геттер и сеттер с помощью замыканий. Это отличный вариант в ситуациях, подобных приведённой выше, когда нам необходимо преобразовать значение перед его назначением другой Binding.
Вот и всё! Теперь у нас есть встроенная замена встроенному SwiftUI ScrollView. Эта замена позволяет нам наблюдать за текущим смещением содержимого, которое мы затем можем привязать к любому свойству состояния, которое нам необходимо. Например, чтобы изменить макет заголовка, просматривать, сообщать о событиях аналитики на наш сервер или выполнять любую другую операцию прокрутки на основании положения. Вы можете найти полный пример, который использует вышеприведенный OffsetObservingScrollView для реализации сворачиваемого view заголовка прямо здесь.
Я надеюсь, что вы нашли эту статью полезной. Если у вас есть какие‑либо вопросы, комментарии или отзывы, не стесняйтесь обращаться ко мне на Mastodon или отправьте мне электронное письмо. Спасибо за прочтение!