MediumSwiftUI Carousels: Finite, Infinite & 3D | Medium
Sitemap

SwiftUI Carousels - Finite, Infinite, Auto & 3D Stack

22 min read5 days ago

--

A practical guide to the carousel shapes you actually ship: finite and infinite paging (horizontal + vertical), auto vs manual playback, and a “3D circle” stack.

Full code on Github: SwiftUI-Carousels

By the end you’ll have:

  1. A CarouselView component that inside splits to FiniteCarouselLayout and InfinitePagingScrollView. Either of them support manual & auto scroll. Everything is controlled via single Configuration object.
  2. A 3D infinite carousel displaying items in a circle with the main item being “featured” and the items next to it “peeking”.

Mental Model

A carousel is not “a fancy ScrollView”. It’s three jobs that must stay in sync:

  1. Page size - each card fills a predictable slot.
  2. Snap - a flick rests on a page, not halfway between two pages.
  3. Selection - something outside the scroll view (timer, dots, deep link) that can move the page.

Finite vs Infinite is a data strategy. Horizontal vs Vertical is mostly an axis swap. Auto vs Manual is whether a timer is allowed to write selection.

What will you learn?

  1. How to build a snapping finite carousel in pure SwiftUI (horizontal & vertical).
  2. Why infinite auto-scroll needs a UIKit UIScrollView pager, and how to wire it.
  3. How to pause/resume auto-scroll without fighting the user’s finger.
  4. How to build a 3D infinite stack that rotates as a circle on either axis.

Table of Contents (build order)

  1. Configuration object
  2. Finite carousel
  3. Auto-scroll controller
  4. Wiring auto-scroll into the finite path
  5. Infinite paging
  6. CarouselView facade
  7. 3D infinite stack (advanced)
  8. What to steal from the companion app

Configuration object

Keep all control values in a single type:

enum CarouselAxis: String, CaseIterable, Identifiable, Sendable {
case horizontal
case vertical
var id: String { rawValue }
var scrollAxis: Axis.Set { self == .horizontal ? .horizontal : .vertical }
var marginEdges: Edge.Set { self == .horizontal ? .horizontal : .vertical }
}

struct CarouselConfiguration {
var axis: CarouselAxis = .horizontal
var isInfinite: Bool = false
var autoScrollEnabled: Bool = false
var autoScrollInterval: TimeInterval = 1.5
var spacing: CGFloat = 16
var contentInsets: EdgeInsets = EdgeInsets(
top: 8, leading: 20, bottom: 8, trailing: 20
)
var showsIndicators: Bool = false
var scrollContentMargin: CGFloat {
contentInsets.mainLeading(axis)
}
}

extension EdgeInsets {
func mainLeading(_ axis: CarouselAxis) -> CGFloat {
axis == .horizontal ? leading : top
}

func mainTrailing(_ axis: CarouselAxis) -> CGFloat {
axis == .horizontal ? trailing : bottom
}
}

Why?
One set of knobs can express eight product modes (axis × isInfinite × auto/manual) without duplicating layout code. When the designer asks “can you make it loop?”, you simply flip a flag instead of rewriting the screen.

We’ll show the implementation later on (section 6).
For now, remember: knobs live on CarouselConfiguration, not as a pile of parameters on every layout type.

Finite carousel (SwiftUI)

Best to use when the set has a real start and end: featured items, onboarding pages, product strips.

Implemented in pure SwiftUI - FiniteCarouselLayout :

struct FiniteCarouselLayout<Item: Identifiable & Hashable, Content: View>: View {
let items: [Item]
let configuration: CarouselConfiguration
@Binding var selection: Item.ID? /// tracks which item is currently selected / aligned with the scroll position
@Binding var isDragging: Bool /// indicates whether the user is currently dragging the carousel
@ViewBuilder var content: (Item) -> Content /// lets the caller define how each item should look

var body: some View {
GeometryReader { proxy in
// calculate available size for each page based on the carousel's dimensions & configured content insets
let pageSize = pageSize(in: proxy.size)
ScrollView(configuration.axis.scrollAxis, showsIndicators: configuration.showsIndicators) {
contentStack(pageSize: pageSize)
// marks individual items as scroll targets ; allows SwiftUI to align the scroll position with individual items instead of arbitrary offsets
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned) // make scrolling snap to the nearest item's boundary the user finishes scrolling
.scrollPosition(id: $selection) // synchronizes the currently visible item with the selection binding
.contentMargins( // applies spacing around the scrollable content
configuration.axis.marginEdges,
configuration.scrollContentMargin,
for: .scrollContent
)
}
}

@ViewBuilder
private func contentStack(pageSize: CGSize) -> some View {
// switch between HStack and VStack dynamically according to given configuration
let layout = configuration.axis == .horizontal
? AnyLayout(HStackLayout(spacing: configuration.spacing))
: AnyLayout(VStackLayout(spacing: configuration.spacing))
layout {
ForEach(items) { item in
content(item)
// give each item the calculated page dimensions -
// horizontal carousel needs explicit width ; vertical carousel needs explicit height
.frame(
width: configuration.axis == .horizontal ? pageSize.width : nil,
height: pageSize.height
)
// make the item occupy one container-relative page.
// it works together with .viewAligned scrolling to provide consistent page snapping
.containerRelativeFrame(
configuration.axis == .horizontal ? .horizontal : .vertical,
count: 1,
span: 1,
spacing: configuration.spacing,
alignment: .center
)
.id(item.id)
.accessibilityAddTraits(item.id == selection ? .isSelected : [])
}
}
}
private func pageSize(in size: CGSize) -> CGSize {
if configuration.axis == .horizontal {
// calculate the available page width after subtracting leading & trailing content insets
let width = max(
size.width
- configuration.contentInsets.leading
- configuration.contentInsets.trailing,
120
)
return CGSize(width: width, height: size.height)
}
// calculate the available page height after subtracting top & bottom content insets
let height = max(
size.height
- configuration.contentInsets.top
- configuration.contentInsets.bottom,
120
)
return CGSize(width: size.width, height: height)
}
}

