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
UIViewRepresentablebridge. .viewAlignedreads 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.