Infinite ScrollView in SwiftUI


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:

  • scrollState is a ScrollPosition binding, used to programmatically jump when wrapping.
  • axisOffset tracks the current offset.
  • duplicateCount is the number of repeated items appended after the original set.
  • viewportExtent stores the visible size, used to calculate duplicateCount.

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 + spacing gives the footprint of one item along the axis.
  • viewport / footprint gives 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

  • itemExtent must match the rendered item dimension along the axis. If your items have variable size, this approach needs adjustments, since totalExtent becomes 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.