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 theScrollViewand pass the expectedScrollTargetBehavior(in the code snipper we usepaging).
SwiftUI offers two built-in scroll behaviors:
paging- AScrollTargetBehaviorthat conforms toPagingScrollTargetBehaviorthat results in aligning scroll targets to container-based geometry.viewAligned- AScrollTargetBehaviorthat conforms toViewAlignedScrollTargetBehaviorthat 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 asScrollViewgets scrolled. - Call the
scrollTargetBehavior(_:)and pass theviewAlignedvalue.
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.