Разработка
Осваиваем скроллинг в SwiftUI: реализация кастомной прокрутки
Настроив ScrollTargetBehavior, мы не только добились горизонтального пейджинга, но и можем расширить его для поддержки вертикальной прокрутки или более сложной логики прокрутки.
Начиная с iOS 17 в SwiftUI появилась функция scrollTargetBehavior
, позволяющая разработчикам управлять поведением прокрутки с большей точностью. Будь то выравнивание представлений или реализация пользовательских эффектов пагинации, ScrollTargetBehavior
обеспечивает надежную поддержку. Что еще более важно, разработчики могут создавать собственные реализации ScrollTargetBehavior
для удовлетворения конкретных потребностей. В этой статье на реальном примере шаг за шагом будет показано, как использовать scrollTargetBehavior
и в конечном итоге реализовать пользовательскую логику управления прокруткой.
Проблема: ограничения пейджинга по умолчанию
Несколько дней назад один из разработчиков поднял проблему с scrollTargetBehavior
: при использовании стандартного поведения пагинации прокрутка в ландшафтном режиме (Landscape) приводила к смещению, не позволяя привязаться к нужной странице.
struct Step0: View {
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(0 ..< 20) { page in
Text(page, format: .number)
.font(.title)
// Set the width of each child view to match the ScrollView container's width
.containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0, alignment: .center)
.frame(height: 200)
.background(.secondary.opacity(0.3))
.border(.red, width: 2)
}
}
}
.border(.blue, width: 2)
.scrollTargetBehavior(.paging)
}
}
Эта проблема удивила меня, поскольку логика работы с пейджингом должна быть относительно простой. Чтобы быстро решить проблему, я сначала попробовал использовать библиотеку Introspect, чтобы напрямую включить свойство isPagingEnabled
базового UIScrollView
.
xxxxxxxxxx
ScrollView {
....
}
.introspect(.scrollView, on: .iOS(.v17), .iOS(.v18)) {
$0.isPagingEnabled = true
}
Однако результат был идентичен использованию .scrollTargetBehavior(.paging)
, с той же проблемой смещения в ландшафтном режиме. Это навело меня на мысль, что стандартное поведение пейджинга, возможно, на самом деле не полагается на ScrollTargetBehavior
для своей реализации.
Я сообщил об этой проблеме в Apple (FB16486510).
Альтернативное решение: может ли viewAligned устранить проблему?
Учитывая, что ширина каждого представления в коде разработчика точно соответствовала ширине контейнера прокрутки, я предложил попробовать viewAligned
— режим, который обеспечивает выравнивание краев представления с краями контейнера в конце прокрутки.
xxxxxxxxxx
struct Step1: View {
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(0 ..< 20) { page in
Text(page, format: .number)
.font(.title)
.containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0, alignment: .center)
.frame(height: 200)
.background(.secondary.opacity(0.3))
.border(.red, width: 2)
}
}
// viewAligned requires scrollTargetLayout
.scrollTargetLayout()
}
.border(.blue, width: 2)
// Limit scrolling to one view at a time
.scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne))
}
}
viewAligned
предлагает несколько уровней точности управления:
alwaysByOne
: Прокрутка одного View за разalwaysByFew
: Прокрутка небольшого количества представленийnever
: Нет ограничений на количество прокручиваемых представлений
Однако во время тестирования я обнаружил, что alwaysByOne
не гарантирует прокрутку только одного представления за раз. В текущем коде, поскольку ширина каждого дочернего представления совпадает с шириной контейнера прокрутки, случайно достигается эффект, похожий на прокрутку страниц. Но если дочерние представления более узкие, расстояние прокрутки становится непредсказуемым.
Кроме того, viewAligned
требует, чтобы содержимое контейнера прокрутки состояло из нескольких дочерних представлений, что делает его непригодным для таких сценариев, как Swift Charts. На этом этапе реализация кастомного поведения пейджинга казалась единственным жизнеспособным решением.
Реализация кастомного пейджинга
ScrollTargetBehavior
позволяет разработчикам настраивать поведение прокрутки. Его объявление выглядит следующим образом:
xxxxxxxxxx
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public protocol ScrollTargetBehavior {
/// Updates the scroll target position
func updateTarget(_ target: inout ScrollTarget, context: Self.TargetContext)
/// The context in which a scroll behavior updates the scroll target.
typealias TargetContext = ScrollTargetBehaviorContext
}
SwiftUI вызывает updateTarget
в конце жеста прокрутки, позволяя разработчикам регулировать положение target
.
ScrollTargetBehaviorContext
предоставляет следующую ключевую информацию:
originalTarget
: положение цели в начале жестаvelocity
: вектор скоростиcontentSize
: размер прокручиваемого содержимогоcontainerSize
: размер контейнера прокруткиaxis
: ось прокрутки
ScrollTarget
не только задает позицию цели, но и предоставляет цель прокрутки, рассчитанную ScrollView
на основе жеста.
Далее мы итеративно доработаем пользовательскую реализацию пейджинга в нескольких версиях.
Версия 1: Простая реализация на основе скорости
В первой версии мы определяем направление прокрутки на основе вектора скорости и настраиваем целевую позицию путем добавления или вычитания ширины контейнера прокрутки.
xxxxxxxxxx
struct CustomHorizontalPagingBehavior: ScrollTargetBehavior {
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
// Current ScrollView width
let scrollViewWidth = context.containerSize.width
// Adjust the target position based on scroll direction
if context.velocity.dx > 0 {
// Scroll right: target position = starting position + ScrollView width
target.rect.origin.x = context.originalTarget.rect.minX + scrollViewWidth
} else if context.velocity.dx < 0 {
// Scroll left: target position = starting position - ScrollView width
target.rect.origin.x = context.originalTarget.rect.minX - scrollViewWidth
}
}
}
extension ScrollTargetBehavior where Self == CustomHorizontalPagingBehavior {
static var horizontalPaging: CustomHorizontalPagingBehavior { .init() }
}
// Usage
.scrollTargetBehavior(.horizontalPaging)
Эта реализация кажется разумной, но имеет явный недостаток: если жест прокрутки заканчивается с нулевой скоростью, действие листания не срабатывает.
Версия 2: улучшенная реализация на основе расстояния прокрутки
Во второй версии мы определяем направление прокрутки, вычисляя разницу между целевой и начальной позициями. Затем мы решаем, нужно ли переходить на страницу, основываясь на заданном условии (например, расстояние прокрутки превышает 1/3 ширины контейнера).
xxxxxxxxxx
struct CustomHorizontalPagingBehavior: ScrollTargetBehavior {
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
// Current ScrollView width
let scrollViewWidth = context.containerSize.width
// Scroll distance
let distance = context.originalTarget.rect.minX - target.rect.minX
// Adjust the target position based on scroll distance
// If the scroll distance exceeds 1/3 of the ScrollView width, switch to the next page
if abs(distance) > scrollViewWidth / 3 {
if distance > 0 {
target.rect.origin.x = context.originalTarget.rect.minX - scrollViewWidth
} else {
target.rect.origin.x = context.originalTarget.rect.minX + scrollViewWidth
}
} else {
// If the scroll distance is less than 1/3, return to the original position
target.rect.origin.x = context.originalTarget.rect.minX
}
}
}
Однако в этой версии все еще есть проблемы: если прокрутка происходит до того, как предыдущая прокрутка полностью остановилась, начальная позиция может быть уже смещена, что приводит к неточному пейджингу.
Версия 3: надежное управление пейджингом
В третьей версии мы не только решаем предыдущие проблемы, но и справляемся со следующими крайними случаями:
- Размер содержимого меньше размера контейнера
- Размер содержимого не является точно кратным размера контейнера
- Убеждаемся, что позиция остановки находится в допустимых пределах
xxxxxxxxxx
struct CustomHorizontalPagingBehavior: ScrollTargetBehavior {
enum Direction {
case left, right, none
}
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
let scrollViewWidth = context.containerSize.width
let contentWidth = context.contentSize.width
// If the content width is less than or equal to the ScrollView width, align to the leftmost position
guard contentWidth > scrollViewWidth else {
target.rect.origin.x = 0
return
}
let originalOffset = context.originalTarget.rect.minX
let targetOffset = target.rect.minX
// Determine the scroll direction by comparing the original offset with the target offset
let direction: Direction = targetOffset > originalOffset ? .left : (targetOffset < originalOffset ? .right : .none)
guard direction != .none else {
target.rect.origin.x = originalOffset
return
}
let thresholdRatio: CGFloat = 1 / 3
// Calculate the remaining content width based on the scroll direction and determine the drag threshold
let remaining: CGFloat = direction == .left
? (contentWidth - context.originalTarget.rect.maxX)
: (context.originalTarget.rect.minX)
let threshold = remaining <= scrollViewWidth ? remaining * thresholdRatio : scrollViewWidth * thresholdRatio
let dragDistance = originalOffset - targetOffset
var destination: CGFloat = originalOffset
if abs(dragDistance) > threshold {
// If the drag distance exceeds the threshold, adjust the target to the previous or next page
destination = dragDistance > 0 ? originalOffset - scrollViewWidth : originalOffset + scrollViewWidth
} else {
// If the drag distance is within the threshold, align based on the scroll direction
if direction == .right {
// Scroll right (page left), round up
destination = ceil(originalOffset / scrollViewWidth) * scrollViewWidth
} else {
// Scroll left (page right), round down
destination = floor(originalOffset / scrollViewWidth) * scrollViewWidth
}
}
// Boundary handling: Ensure the destination is within valid bounds and aligns with pages
let maxOffset = contentWidth - scrollViewWidth
let boundedDestination = min(max(destination, 0), maxOffset)
if boundedDestination >= maxOffset * 0.95 {
// If near the end, snap to the last possible position
destination = maxOffset
} else if boundedDestination <= scrollViewWidth * 0.05 {
// If near the start, snap to the beginning
destination = 0
} else {
if direction == .right {
// For right-to-left scrolling, calculate from the right end
let offsetFromRight = maxOffset - boundedDestination
let pageFromRight = round(offsetFromRight / scrollViewWidth)
destination = maxOffset - (pageFromRight * scrollViewWidth)
} else {
// For left-to-right scrolling, keep original behavior
let pageNumber = round(boundedDestination / scrollViewWidth)
destination = min(pageNumber * scrollViewWidth, maxOffset)
}
}
target.rect.origin.x = destination
}
}
Эта версия легко справляется со случаями, когда размер содержимого не является точным кратным размеру контейнера.
xxxxxxxxxx
struct Step2: View {
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(0 ..< 10) { page in
Text(page, format: .number)
.font(.title)
// Set the width to 1/3 of the ScrollView width
.containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 0, alignment: .center)
.frame(height: 200)
.background(.secondary.opacity(0.3))
.border(.red, width: 2)
}
}
}
.border(.blue, width: 2)
.scrollTargetBehavior(.horizontalPaging)
}
}
Я поделился реализацией горизонтальной и вертикальной пагинации в этом Gist. Не стесняйтесь посмотреть и проверить его.
За пределами пейджинга
Настроив ScrollTargetBehavior
, мы не только добились горизонтального пейджинга, но и можем расширить его для поддержки вертикальной прокрутки или более сложной логики прокрутки. Например, комбинируя скорость прокрутки (velocity), можно реализовать многостраничную прокрутку при быстром пролистывании и одностраничную при легком пролистывании.
Кроме того, scrollTargetBehavior
может служить инструментом для динамической загрузки данных. По сравнению с использованием onAppear
в ленивых представлениях, оно позволяет нам запускать загрузку данных раньше во время прокрутки, тем самым улучшая проблему скачка прокрутки, вызванную динамической загрузкой данных в ленивых контейнерах SwiftUI.
Хотя onScrollGeometryChange
позволяет добиться аналогичной функциональности, он доступен только в iOS 18 и более поздних версиях, в то время как ScrollTargetBehavior
поддерживается с iOS 17, что делает его более широко применимым.
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.11
-
Новости1 неделя назад
Видео и подкасты о мобильной разработке 2025.14
-
Видео и подкасты для разработчиков3 недели назад
Javascript для бэкенда – отличная идея: Node.js, NPM, Typescript
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.12