Connect with us

Разработка

Осваиваем скроллинг в 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, что делает его более широко применимым.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: