Greetings, traveler!
Some UI patterns expect a scroll view that never reaches an end. Think of a “featured” carousel on a storefront, a ticker of recent items, a carousel of songs in your player, or a background strip of logos in a landing screen. In these cases, the user should be able to keep swiping in either direction without hitting a hard boundary, and the motion should feel continuous.
SwiftUI doesn’t ship an “infinite” scroll view for this use case. You can build one by combining three ideas:
- Render your items once, then append a small repeated tail.
- Watch scroll geometry so you know when the user is about to leave the “safe” range.
- When the offset crosses a boundary, jump the scroll position while preserving velocity so the gesture feels uninterrupted.
The component below implements exactly that, supports both horizontal and vertical axes, and also includes optional auto-scrolling.
The key constraint is predictability of item sizing. This implementation requires an itemExtent: the item size along the scrolling axis. That lets the view compute how much content exists and how many duplicates are needed to cover the viewport.
The public API
The view is generic over a RandomAccessCollection whose elements are Identifiable, and it accepts a content builder, so it feels close to 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
}
}A couple of state properties are doing the heavy lifting:
scrollStateis aScrollPositionbinding, used to programmatically jump when wrapping.axisOffsettracks the current offset.duplicateCountis the number of repeated items appended after the original set.viewportExtentstores the visible size, used to calculateduplicateCount.
Rendering the original items and the repeated tail
The body renders two blocks back-to-back: the original data, then duplicateCount items that repeat from the beginning of the collection. The code supports both orientations by switching between HStack and 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)
...
}That repeated tail is what makes wrap-around possible. When the user scrolls past the original set and into the repeated tail, the code can jump them back into the “main” region while keeping the gesture’s velocity.
Measuring the viewport to decide how many duplicates you need
A loop works when the repeated tail is long enough to fill the screen while the jump happens. If the tail is too short, the user can see blank space or a discontinuity.
The view uses onScrollGeometryChange to read the scroll container size and compute 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
}The estimate is:
itemExtent + spacinggives the footprint of one item along the axis.viewport / footprintgives an approximate number of items visible at once.- Adding a small safety margin keeps the tail from being “exactly” viewport-sized, which helps during momentum scrolling.
Detecting when it’s time to wrap
The second geometry observer tracks the content offset. The code computes how long the original region is, then decides when the offset has crossed a boundary.
.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)
}
}
}
}
}Two cases are handled:
- Backward wrap: when the offset goes below zero, jump forward by one full content length (
totalExtent). - Forward wrap: when you move past the original region and deep enough into the repeated tail, jump back by an amount that keeps you aligned with where you were headed.
The important detail is the transaction:
var transaction = Transaction()
transaction.scrollPositionUpdatePreservesVelocity = true
withTransaction(transaction) {
...
}This allows the scroll position update to keep the current momentum, so the user’s swipe does not feel like it hit a wall.
Adding optional auto-scrolling
The component also supports hands-free movement. A timer ticks at a small interval and nudges the scroll position by scrollingSpeed points per tick.
.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)
}
}This works well for decorative loops where user interaction is optional. In interactive carousels, you may prefer to pause auto-scrolling while the user is dragging.
Usage examples
Horizontal looping carousel
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)Vertical looping list
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)
}
}Why not LazyHStack / LazyVStack?
For smooth infinite scrolling, a predictable layout is more important than lazy scrolling. Furthermore, displaying a large number of elements isn’t as beneficial for such a component. However, lazy stacks can also be a suitable option.
Practical notes
itemExtentmust match the rendered item dimension along the axis. If your items have variable size, this approach needs adjustments, sincetotalExtentbecomes harder to predict.- The component appends a repeated tail once. For typical carousel sizes this is cheap, yet it still duplicates views. Keep the per-item view lightweight.
This pattern gives you a smooth infinite loop with SwiftUI’s built-in scrolling APIs, and keeps the behavior consistent across horizontal and vertical layouts.
Available on GitHub.
