Connect with us

Разработка

Бесконечный ScrollView в SwiftUI

SwiftUI не предоставляет «бесконечную» прокрутку для этого случая. Вы можете создать её, объединив три идеи.

Опубликовано

/

     
     

Некоторые шаблоны пользовательского интерфейса предполагают бесконечную прокрутку. Вспомните карусель с избранными элементами на витрине магазина, ленту с последними товарами, карусель песен в плеере или фоновую полосу логотипов на главном экране. В этих случаях пользователь должен иметь возможность продолжать прокрутку в любом направлении, не достигая жесткой границы, и движение должно ощущаться непрерывным.

SwiftUI не предоставляет «бесконечную» прокрутку для этого случая. Вы можете создать её, объединив три идеи:

  • Отобразить элементы один раз, а затем добавить небольшой повторяющийся хвост
  • Отслеживать геометрию прокрутки, чтобы знать, когда пользователь собирается выйти за пределы «безопасного» диапазона
  • Когда смещение пересекает границу, перескочить в позиции прокрутки, сохраняя скорость, чтобы жест ощущался непрерывным

Приведенный ниже компонент реализует именно это, поддерживает как горизонтальную, так и вертикальную оси, а также включает опциональную автоматическую прокрутку.

Ключевое ограничение — предсказуемость размеров элементов. Эта реализация требует указания itemExtent — размера элемента вдоль оси прокрутки. Это позволяет вью вычислить общий объём контента и определить, сколько дубликатов нужно для заполнения области отображения (viewport).

Публичный API

Представление является дженериком над RandomAccessCollection, элементы которого соответствуют Identifiable, и принимает билдер content, поэтому по ощущениям использования близок к связке ScrollView + ForEach.

public struct InfiniteScrollView<Collection: RandomAccessCollection, Content: View>: View
where Collection.Element: Identifiable {
    private let spacing: CGFloat
    private let scrollingSpeed: CGFloat
    private let itemExtent: CGFloat
    private let axis: Axis.Set
    private let dataSource: Collection

    @ViewBuilder private let content: (Collection.Element) -> Content

    @State private var scrollState: ScrollPosition = .init()
    @State private var viewportExtent: CGFloat = .zero
    @State private var axisOffset: CGFloat = .zero
    @State private var duplicateCount: Int = .zero

    public init(
        axis: Axis.Set = .horizontal,
        spacing: CGFloat = 10,
        scrollingSpeed: CGFloat = 0,
        itemExtent: CGFloat,
        dataSource: Collection,
        @ViewBuilder content: @escaping (Collection.Element) -> Content
    ) {
        self.axis = axis
        self.spacing = spacing
        self.scrollingSpeed = scrollingSpeed
        self.itemExtent = itemExtent
        self.dataSource = dataSource
        self.content = content
    }
}

Пара ыtate-свойств выполняет основную работу:

  • scrollState — это биндинг ScrollPosition, используется для программных «перескоков» при зацикливании
  • axisOffset — отслеживает текущий сдвиг (offset) по оси прокрутки
  • duplicateCount — количество элементов, дублируемых и добавляемых после исходного набора
  • viewportExtent — размер видимой области (viewport), используется для расчёта duplicateCount

Рендеринг исходных элементов и дублирующего «хвоста»

Тело представления рендерит два блока подряд: сначала исходные данные, затем duplicateCount элементов, повторяющих начало коллекции. Код поддерживает обе ориентации — переключаясь между HStack и VStack.

public var body: some View {
    ScrollView(axis) {
        ZStack {
            if axis == .horizontal {
                HStack(spacing: spacing) {
                    HStack(spacing: spacing) {
                        ForEach(dataSource) { item in
                            content(item)
                                .frame(width: itemExtent)
                        }
                    }

                    HStack(spacing: spacing) {
                        ForEach(0..<duplicateCount, id: \.self) { index in
                            let actualIndex = index % dataSource.count
                            let itemIndex = dataSource.index(dataSource.startIndex, offsetBy: actualIndex)

                            content(dataSource[itemIndex])
                                .frame(width: itemExtent)
                        }
                    }
                }
            } else {
                VStack(spacing: spacing) {
                    VStack(spacing: spacing) {
                        ForEach(dataSource) { item in
                            content(item)
                                .frame(height: itemExtent)
                        }
                    }

                    VStack(spacing: spacing) {
                        ForEach(0..<duplicateCount, id: \.self) { index in
                            let actualIndex = index % dataSource.count
                            let itemIndex = dataSource.index(dataSource.startIndex, offsetBy: actualIndex)

                            content(dataSource[itemIndex])
                                .frame(height: itemExtent)
                        }
                    }
                }
            }
        }
    }
    .scrollPosition($scrollState)
    .scrollIndicators(.hidden)
    ...
}

Именно этот дублирующий «хвост» и делает возможным бесшовное зацикливание. Когда пользователь прокручивает список за пределы исходного набора элементов и попадает в повторяющийся хвост, код может «перебросить» его обратно в основную область, сохранив при этом скорость жеста.

Измерение viewport для расчёта нужного количества дубликатов

Зацикливание работает только в том случае, если повторяющийся хвост достаточно длинный, чтобы покрыть экран в момент такого перехода. Если хвост слишком короткий, пользователь может заметить пустое пространство или визуальный разрыв.

Для этого вью использует onScrollGeometryChange, чтобы получить размер scroll-контейнера и вычислить duplicateCount.

.onScrollGeometryChange(for: CGFloat.self) {
    if axis == .horizontal {
        return $0.containerSize.width
    } else {
        return $0.containerSize.height
    }
} action: { _, newValue in
    let measuredViewport = newValue
    let safeValue: Int = 1
    let neededCount = (measuredViewport / (itemExtent + spacing)).rounded()

    self.duplicateCount = Int(neededCount) + safeValue
    self.viewportExtent = measuredViewport
}