Currently the layout is just a “manual snap pager”. The isDragging will be used later (section 4).

What do we have in this code?:

  • GeometryReader + pageSize(in:)- measure the carousel’s offered frame and turn it into one page’s width/height (minus the configured insets).
  • AnyLayout - one code path to support both horizontal and vertical layouts (HStack / VStack).
  • Explicit .frame & containerRelativeFrame- fill the page slot, then declare it as a scroll target.
  • scrollTargetLayout + .viewAligned + scrollPosition(id:)- snap + programmable selection. These work together to let SwiftUI track the selected item and align scrolling to individual pages.
  • contentMargins- inset first/last page according to the configuration.

Why this snaps cleanly?:
SwiftUI’s paging APIs only work when targets are declared (scrollTargetLayout) and a behavior is set (.viewAligned). Skipping either one is how you get that “almost snapped” feel. The explicit pageSize frame keeps cards from growing past the designated slot.

Auto-scroll controller

A carousel that fights the finger is a bug. An auto-scrolling carousel must stop its auto motion once the user intervenes.
Own pause/resume in a separate controller:

@MainActor
final class AutoScrollController: ObservableObject {
@Published private(set) var tick: UInt64 = 0
@Published private(set) var isPausedByUserInteraction = false
private var timer: AnyCancellable?
private var resumeWorkItem: DispatchWorkItem?
private var interval: TimeInterval = 1.5 /// seconds between auto scrolls
private var isEnabled = false /// whether to autoscroll or not

func configure(enabled: Bool, interval: TimeInterval) {
self.interval = max(interval, 0.8)
self.isEnabled = enabled
rebuildTimer()
}

func pauseForUserInteraction(resumeAfter: TimeInterval = 2.4) {
guard isEnabled else { return }
isPausedByUserInteraction = true
resumeWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.isPausedByUserInteraction = false
self?.rebuildTimer()
}
resumeWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + resumeAfter, execute: work)
rebuildTimer()
}

func stop() {
isEnabled = false
resumeWorkItem?.cancel()
resumeWorkItem = nil
isPausedByUserInteraction = false
timer?.cancel()
timer = nil
}

private func rebuildTimer() {
timer?.cancel()
timer = nil
guard isEnabled, !isPausedByUserInteraction else { return }
timer = Timer.publish(every: interval, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
self?.tick &+= 1
}
}
}

Why a separate controller?
Screens (and layout views) shouldn’t own Timer lifecycle. Consumers should observe eachtick from the outside and react accordingly.

Wiring auto-scroll into the finite path

Detect drag without stealing paging

On FiniteCarouselLayout, add a simultaneous drag gesture (minimumDistance is 20 so short flicks are still owned by ScrollView ):

struct FiniteCarouselLayout<...>: View {
...variables..
var body: some View {
GeometryReader { proxy in
...
ScrollView(...) { ... }
...modifiers...
// Allow to detect drag interactions without replacing ScrollView's built-in scrolling behavior
.simultaneousGesture(dragGesture) // <-------- add this
}
}

private var dragGesture: some Gesture {
DragGesture(minimumDistance: 20) // min 20 points drag to prevent trating small accidental movements as drags
// only update binding when dragging actually starts ; avoid repeatedly assigning true during the same gesture
.onChanged { _ in if !isDragging { isDragging = true } }
// reset dragging state when the gesture finishes, allowing parent view to react accordingly
.onEnded { _ in isDragging = false }
}
}

Advance selection on each tick

Whoever owns the controller (eventually CarouselView, in section 6) does:

// pause auto-scrolling when the user starts interacting with the carousel
.onChange(of: isDragging) { _, dragging in
if dragging { autoScroll.pauseForUserInteraction() }
}

// each auto-scroll tick advances the selection in finite mode
.onChange(of: autoScroll.tick) { _, _ in
advanceFiniteSelection()
}

// stop the timer when the carousel disappears to avoid unnecessary work.
.onDisappear {
autoScroll.stop()
}


