ScrollView snapping in SwiftUI
By default, a ScrollView in SwiftUI uses the system's standard deceleration rate and the scrolling velocity to determine where the scroll motion should stop. While this behavior works well in most situations, it doesn't consider the dimensions of the scroll view or its content.
In some interfaces, it can be useful to customize the scroll behavior and make the scroll view snap precisely to a page or a specific view.
I've been exploring the APIs that SwiftUI provides to customize scroll snapping, and in this post I'll share what I've learned and what to keep in mind to avoid unexpected results.
# Paging scroll behavior
When the content of each scroll view item fills the full height of the screen in a vertical scroll, or the full width of the screen in a horizontal scroll, it often makes sense to enable paging so that scrolling snaps in page-sized increments based on the visible region rather than stopping at arbitrary positions.
Let's look at a simple example with a horizontal gallery of cat images where each image matches the container visible width.
ScrollView(.horizontal) {
LazyHStack {
ForEach(catPhotos, id: \.self) { photo in
Image(photo)
.resizable()
.scaledToFit()
.containerRelativeFrame(.horizontal)
}
}
}
.scrollIndicators(.hidden)
As the user scrolls through the images, the final offset is determined by velocity and deceleration, so it can settle between pages and leave an image partially visible.
To customize where scroll gestures should end, SwiftUI provides the scrollTargetBehavior(_:) modifier. We can apply it to the ScrollView in our code, and pass it the paging value. This value tells SwiftUI to adjust the deceleration and target calculation so the final resting position aligns with the visible container size.
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(catPhotos, id: \.self) { photo in
Image(photo)
.resizable()
.scaledToFit()
.containerRelativeFrame(.horizontal)
}
}
}
.scrollIndicators(.hidden)
.scrollTargetBehavior(.paging)
Note that for it to work correctly with our horizontal scroll example, we also need to set the LazyHStack spacing to zero. Without it, the default spacing assigned by SwiftUI will interfere with the scroll offset calculations and the result won't be correct.
With this setup, the scroll gesture will flip through each image, always settling on one image per page that takes up the full width of the screen.
While this works well in the vertical orientation, in the horizontal one the safe area insets seem to affect the offset preventing correct alignment. If the layout allows it, as in our image gallery example, we can ignore the horizontal safe area insets so that paging works as expected.
ScrollView(.horizontal) {
...
}
.scrollIndicators(.hidden)
.scrollTargetBehavior(.paging)
.ignoresSafeArea(.container, edges: .horizontal)
# View aligned scroll behavior
# View-aligned snapping with full-width content
In addition to the paging scroll behavior, where the scroll snaps to the container’s visible region, SwiftUI also provides a viewAligned option that makes the scroll snap to individual views instead. When using this behavior, we also need to mark the scrollable content with the scrollTargetLayout(isEnabled:) modifier so SwiftUI knows which views should act as snap targets.
We can apply the view aligned scroll snapping to our image gallery by using the scrollTargetBehavior() modifier with the viewAligned value and marking the LazyHStack with scrollTargetLayout().
ScrollView(.horizontal) {
LazyHStack {
ForEach(catPhotos, id: \.self) { photo in
Image(photo)
.resizable()
.scaledToFit()
.containerRelativeFrame(.horizontal)
}
}
.scrollTargetLayout()
}
.scrollIndicators(.hidden)
.scrollTargetBehavior(.viewAligned)
Since each image still takes up the full width of the container, this approach produces the same paging-like behavior as the paging option we used earlier, but it works without setting the LazyHStack spacing to zero.
Another benefit is that it also behaves correctly when the horizontal safe area insets are preserved, allowing it to work as expected in horizontal phone orientation without any additional adjustments.
# View-aligned snapping with multiple items visible
The view aligned scrolling behavior is particularly useful in horizontal galleries that display several items at once, with one partially visible to hint at more content beyond the edge. When view aligned snapping is not enabled, scrolling through such a layout can require precise aiming from the user, while the view aligned behavior makes scrolling feel guided and intentional, snapping cleanly to the nearest item.
ScrollView(.horizontal) {
LazyHStack {
ForEach(catPhotos, id: \.self) { photo in
Image(photo)
.resizable()
.scaledToFit()
.containerRelativeFrame(
.horizontal, count: 5,
span: 2, spacing: 0
)
}
}
.scrollTargetLayout()
}
.scrollIndicators(.hidden)
.scrollTargetBehavior(.viewAligned)
# View-aligned snapping and oversized items
One important consideration to keep in mind when applying the view aligned scroll behavior, is that each scroll target should fit within the visible region of the scroll view. If an item's width in a horizontal scroll or height in a vertical one exceeds the container's size, scrolling can feel broken. The oversized item will interfere with the snapping logic and make it difficult to continue scrolling once reached.
Let's say we wanted to modify our horizontal gallery by changing the containerRelativeFrame() axis from horizontal to vertical, so that each photo takes the same height while growing in width as its aspect ratio requires.
ScrollView(.horizontal) {
LazyHStack {
ForEach(catPhotos, id: \.self) { photo in
Image(photo)
.resizable()
.scaledToFit()
.containerRelativeFrame(
.vertical, count: 5,
span: 2, spacing: 0
)
}
}
.scrollTargetLayout()
}
.scrollIndicators(.hidden)
.scrollTargetBehavior(.viewAligned)
In this case, some cat photos can become too wide, wider than the phone screen. When such a wide photo is reached, the scrolling will feel stuck.
In layouts with large items that extend beyond the visible bounds and require custom snapping, it can make sense to define a custom ScrollTargetBehavior, which provides finer control over how the content should scroll, for example by snapping at fixed distances or to specific offsets.
These are just a few ways to customize scrolling in SwiftUI, and there's still plenty more to explore. Since iOS 17, SwiftUI has gained a much richer scrolling system, not just for snapping, but also for defining scroll targets, margins, and positions, giving us finer control over how content moves, aligns, and interacts with its container.
If you are looking to build a strong foundation in SwiftUI, my book SwiftUI Fundamentals is a great place to start. It takes a deep dive into the framework's core principles and APIs to help you understand how it works under the hood and how to use it effectively in your projects.
For more resources on Swift and SwiftUI, check out my other books and book bundles.



