← 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.

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 UIViewRepresentable bridge.
  • .viewAligned reads 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.

swiftuiios17scrolling

Related shorts