// finite carousel advance uses `withAnimation` so scrollPosition interpolates instead of hard-cutting
private func advanceFiniteSelection() {
guard configuration.autoScrollEnabled, !items.isEmpty, !isDragging else { return }
guard let current = selection ?? items.first?.id,
let index = items.firstIndex(where: { $0.id == current }) else { return }
let nextIndex = index + 1 < items.count ? index + 1 : 0
withAnimation(.smooth(duration: 0.55)) {
selection = items[nextIndex].id
}
}

At this point you already have a complete finite + auto/manual carousel.

Infinite paging (UIKit)

An infinite carousel needs to feel like it never reaches the end. The common way to achieve this is:

1. Display the items three times.

2. Start in the middle copy.

3. When the user reaches the first or last copy, silently jump back to the matching item in the middle.

SwiftUI auto-scrolling vs UIKit:
Manual infinite scrolling works well (gesture physics move offset), but programmatic auto-scrolling introduces another challenge. With SwiftUI’s scrollPosition and scrollTo, animated transitions in a triple-buffered carousel can sometimes snap instead of smoothly sliding to the next page, particularly with vertical scrolling.
UIKit’s UIScrollView.setContentOffset(_:animated:) gives us more direct and predictable control over the scrolling animation on both axes.

The representable bridge

struct InfinitePagingScrollView<Item: Identifiable & Hashable, Page: View>: UIViewControllerRepresentable {
let items: [Item]
let axis: CarouselAxis
let spacing: CGFloat
let contentInsets: EdgeInsets
@Binding var selection: Item.ID? // keeps the selected item synchronized with the parent SwiftUI view.
@Binding var isDragging: Bool

// changes whenever the auto-scroll timer fires. Used as a signal to advance the carousel by one page.
var autoAdvanceTick: UInt64
var autoScrollEnabled: Bool


@ViewBuilder var pageBuilder: (Item) -> Page // lets the caller define how each item should look

// creates the Coordinator, which handles communication between the UIKit controller and SwiftUI bindings.
func makeCoordinator() -> Coordinator {
Coordinator(selection: $selection, isDragging: $isDragging)
}


// creates the UIKit view controller that manages the infinite carousel.
// this is called when SwiftUI first creates the representable.
func makeUIViewController(context: Context) -> InfinitePagingController<Item, Page> {
let controller = InfinitePagingController<Item, Page>()

// apply the latest configuration and callbacks before loading items.
apply(to: controller, context: context)
controller.reload(items: items, selecting: selection)

// remember the current tick so the initial render doesn't trigger an unnecessary auto-scroll.
context.coordinator.lastTick = autoAdvanceTick
return controller
}


// called by SwiftUI whenever the representable's inputs change.
// the method keeps the existing UIKit controller in sync.
func updateUIViewController(_ controller: InfinitePagingController<Item, Page>, context: Context) {
apply(to: controller, context: context)

// reload only when the actual item IDs change to avoids rebuilding on unrelated SwiftUI updates.
if controller.items.map(\.id) != items.map(\.id) {
controller.reload(items: items, selecting: selection)
}

// advance one page when a new auto-scroll tick arrives.
if autoScrollEnabled,
context.coordinator.lastTick != autoAdvanceTick {
context.coordinator.lastTick = autoAdvanceTick
if !context.coordinator.dragging {
controller.advanceOnePage(animated: true)
}
}
}

// passes SwiftUI configuration and callbacks to the UIKit controller.
// this keeps the controller independent of the SwiftUI parent view.
private func apply(to controller: InfinitePagingController<Item, Page>, context: Context) {
controller.axis = axis
controller.spacing = spacing
controller.insets = contentInsets
controller.pageBuilder = pageBuilder

// notify the Coordinator when the selected page changes in UIKit.
controller.onSelect = { id in
context.coordinator.applySelection(id)
}

// notify the Coordinator when the user starts or stops dragging.
controller.onDragging = { dragging in
context.coordinator.applyDragging(dragging)
}
}


// bridges events from UIKit back to SwiftUI.
// it also stores interaction state that needs to persist between updates.
final class Coordinator {
var selection: Binding<Item.ID?>
var isDragging: Binding<Bool>
var lastTick: UInt64 = 0
var dragging = false

init(selection: Binding<Item.ID?>, isDragging: Binding<Bool>) {
self.selection = selection
self.isDragging = isDragging
}

func applySelection(_ id: Item.ID) {
// update the SwiftUI selection only when it actually changes.
if selection.wrappedValue != id {
selection.wrappedValue = id
}
}

func applyDragging(_ value: Bool) {
// keep the local dragging state synchronized with SwiftUI.
dragging = value
// update the parent binding only when the value changes.
if isDragging.wrappedValue != value {
isDragging.wrappedValue = value
}
}
}
}

Auto-scroll is driven from SwiftUI:
Each autoAdvanceTick change (every time the timer fires) calls controller.advanceOnePage(animated:true) on the UIKit controller to tell it to advance one page with animation.

The Coordinator only syncs selection and drag. The illusion (triple buffer, snap, silent recenter) lives in InfinitePagingController.

Get Noam Kurtzer’s stories in your inbox

Join Medium for free to get updates from this writer.

