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