Оценка строится так:

  • itemExtent + spacing даёт размер, который один элемент занимает вдоль оси прокрутки
  • viewport / footprint даёт примерное количество элементов, одновременно видимых на экране
  • Небольшой запас сверху не даёт хвосту быть размером ровно с viewport, что помогает при инерционной прокрутке (momentum scrolling)

Определение момента для зацикливания

Второй observer геометрии отслеживает смещение контента. Код вычисляет длину исходной области, после чего определяет, пересекло ли смещение границу, в которой нужно выполнить переход обратно.

.onScrollGeometryChange(for: CGFloat.self) {
    if axis == .horizontal {
        return $0.contentOffset.x + $0.contentInsets.leading
    } else {
        return $0.contentOffset.y + $0.contentInsets.top
    }
} action: { oldValue, newValue in
    axisOffset = newValue
    guard duplicateCount > 0 else { return }

    let itemsExtentSum = CGFloat(dataSource.count) * itemExtent
    let spacingSum = CGFloat(dataSource.count) * spacing
    let totalExtent = itemsExtentSum + spacingSum

    let wrapTargetOffset = min(totalExtent - newValue, 0)

    if wrapTargetOffset < 0 || newValue < 0 {
        var transaction = Transaction()
        transaction.scrollPositionUpdatePreservesVelocity = true

        withTransaction(transaction) {
            if newValue < 0 {
                if axis == .horizontal {
                    scrollState.scrollTo(x: totalExtent)
                } else {
                    scrollState.scrollTo(y: totalExtent)
                }
            } else {
                if axis == .horizontal {
                    scrollState.scrollTo(x: wrapTargetOffset)
                } else {
                    scrollState.scrollTo(y: wrapTargetOffset)
                }
            }
        }
    }
}

Обрабатываются два сценария:

  • Зацикливание назад (backward wrap): когда смещение становится меньше нуля, выполняется переход вперёд на полную длину контента (totalExtent)
  • Зацикливание вперёд (forward wrap): когда прокрутка выходит за пределы исходной области и уходит достаточно глубоко в повторяющийся хвост, выполняется переход назад на величину, сохраняющую выравнивание с текущим направлением движения

Ключевая деталь здесь — транзакции:

var transaction = Transaction()
transaction.scrollPositionUpdatePreservesVelocity = true
withTransaction(transaction) {
    ...
}

Это позволяет обновлять положение прокрутки, сохраняя текущий импульс, чтобы движение пользователя не ощущалось как «упирание в стену».

Добавление опциональной автоматической прокрутки

Компонент также поддерживает движение без помощи рук. Таймер срабатывает с небольшим интервалом и корректирует положение прокрутки, изменяя его на количество точек scrollingSpeed за тик.

.onReceive(Timer.publish(every: 0.01, on: .main, in: .default).autoconnect()) { _ in
    guard scrollingSpeed != 0 else { return }
    if axis == .horizontal {
        scrollState.scrollTo(x: axisOffset + scrollingSpeed)
    } else {
        scrollState.scrollTo(y: axisOffset + scrollingSpeed)
    }
}

Это хорошо работает для декоративных зацикленных лент, где взаимодействие пользователя необязательно. Для интерактивных каруселей обычно лучше приостанавливать автопрокрутку, пока пользователь перетаскивает элементы.

Примеры использования

Горизонтальная зацикленная карусель

struct Item: Identifiable {
    let id = UUID()
    let index: Int
}

let dataSource = Array(1...10).map { Item(index: $0) }

InfiniteScrollView(
    axis: .horizontal,
    spacing: 10,
    scrollingSpeed: 0.7,
    itemExtent: 100,
    dataSource: dataSource
) { item in
    Rectangle()
        .fill(.blue)
        .frame(height: 100)
        .overlay {
            Text("\(item.index)")
                .font(.headline)
                .foregroundStyle(.white)
        }
}
.frame(height: 120)

Вертикальный зацикленный список

struct Item: Identifiable {
    let id = UUID()
    let index: Int
}

let dataSource = Array(1...20).map { Item(index: $0) }

InfiniteScrollView(
    axis: .vertical,
    spacing: 8,
    scrollingSpeed: 0.0,
    itemExtent: 60,
    dataSource: dataSource
) { item in
    RoundedRectangle(cornerRadius: 12)
        .fill(.green.opacity(0.5))
        .overlay {
            Text("Row \(item.index)")
                .font(.subheadline)
                .foregroundStyle(.white)
                .padding(.vertical, 8)
        }
}

Почему не LazyHStack/LazyVStack?

Для плавного бесконечного скролла предсказуемый layout важнее, чем ленивый рендеринг. К тому же для такого компонента обычно нет большой пользы от отображения очень большого количества элементов. Тем не менее LazyHStack и LazyVStack тоже могут быть подходящим вариантом.

Практические замечания

  • itemExtent должен точно соответствовать размеру отрисовываемого элемента вдоль оси прокрутки. Если элементы имеют переменный размер, этот подход потребует доработки, потому что totalExtent становится сложнее предсказать.
  • Компонент один раз добавляет повторяющийся «хвост». Для типичных размеров карусели это недорого, но всё же приводит к дублированию вью. Поэтому лучше, чтобы представление каждого элемента оставалось лёгким.

Такой паттерн позволяет реализовать плавный бесконечный цикл на встроенных скроллирующих API SwiftUI и сохранить единообразное поведение как для горизонтальных, так и для вертикальных шаблонов.

Код доступен на GitHub.

Источник

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

Популярное

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

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