The drag pause reaches infinite the same way as finite. UIKit sets onDragging → Coordinator writes isDragging → CarouselView calls autoScroll.pauseForUserInteraction().

InfinitePagingController

The representable is only a bridge. The pager itself is a UIViewController that owns a UIScrollView, hosts each page in a UIHostingController, and keeps three copies of the list in memory.

@MainActor
final class InfinitePagingController<Item: Identifiable & Hashable, Page: View>: UIViewController, UIScrollViewDelegate {
var axis: CarouselAxis = .vertical
var spacing: CGFloat = 16
var insets: EdgeInsets = EdgeInsets()
var pageBuilder: ((Item) -> Page)?
var onSelect: ((Item.ID) -> Void)?
var onDragging: ((Bool) -> Void)?

private(set) var items: [Item] = []
private let scrollView = UIScrollView()
private var hosts: [UIHostingController<PageHost<Page>>] = []
private var pageLength: CGFloat = 0
private var loopedCount: Int { items.count * 3 }
private var middleStart: Int { items.count }
private var strideLength: CGFloat { pageLength + spacing }

// viewDidLoad: pin scrollView to edges, fast deceleration, no content inset adjustment.
// ...
}

PageHost is a tiny wrapper so horizontal pages can force an exact CGSize into the hosting controller (same idea as finite pageSize). Vertical can leave size nil.

func reload(items: [Item], selecting selection: Item.ID?) {
self.items = items
rebuildHosts() // builds items.count * 3 hosting controllers
pageLength = 0
view.setNeedsLayout()
view.layoutIfNeeded()
let offset = items.firstIndex(where: { $0.id == selection }) ?? 0
let page = middleStart + offset
setOffset(toPage: page, animated: false)
emitSelection(forPage: page)
}

Advance + silent recenter

These methods belong on the controller, which is what updateUIViewController calls:

/// Animated content-offset advance — this is the auto-scroll slide.
func advanceOnePage(animated: Bool) {
guard !items.isEmpty, pageLength > 0 else { return }
let next = min(currentPageIndex() + 1, loopedCount - 1)
setOffset(toPage: next, animated: animated)
if !animated {
emitSelection(forPage: next)
recenterIfNeeded()
}
}
private func setOffset(toPage index: Int, animated: Bool) {
let index = min(max(index, 0), max(loopedCount - 1, 0))
let main = CGFloat(index) * strideLength
let point = axis == .horizontal
? CGPoint(x: main, y: 0)
: CGPoint(x: 0, y: main)
scrollView.setContentOffset(point, animated: animated)
}
private func currentPageIndex() -> Int {
guard strideLength > 0 else { return middleStart }
let raw = axis == .horizontal
? scrollView.contentOffset.x
: scrollView.contentOffset.y
return min(max(Int(round(raw / strideLength)), 0), max(loopedCount - 1, 0))
}
private func clampedMiddle(_ index: Int) -> Int {
guard !items.isEmpty else { return 0 }
let logical = ((index % items.count) + items.count) % items.count
return middleStart + logical
}
private func emitSelection(forPage index: Int) {
guard !items.isEmpty else { return }
onSelect?(items[index % items.count].id)
}
/// If we drifted into the first or third copy, jump to the matching page in the middle — no animation.
private func recenterIfNeeded() {
guard !items.isEmpty else { return }
let current = currentPageIndex()
guard current / items.count != 1 else { return }
setOffset(toPage: clampedMiddle(current), animated: false)
}

After an animated auto-advance finishes, UIKit calls scrollViewDidEndScrollingAnimation- that is where we emit selection and recenter (same as after a user flick):

func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
let index = currentPageIndex()
emitSelection(forPage: index)
recenterIfNeeded()
}

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
onDragging?(true)
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate { finishUserScroll() }
}

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
finishUserScroll()
}

private func finishUserScroll() {
onDragging?(false)
let index = currentPageIndex()
emitSelection(forPage: index)
recenterIfNeeded()
}

Velocity-aware snap (why short flicks feel right)

Don’t trust UIKit’s projected targetContentOffset alone for a page strip. Snap from the lift offset + velocity sign so a canceled flick doesn’t skip two pages, and a decisive flick still advances one:

func scrollViewWillEndDragging(
_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>
) {
guard strideLength > 0 else { return }
let horizontal = axis == .horizontal
let current = horizontal ? scrollView.contentOffset.x : scrollView.contentOffset.y
let v = horizontal ? velocity.x : velocity.y
let resting = current / strideLength
let index: Int
if v > 0.2 {
index = Int(floor(resting)) + 1
} else if v < -0.2 {
index = Int(ceil(resting)) - 1
} else {
index = Int(resting.rounded())
}
let clamped = min(max(index, 0), max(loopedCount - 1, 0))
let snapped = CGFloat(clamped) * strideLength
if horizontal {
targetContentOffset.pointee.x = snapped
} else {
targetContentOffset.pointee.y = snapped
}
}

Layout in one sentence + sketch

On layout, pageLength is the container’s main-axis size minus insets (same idea as finite pageSize). Each host is placed at `index * (pageLength + spacing)`, and contentSize spans all three copies. Horizontal pages get an explicit size into PageHost; vertical pages fill width with insets.

