SwiftUI ScrollView Paging, Snapping, and Content Margins
Modern ScrollView is configured declaratively: scrollTargetBehavior for paging and snapping, scrollPosition for tracking, contentMargins for insets, plus bounce and disable controls.
import SwiftUI
struct ColorPager: View {
@State private var current: Int?
var body: some View {
VStack {
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(0..<6, id: \.self) { index in
RoundedRectangle(cornerRadius: 24)
.fill(.indigo.gradient)
.containerRelativeFrame(.horizontal)
.frame(height: 220)
.id(index)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.paging)
.scrollPosition(id: $current)
.contentMargins(.horizontal, 20, for: .scrollContent)
Text("Page \((current ?? 0) + 1) of 6")
.font(.caption)
}
}
}Declarative scroll configuration
ScrollView gained a family of modifiers that replace most UIScrollView delegate work. The pattern: describe the geometry (scrollTargetLayout, contentMargins), the physics (scrollTargetBehavior, scrollBounceBehavior), and the state (scrollPosition), then let the system drive.
Snapping
Two built-in behaviors cover carousels:
.scrollTargetBehavior(.paging) // one page per swipe
.scrollTargetBehavior(.viewAligned) // settle on item edges
.viewAligned needs to know what an "item" is — apply .scrollTargetLayout() to the LazyHStack or LazyVStack whose children are the snap targets.
Tracking and driving position
.scrollPosition(id: $current) binds the leading visible item's identity. Write to the binding (optionally inside withAnimation) to scroll programmatically; read it to update page indicators. For pixel-level needs, ScrollPosition also supports offsets and edges.
Margins, bounce, and disabling
contentMargins(.horizontal, 20, for: .scrollContent) insets the content while letting indicators hug the screen edge. scrollBounceBehavior(.basedOnSize) stops short content from rubber-banding. scrollDisabled(true) freezes scrolling while a drag gesture or edit mode owns the touch.
Common mistakes
- Forgetting
scrollTargetLayout, so viewAligned has nothing to align to. - Padding the content instead of using contentMargins, which clips shadows at the edges.
- Tracking position with GeometryReader when scrollPosition already reports it.
Related reference
containerRelativeFrame sizes a view as a fraction of its nearest container — full width, one-of-three columns, or custom math — without GeometryReader.
ScrollViewReader exposes a proxy whose scrollTo(_:anchor:) jumps to any child with an id — chat bottoms, section indexes, and back-to-top buttons.
tabViewStyle(.page) turns TabView into a swipeable pager — onboarding carousels and image galleries — with index dots controlled by indexViewStyle and display mode.