Snapping horizontal carousels — think App Store cards or a menu picker — used to mean reaching for UICollectionView and a compositional layout, or hand-rolling offset math with DragGesture. SwiftUI now snaps for you.
#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 layout then needs a UIViewRepresentable wrapper, a diffable data source, and cell registration just to show four cards.
#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)
}
}
#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.- Combines directly with
LazyHStack/LazyVStack, so 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/last item's padding.
#Gotcha
.scrollTargetBehavior(.viewAligned) does nothing without .scrollTargetLayout() applied to the LazyHStack inside the ScrollView. The behavior modifier tells the scroll view how to settle; the layout modifier tells it what counts as a target. Skip the second one and the carousel scrolls freely, no snap, no error, no warning — it just silently behaves like a plain ScrollView.