private func layoutPages() {
guard !items.isEmpty, pageLength > 0, hosts.count == loopedCount else { return }
let horizontal = axis == .horizontal

for (index, host) in hosts.enumerated() {
let main = CGFloat(index) * strideLength
let item = items[index % items.count]
if horizontal {
let size = CGSize(width: pageLength, height: max(view.bounds.height, 1))
host.rootView = PageHost(content: pageBuilder!(item), size: size)
host.view.frame = CGRect(
x: insets.leading + main, y: 0,
width: size.width, height: size.height
)
}
else {
host.rootView = PageHost(content: pageBuilder!(item), size: nil)
host.view.frame = CGRect(
x: insets.leading,
y: insets.top + main,
width: max(view.bounds.width - insets.leading - insets.trailing, 1),
height: pageLength
)
}
}

let contentSpan =
insets.mainLeading(axis)
+ CGFloat(loopedCount) * pageLength
+ CGFloat(max(loopedCount - 1, 0)) * spacing
+ insets.mainTrailing(axis)
scrollView.contentSize = horizontal
? CGSize(width: contentSpan, height: view.bounds.height)
: CGSize(width: view.bounds.width, height: contentSpan)
}

What do we have in this code?

  • Triple buffer (`loopedCount = count * 3`, start at middleStart) - room to scroll “past” either end.
  • `setContentOffset(_:animated:)`- the auto-scroll slide that SwiftUI’s `scrollPosition` wouldn’t reliably animate on this layout.
  • recenterIfNeeded- silent jump back to the middle copy so the user never hits a wall.
  • Lift-offset + velocity snap- consistent one-page flicks.
  • onDragging / onSelect- keep SwiftUI’s isDragging and selection honest.

CarouselView facade (assembly)

Create a single gateway component holding both Finite and Infinite carousel.

struct CarouselView<Item: Identifiable & Hashable, Content: View>: View {
let items: [Item]
var configuration: CarouselConfiguration = .horizontal
@Binding var selection: Item.ID?

// callers provide custom content for each carousel item.
@ViewBuilder var content: (Item) -> Content

@StateObject private var autoScroll = AutoScrollController()
@State private var isDragging = false

// provides a fallback selection so the carousel always has an initial item.
private var resolvedSelection: Binding<Item.ID?> {
Binding(
get: { selection ?? items.first?.id },
set: { selection = $0 }
)
}

var body: some View {
Group {
if items.isEmpty {
ContentUnavailableView(
"No Items",
systemImage: "rectangle.stack",
description: Text("Provide at least one item to render a carousel.")
)
}
else if configuration.isInfinite {
GeometryReader { geo in
InfinitePagingScrollView(
items: items,
axis: configuration.axis,
spacing: configuration.spacing,
contentInsets: configuration.contentInsets,
selection: resolvedSelection,
isDragging: $isDragging,
autoAdvanceTick: autoScroll.tick,
autoScrollEnabled: configuration.autoScrollEnabled,
pageBuilder: content
)
.frame(width: geo.size.width, height: geo.size.height)
}
}
else {
FiniteCarouselLayout(
items: items,
configuration: configuration,
selection: resolvedSelection,
isDragging: $isDragging,
content: content
)
}
}
.onAppear {
// select the first item if no initial selection was provided.
if selection == nil {
selection = items.first?.id
}
syncAutoScroll()
}
// restart the auto-scroll controller when its settings change.
.onChange(of: configuration.autoScrollEnabled) { _, _ in syncAutoScroll() }
.onChange(of: configuration.autoScrollInterval) { _, _ in syncAutoScroll() }
.onChange(of: isDragging) { _, dragging in
if dragging {
autoScroll.pauseForUserInteraction()
}
}
.onChange(of: autoScroll.tick) { _, _ in
guard !configuration.isInfinite else { return }
advanceFiniteSelection()
}
.onDisappear {
autoScroll.stop()
}
}

// apply the current auto-scroll settings to the controller.
private func syncAutoScroll() {
autoScroll.configure(
enabled: configuration.autoScrollEnabled,
interval: configuration.autoScrollInterval
)
}

private func advanceFiniteSelection() {
// don't advance if auto-scroll is disabled, there are no items, or user is currently dragging.
guard configuration.autoScrollEnabled, !items.isEmpty, !isDragging else { return }
// find the current item's position in the array.
guard let current = selection ?? items.first?.id,
let index = items.firstIndex(where: { $0.id == current }) else { return }
// move to the next item, or return to the first when reaching the end.
let nextIndex = index + 1 < items.count ? index + 1 : 0
// animate the selection change so the carousel scrolls smoothly.
withAnimation(.smooth(duration: 0.55)) {
selection = items[nextIndex].id
}
}
}

What do we have in this code?

  • Branch on configuration.isInfinite → pick the relevant engine from section 2 or 5.
  • Own AutoScrollController. Finite carousel consumes tick here; infinite consumes tick inside the representable.
  • `resolvedSelection` → always a valid id without forcing the parent.
  • Drag pause is shared via `isDragging`.

