
Define how scrolling behaves with Scroll Target Behavior in SwiftUI
Learn how to customize the way the scroll behavior ends and the content aligns when scrolling in SwiftUI
When scrolling a view in SwiftUI, you can customize how the scroll finishes and how the content aligns by using ScrollTargetBehavior
, a protocol that helps to define the scroll behavior of a scrollable view, and scrollTargetBehavior(_:)
, the related ScrollView
method that allows applying that behavior in the view.
ScrollView with a ScrollTargetBehavior implemented and without.
Here is how you can implement a scrolling behavior:
// 1.
ScrollView(.horizontal) {
// 2.
LazyHStack {
ForEach(0..<50) { x in
ZStack {
Rectangle()
.fill(colors.randomElement()?.opacity(0.7) ?? .gray)
.frame(width: 250, height: 180)
Text("\(x+1)")
.foregroundStyle(.white)
.fontWeight(.heavy)
}
.padding()
}
}
}
// 3.
.scrollTargetBehavior(.paging)
- Create an instance of
ScrollView
. - Add the view content.
- Call the
scrollTargetBehavior(_:)
modifier on theScrollView
and pass the expectedScrollTargetBehavior
(in the code snipper we usepaging
).
SwiftUI offers two built-in scroll behaviors:
paging
- AScrollTargetBehavior
that conforms toPagingScrollTargetBehavior
that results in aligning scroll targets to container-based geometry.viewAligned
- AScrollTargetBehavior
that conforms toViewAlignedScrollTargetBehavior
that results in aligning its scroll targets to a rectangle that’s aligned to the geometry of a view.
While paging can be easily implemented as demonstrated before, viewAligned
needs some code integration.
ScrollView(.horizontal) {
LazyHStack {
ForEach(0..<50) { x in
ZStack {
Rectangle()
.fill(colors.randomElement()?.opacity(0.7) ?? .gray)
.frame(width: 250, height: 180)
Text("\(x+1)")
.foregroundStyle(.white)
.fontWeight(.heavy)
}
.padding()
}
}
// 1.
.scrollTargetLayout()
}
// 2.
.scrollTargetBehavior(.viewAligned)
- Use the
scrollTargetLayout(isEnabled:)
modifier on the container whose content needs to align asScrollView
gets scrolled. - Call the
scrollTargetBehavior(_:)
and pass theviewAligned
value.
ViewAlignedScrollTargetBehavior
conforming behavior such as viewAligned
works along scrollTargetLayout(isEnabled:)
method, which applies scroll targeting only to the content inside the layout it's called on, while making sure that any nested target layouts don’t also act as scroll target areas.
paging - viewAligned
The number of views that can be scrolled at a time can be defined by using a LimitBehavior
, which comes with the following built-in values:
automatic
: the default behavior that limits scrolling only in compact horizontal environments.always
: always limits scrolling regardless of the environment.alwaysByFew
: limits to a few items per gesture (chosen automatically).alwaysByOne
: limits a one-item scroll per interaction.never
: never limits the amount of views that can be scrolled.
Here is how to apply a limit behavior:
ScrollView(.horizontal) {
...
}
// 1.
.scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne))
Specify the LimitBehavior
when you create a view aligned scroll behavior using ViewAlignedScrollTargetBehavior(limitBehavior:)
by passing one of those values as a parameter accordingly to the scrolling behavior designed.
With 26.0 OS, two new ViewAlignedScrollTargetBehavior
constructors are available:
init(anchor:)
: allowing the specification of the anchor point for a view-aligned scroll behavior.init(limitBehavior:anchor:)
: allowing to specify both limit behavior and anchor for a view-aligned scroll behavior.
The scroll behavior customization can go further with ScrollTargetBehavior
protocol, that allows shaping the behavior leveraging the updateTarget(_:context:)
method, responsible to update the proposed target that a scrollable view should scroll to.
// 1.
struct OneCenteredItemScrollTargetBehavior: ScrollTargetBehavior {
// 2.
func updateTarget(_ target: inout ScrollTarget, context: ScrollTargetBehaviorContext) {
// 3.
// 3a. Define layout constants
let itemWidth: CGFloat = 250
let padding: CGFloat = 16
let totalItemWidth = itemWidth + padding * 2
let containerWidth = context.containerSize.width
// 3b. Calculate scroll offset and delta
let originalOffset = context.originalTarget.rect.origin.x
let proposedOffset = target.rect.origin.x
let scrollDelta = proposedOffset - originalOffset
// 3c. Calculate the index of the item currently centered
let currentIndex = Int(round((originalOffset + containerWidth / 2) / totalItemWidth))
// 3d. Determine scroll direction and update index
var targetIndex = currentIndex
if scrollDelta > totalItemWidth * 0.2 {
targetIndex += 1 // Scroll right
} else if scrollDelta < -totalItemWidth * 0.2 {
targetIndex -= 1 // Scroll left
}
// 3e. Clamp the index within bounds
targetIndex = max(0, min(49, targetIndex))
// 3f. Compute offset to center the item
let centeredOffset = CGFloat(targetIndex) * totalItemWidth + totalItemWidth / 2 - containerWidth / 2
// 3g. Apply the updated rect
target.rect = CGRect(x: centeredOffset, y: 0, width: totalItemWidth, height: 1)
}
}
It works as follows:
- Create a custom struct that conforms to
ScrollTargetBehavior
. - Declare the
updateTarget(_:context:)
stub. - Add the logic of the scrolling behavior.
In this example, the scroll behavior should consist of scrolling one item at a time, which should always stay at the center of the screen:
- Set layout constants
- Capture the offset before and after the scroll
- Calculate the index of the item currently centered
- Determine scroll direction and update index
- Clamp the index within bounds
- Compute offset to center the item
- Update the
ScrollTarget
.
ScrollView(.horizontal) {
...
}
// 1.
.scrollTargetBehavior(OneCenteredItemScrollTargetBehavior())
- Pass an instance of the custom behavior to the
scrollTargetBehavior(_:)
method.
SwiftUI invokes updateTarget(_:context:)
in two main scenarios:
- At the end of a scroll gesture, the system uses system momentum to determine the natural stopping point.
- When the size of the scroll view changes, the system requires a new scroll position calculation.
By customizing this behavior, you can override the default destination, creating smooth, deliberate snapping effects tailored to your content.