Разработка
Осваиваем скроллинг в 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
.
ScrollView { .... } .introspect(.scrollView, on: .iOS(.v17), .iOS(.v18)) { $0.isPagingEnabled = true }
Однако результат был идентичен использованию .scrollTargetBehavior(.paging)
, с той же проблемой смещения в ландшафтном режиме. Это навело меня на мысль, что стандартное поведение пейджинга, возможно, на самом деле не полагается на ScrollTargetBehavior
для своей реализации.
Я сообщил об этой проблеме в Apple (FB16486510).
Альтернативное решение: может ли viewAligned устранить проблему?
Учитывая, что ширина каждого представления в коде разработчика точно соответствовала ширине контейнера прокрутки, я предложил попробовать viewAligned
— режим, который обеспечивает выравнивание краев представления с краями контейнера в конце прокрутки.
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
позволяет разработчикам настраивать поведение прокрутки. Его объявление выглядит следующим образом:
@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: Простая реализация на основе скорости
В первой версии мы определяем направление прокрутки на основе вектора скорости и настраиваем целевую позицию путем добавления или вычитания ширины контейнера прокрутки.
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 ширины контейнера).
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: надежное управление пейджингом
В третьей версии мы не только решаем предыдущие проблемы, но и справляемся со следующими крайними случаями:
- Размер содержимого меньше размера контейнера
- Размер содержимого не является точно кратным размера контейнера
- Убеждаемся, что позиция остановки находится в допустимых пределах
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 } }
Эта версия легко справляется со случаями, когда размер содержимого не является точным кратным размеру контейнера.
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 недели назад
Конец программирования в том виде, в котором мы его знаем
-
Видео и подкасты для разработчиков1 неделя назад
Как устроена мобильная архитектура. Интервью с тех. лидером юнита «Mobile Architecture» из AvitoTech
-
Магазины приложений3 недели назад
Магазин игр Aptoide запустился на iOS в Европе
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.8