Why create this facade?
This way, the implementation is the same everywhere and is as simple as:

CarouselView(
items: kind.items,
configuration: configuration,
selection: $selection
) { item in
SampleCardView(item: item)
}

In the demo app, Auto/Manual and the interval are merged into CarouselConfiguration before this call; the stack screen uses StackedInfiniteCarouselView instead.

3D infinite stack

This is a completely new component, separate from the CarouselView.

When the target is to achieve a “wow effect”, an infinite rotating ring of items (a focused center with peeks) is a good choice. In such case don’t use ScrollView paging - place every item on a ring and drive continuous progress from the drag. Cards stay face-on - they only orbit, scale, and their zIndex changes.

The design & mechanics varies according to the number of items:
• 2 items - dedicated two-slot engine (center + leading peek); the swap still looks like a wheel thanks to the animation.
• 3 items - classic 2π/3 ring.
• 4+ items - depth comes from the circle, but orbit uses slot spacing so the far card doesn’t sit under a ±1 neighbor.

Press enter or click to view image in full size
Vertical 3D stack carousel with 6 items

The model

let progress = CGFloat(internalIndex) - dragFraction  // live while dragging
let wrapped = wrappedDelta(itemIndex: i, progress: progress) // shortest signed slots
let placement = orbitPlacement(itemIndex: i, progress: progress, wrappedDelta: wrapped, ...)
// 3-up: sin/cos ring. 2-up: twoSlotPlacement. 4+: orbit = wrapped * spacing, depth = cos(angle)
let orbit = placement.orbit // y when vertical, x when horizontal
let depth = placement.depth // 1 = front … −1 = behind
let scale = 0.70 + 0.30 * ((depth + 1) * 0.5)
let opacity = cardOpacity(forWrappedDelta: wrapped) // front 1 → ±1 ~0.36 → ±2 ~0.18 → 0

What do we have in this code?

  • progress - continuous position on the ring (index + live drag).
  • wrappedDelta- shortest signed distance on a circular item list (handles wrap); also drives opacity and 4+ orbits.
  • orbitPlacement- picks the 2-slot engine, the classic sin/cos ring, or slot-spaced 4+ orbits.
  • orbit / depth- place the card in the plane and decide who’s in front.
  • scale- smaller when behind so the wheel reads as depth without tilting the card.
  • cardOpacity - focus stays opaque; second-layer peeks fade so the featured card wins.

Why a circle instead of a linear offset?
On a slow swipe the leading neighbor must arc behind the center and emerge as the trailing neighbor (top → bottom vertically, left → right horizontally). A linear “slide off / slide on” never shows that path.

Sizing the stage

Peek cards need room outside the focused card. Keep the math in a small helper:

enum StackCarouselMetrics {
static let creditCardAspectRatio: CGFloat = 1.586
static let spacingRatioVertical: CGFloat = 0.55
static let spacingRatioHorizontal: CGFloat = 0.30
static let neighborScaleAtOne: CGFloat = 0.88
static let extentSlackRatio: CGFloat = 0.22
static let horizontalFocusWidthRatio: CGFloat = 0.88
static let maxVisibleSlot: Int = 2

static func visibleOuterSlot(itemCount: Int) -> Int {
guard itemCount > 1 else { return 1 }
if itemCount == 2 { return 1 }
return min(maxVisibleSlot, max(itemCount / 2, 1))
}

static func spacingRatio(for axis: CarouselAxis, itemCount: Int = 3) -> CGFloat {
let base = axis == .horizontal ? spacingRatioHorizontal : spacingRatioVertical
// Vertical packs tighter as count grows; horizontal only mild tighten for 4+.
// (Full pack math lives in the repo — keep the signature honest here.)
return base // see StackCarouselMetrics.spacingRatio in the companion app for count packing
}

static func cardMainLength(
forCardCrossAxis crossAxis: CGFloat,
aspectRatio: CGFloat = creditCardAspectRatio,
axis: CarouselAxis = .vertical
) -> CGFloat {
axis == .vertical ? crossAxis / aspectRatio : crossAxis * aspectRatio
}

/// Container extent along the scroll axis so rest neighbors aren’t clipped.
static func stackExtent(
forCardCrossAxis crossAxis: CGFloat,
aspectRatio: CGFloat = creditCardAspectRatio,
axis: CarouselAxis = .vertical,
spacingRatio: CGFloat? = nil,
itemCount: Int = 3
) -> CGFloat {
let ratio = spacingRatio ?? self.spacingRatio(for: axis, itemCount: itemCount)
let cardMain = cardMainLength(
forCardCrossAxis: crossAxis,
aspectRatio: aspectRatio,
axis: axis
)
let spacing = cardMain * ratio
let outer = CGFloat(visibleOuterSlot(itemCount: itemCount))
let orbitReach = outer * spacing
let slack = cardMain * extentSlackRatio
return 2 * orbitReach + cardMain * neighborScaleAtOne + slack
}
}

Pass itemCount into stackExtent (and spacingRatio) so a 2-card wheel and a 6-card wheel don’t claim the same stage height.

