← All shorts
06iOS 17+2 min read

scrollTargetBehavior: Snapping Carousels, No UIKit

Build a paging, view-aligned carousel with plain SwiftUI — no UICollectionView, no compositional layout.

A snapping horizontal carousel. App Store cards, a menu picker, that shape. Getting one used to mean UICollectionView plus a compositional layout, or hand-rolling offset math against a DragGesture and hoping the deceleration felt right.

SwiftUI snaps for you now.

#The old way

let layout = UICollectionViewCompositionalLayout { _, _ in
    let itemSize = NSCollectionLayoutSize(
        widthDimension: .absolute(220),
        heightDimension: .absolute(100)
    )
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
    let section = NSCollectionLayoutSection(group: group)
    section.orthogonalScrollingBehavior = .groupPagingCentered
    section.interGroupSpacing = 16
    return section
}

That's the easy part. The layout still needs a UIViewRepresentable wrapper, a diffable data source, and cell registration before anything appears on screen. Four cards, all that scaffolding.

#The new way

import SwiftUI

struct MenuItem: Identifiable {
    let id = UUID()
    let name: String
    let price: String
}

struct MenuCarousel: View {
    let items: [MenuItem] = [
        MenuItem(name: "Espresso", price: "$3.50"),
        MenuItem(name: "Cappuccino", price: "$4.50"),
        MenuItem(name: "Cold Brew", price: "$5.00"),
        MenuItem(name: "Matcha Latte", price: "$5.50")
    ]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 16) {
                ForEach(items) { item in
                    VStack(alignment: .leading, spacing: 4) {
                        Text(item.name)
                            .font(.headline)
                        Text(item.price)
                            .foregroundStyle(.secondary)
                    }
                    .padding()
                    .frame(width: 220, height: 100, alignment: .leading)
                    .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16))
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
        .contentMargins(.horizontal, 20, for: .scrollContent)
    }
}

Two modifiers on the ScrollView, one on the stack. That's the whole carousel.

#Why it matters

  • No cell registration, no diffable data source, no UIViewRepresentable bridge.
  • .viewAligned reads each subview's natural bounds, so mixed-width items still snap correctly.
  • It composes with LazyHStack/LazyVStack. Lazy loading and snapping aren't mutually exclusive anymore.
  • .contentMargins(.horizontal, 20, for: .scrollContent) gives you inset "peeking" edges for free, without fudging the first and last item's padding.

#Gotcha

.scrollTargetBehavior(.viewAligned) does nothing on its own. It needs .scrollTargetLayout() on the LazyHStack inside the ScrollView.

The behavior modifier answers how the scroll view settles; the layout modifier answers what counts as a target. Drop the second one and there's nothing to settle on, so the carousel scrolls freely. No snap. Nothing warns you either. It silently behaves like a plain ScrollView.

swiftuiios17scrolling

Related shorts