Ring math + card placement

/// Shortest signed distance — visibility culling + 4+ slot orbits.
private func wrappedDelta(itemIndex: Int, progress: CGFloat) -> CGFloat {
guard itemCount > 0 else { return 0 }
let n = CGFloat(itemCount)
var delta = CGFloat(itemIndex) - progress
delta = delta.truncatingRemainder(dividingBy: n)
if delta > n / 2 { delta -= n }
else if delta < -n / 2 { delta += n }
return delta
}

/// Opacity by slot distance: front = 1, ±1 ≈ 0.36, ±2 ≈ 0.18, farther = hidden.
private func cardOpacity(forWrappedDelta delta: CGFloat) -> Double {
let d = abs(Double(delta))
let stops: [(Double, Double)] = [
(0.0, 1.00),
(1.0, 0.36),
(2.0, 0.18),
(2.45, 0.00)
]
if d <= stops[0].0 { return stops[0].1 }
for index in 0..<(stops.count - 1) {
let (d0, o0) = stops[index]
let (d1, o1) = stops[index + 1]
if d <= d1 {
let t = (d - d0) / (d1 - d0)
return o0 + (o1 - o0) * t
}
}
return 0
}

private func orbitPlacement(
itemIndex: Int,
progress: CGFloat,
wrappedDelta: CGFloat,
stepAngle: CGFloat,
radius: CGFloat,
spacing: CGFloat
) -> (orbit: CGFloat, depth: CGFloat) {
if itemCount == 2 {
return twoSlotPlacement(itemIndex: itemIndex, progress: progress, spacing: spacing)
}
let rawDelta = CGFloat(itemIndex) - progress
let angle = rawDelta * stepAngle
let depth = cos(angle)
if itemCount >= 4 {
return (wrappedDelta * spacing, depth) // slot lane, not collapsed sin orbit
}
return (sin(angle) * radius, depth)
}

@ViewBuilder
private func cardLayer(
item: Item,
itemIndex: Int,
progress: CGFloat,
wrappedDelta: CGFloat,
stepAngle: CGFloat,
radius: CGFloat,
spacing: CGFloat,
cardMain: CGFloat,
cardCross: CGFloat,
opacity: Double
) -> some View {
let placement = orbitPlacement(
itemIndex: itemIndex,
progress: progress,
wrappedDelta: wrappedDelta,
stepAngle: stepAngle,
radius: radius,
spacing: spacing
)
let depth = placement.depth
let orbit = placement.orbit
let scale = 0.70 + 0.30 * ((depth + 1) * 0.5)
let isFront = abs(wrappedDelta) < 0.5 // slot distance, not depth > 0.85
cardBuilder(item)
.frame(
width: axis == .vertical ? cardCross : cardMain,
height: axis == .vertical ? cardMain : cardCross
)
.compositingGroup()
.shadow(
color: .black.opacity((isFront ? 0.35 : max(0.08, 0.22 * Double((depth + 1) * 0.5))) * opacity),
radius: isFront ? 18 : 8,
y: isFront ? 10 : 4
)
.opacity(opacity)
.scaleEffect(scale)
.offset(
x: axis == .horizontal ? orbit : 0,
y: axis == .vertical ? orbit : 0
)
.zIndex(Double(depth))
.allowsHitTesting(isFront && !isSettling)
}

Two-item mode is a separate path: rest is only center + leading peek. The front takes the short arc onto the peek; the peek takes the long arc through behind onto center. Reverse swipe runs the same wheel backward. See twoSlotPlacement in the companion app.

Drag → settle → remap (the infinite part)

internalIndex is a large virtual index (started near `count * 50 + currentIndex`) so we can step forever without bouncing off zero. While dragging, `dragOffset / slotExtent` becomes dragFraction.
On finger up we either ease back to zero or commit one or more slots (from swipe distance / predicted translation), then remap: bump internalIndex by the page count, zero dragOffset, publish currentIndex — with no visual jump because progress is continuous across that remap.
Drive the settle by stepping dragOffset every frame. withAnimation looks smooth on screen but assigns the end value immediately in model space, so any Binding derived from dragOffset (page-indicator scrub) would snap. The frame loop keeps scrubProgress honest for finger drag, settle, and auto-scroll alike.

private func dragGesture(slotExtent: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 8)
.onChanged { value in
if !isSettling, settleTask != nil {
settleTask?.cancel()
settleTask = nil
}
guard !isSettling else { return }
if dragOffset == 0 { onUserDrag?() }
dragOffset = axis == .vertical
? value.translation.height
: value.translation.width
}
.onEnded { value in
guard !isSettling else { return }
let translation = axis == .vertical
? value.translation.height
: value.translation.width
let predicted = axis == .vertical
? value.predictedEndTranslation.height
: value.predictedEndTranslation.width
let threshold = slotExtent / 4
let decisive = abs(predicted) > abs(translation) ? predicted : translation
if abs(decisive) > threshold {
let steps = max(1, Int((abs(decisive) / slotExtent).rounded()))
let direction = decisive > 0 ? -1 : +1
commitStep(direction: direction, slotExtent: slotExtent, steps: steps)
} else {
animateDragOffset(to: 0, duration: 0.28)
}
}
}

/// direction +1 = next, −1 = previous. Used by manual commit and auto-scroll ticks.
private func commitStep(direction: Int, slotExtent: CGFloat, steps: Int = 1) {
guard itemCount > 0, !isSettling else { return }
let pageCount = max(1, steps)
isSettling = true
let targetOffset = CGFloat(-direction) * slotExtent * CGFloat(pageCount)
let duration = min(0.42, 0.26 + 0.06 * Double(pageCount))
animateDragOffset(to: targetOffset, duration: duration) {
internalIndex += direction * pageCount
dragOffset = 0
currentIndex = realIndex(for: internalIndex)
isSettling = false
}
}

/// Step dragOffset on the main actor so scrubProgress tracks settle / auto-scroll
/// the same way it tracks a finger. withAnimation only interpolates rendering —
/// bindings would jump straight to the end value.
private func animateDragOffset(
to target: CGFloat,
duration: TimeInterval,
onComplete: @escaping () -> Void = {}
) {
settleTask?.cancel()
let start = dragOffset
let delta = target - start
guard abs(delta) > 0.5 else {
dragOffset = target
onComplete()
return
}
settleTask = Task { @MainActor in
let startTime = Date()
while !Task.isCancelled {
let elapsed = Date().timeIntervalSince(startTime)
let t = min(1, elapsed / duration)
let eased = 1 - pow(1 - t, 3) // ease-out
dragOffset = start + delta * CGFloat(eased)
if t >= 1 { break }
try? await Task.sleep(nanoseconds: 16_000_000)
}
guard !Task.isCancelled else { return }
dragOffset = target
onComplete()
}
}

Body sketch (stage → progress → layers)

var body: some View {
GeometryReader { geo in
let stageMain = axis == .vertical ? geo.size.height : geo.size.width
let stageCross = axis == .vertical ? geo.size.width : geo.size.height
let cardMain = axis == .vertical
? stageCross / cardAspectRatio
: min(
stageCross * cardAspectRatio,
stageMain * StackCarouselMetrics.horizontalFocusWidthRatio
)
let cardCross = axis == .vertical ? stageCross : cardMain / cardAspectRatio
let interCardSpacing = cardMain * resolvedSpacingRatio
let slotExtent = max(cardMain, 1)
let dragFraction = dragOffset / slotExtent
let progress = CGFloat(internalIndex) - dragFraction
let logical = Self.logicalPageProgress(virtual: progress, count: itemCount)
// radius from spacing / sin(stepAngle); draw items with |wrappedDelta| < ~2.45 and opacity > 0
stackContent(
slotExtent: slotExtent,
cardMain: cardMain,
cardCross: cardCross,
interCardSpacing: interCardSpacing,
progress: progress,
logicalProgress: logical
)
}
}

Publish logicalProgress into a scrubProgress Binding. PageIndicatorView takes that continuous value so the active dot shrinks while the destination grows mid-swipe (and during auto-advance).

Auto-scroll on the stack

Same timer as the paging carousel- different consumer. Pass autoScroll.tick in and pause via onUserDrag:

.onChange(of: autoAdvanceTick) { _, tick in
guard autoScrollEnabled, !isSettling, itemCount > 0 else { return }
guard tick != lastAutoTick else { return }
lastAutoTick = tick
commitStep(direction: +1, slotExtent: slotExtent)
}

Call site sketch:

StackedInfiniteCarouselView(
items: items,
currentIndex: $index,
scrubProgress: $scrubProgress,
axis: .vertical, // or .horizontal
autoAdvanceTick: autoScroll.tick,
autoScrollEnabled: true,
onUserDrag: { autoScroll.pauseForUserInteraction() }
) { item in
SampleCardView(item: item)
}
.frame(
height: StackedInfiniteCarouselView.stackExtent(
forCardCrossAxis: cardWidth,
itemCount: items.count
)
)

PageIndicatorView(
count: items.count,
progress: scrubProgress,
axis: .vertical
)

Why this isn’t inside `CarouselView`?
Paging carousels snap full pages. The stack is continuous ring progress with a different selection model (`Int` index, not `Item.ID?` via `scrollPosition`). Keeping it separate avoids forcing one configuration object to mean two different interaction models.

What to steal from the companion app

  • Carousel/CarouselView.swiftFinite + infinite facade
  • Carousel/FiniteCarouselLayout.swiftPure-SwiftUI snapping pager
  • Carousel/Infinite/InfinitePagingScrollView.swiftUIKit representable bridge
  • Carousel/Infinite/InfinitePagingController.swiftTriple-buffer + snap physics
  • Carousel/Stack/StackedInfiniteCarouselView.swift3D circle stack (Vertical + Horizontal)
  • Carousel/Stack/StackCarouselMetrics.swiftAxis-aware sizing helpers
  • Carousel/AutoScrollController.swiftTimer + resume delay
  • Carousel/CarouselConfiguration.swiftShared knobs

Thank you for reading!

--

--