Starting with iOS 17, SwiftUI introduced scrollTargetBehavior
, enabling developers to control scrolling behavior with greater precision. Whether it’s aligning views at rest or implementing custom paging effects, scrollTargetBehavior
offers robust support. More importantly, developers can create custom ScrollTargetBehavior
implementations to meet specific needs. This article will walk through a real-world example, step by step, to demonstrate how to use scrollTargetBehavior
and ultimately implement a custom scrolling control logic.
Problem: Limitations of Default Paging
A few days ago, a developer raised an issue with scrollTargetBehavior
: when using the default paging
behavior, scrolling in landscape mode (Landscape) resulted in misalignment, failing to snap to the correct page.
struct Step0: View {
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(0 ..< 20) { page in
Text(page, format: .number)
.font(.title)
// Set the width of each child view to match the ScrollView container's width
.containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0, alignment: .center)
.frame(height: 200)
.background(.secondary.opacity(0.3))
.border(.red, width: 2)
}
}
}
.border(.blue, width: 2)
.scrollTargetBehavior(.paging)
}
}
This issue surprised me, as paging logic should be relatively straightforward. To quickly address the problem, I first tried using the Introspect
library to directly enable the isPagingEnabled
property of the underlying UIScrollView
.
ScrollView {
....
}
.introspect(.scrollView, on: .iOS(.v17), .iOS(.v18)) {
$0.isPagingEnabled = true
}
However, the result was identical to using .scrollTargetBehavior(.paging)
, with the same misalignment issue in landscape mode. This led me to realize that the default paging
behavior might not actually rely on ScrollTargetBehavior
for its implementation.
I have reported this issue to Apple (FB16486510).
Alternative Solution: Can viewAligned Fix the Problem?
Given that each view in the developer’s code had a width exactly matching the scroll container’s width, I suggested trying viewAligned
, a mode that ensures the edges of the view align with the container’s edges at the end of a scroll.
struct Step1: View {
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(0 ..< 20) { page in
Text(page, format: .number)
.font(.title)
.containerRelativeFrame(.horizontal, count: 1, span: 1, spacing: 0, alignment: .center)
.frame(height: 200)
.background(.secondary.opacity(0.3))
.border(.red, width: 2)
}
}
// viewAligned requires scrollTargetLayout
.scrollTargetLayout()
}
.border(.blue, width: 2)
// Limit scrolling to one view at a time
.scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne))
}
}
viewAligned
offers several levels of control precision:
alwaysByOne
: Scroll one view at a timealwaysByFew
: Scroll a small number of viewsnever
: No limit on the number of views scrolled
However, during testing, I discovered that alwaysByOne
does not guarantee scrolling only one view at a time. In the current code, since each child view’s width matches the scroll container’s width, it coincidentally achieves a paging-like effect. But if the child views are narrower, the scrolling distance becomes unpredictable.
Additionally, viewAligned
requires the scroll container’s content to consist of multiple child views, making it unsuitable for scenarios like Swift Charts. At this point, implementing a custom paging
behavior seemed like the only viable solution.
Custom Paging Implementation
ScrollTargetBehavior
empowers developers to customize scrolling behavior. Its declaration is as follows:
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public protocol ScrollTargetBehavior {
/// Updates the scroll target position
func updateTarget(_ target: inout ScrollTarget, context: Self.TargetContext)
/// The context in which a scroll behavior updates the scroll target.
typealias TargetContext = ScrollTargetBehaviorContext
}
SwiftUI calls updateTarget
at the end of a scroll gesture, allowing developers to adjust the target
position.
ScrollTargetBehaviorContext
provides the following key information:
- originalTarget: The target position at the start of the gesture
- velocity: The velocity vector
- contentSize: The size of the scrollable content
- containerSize: The size of the scroll container
- axis: The scrollable axis
ScrollTarget
not only sets the target position but also provides the scroll target calculated by ScrollView
based on the gesture.
Next, we will iteratively refine a custom paging
implementation through multiple versions.
Version 1: Simple Implementation Based on Velocity
In the first version, we determine the scroll direction based on the velocity vector and adjust the target position by adding or subtracting the scroll container’s width.
struct CustomHorizontalPagingBehavior: ScrollTargetBehavior {
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
// Current ScrollView width
let scrollViewWidth = context.containerSize.width
// Adjust the target position based on scroll direction
if context.velocity.dx > 0 {
// Scroll right: target position = starting position + ScrollView width
target.rect.origin.x = context.originalTarget.rect.minX + scrollViewWidth
} else if context.velocity.dx < 0 {
// Scroll left: target position = starting position - ScrollView width
target.rect.origin.x = context.originalTarget.rect.minX - scrollViewWidth
}
}
}
extension ScrollTargetBehavior where Self == CustomHorizontalPagingBehavior {
static var horizontalPaging: CustomHorizontalPagingBehavior { .init() }
}
// Usage
.scrollTargetBehavior(.horizontalPaging)
This implementation seems reasonable but has a clear flaw: if the scroll gesture ends with zero velocity, the paging action will not trigger.
Version 2: Improved Implementation Based on Scroll Distance
In the second version, we determine the scroll direction by calculating the difference between the target position and the starting position. We then decide whether to page based on a preset condition (e.g., scroll distance exceeding 1/3 of the container’s width).
struct CustomHorizontalPagingBehavior: ScrollTargetBehavior {
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
// Current ScrollView width
let scrollViewWidth = context.containerSize.width
// Scroll distance
let distance = context.originalTarget.rect.minX - target.rect.minX
// Adjust the target position based on scroll distance
// If the scroll distance exceeds 1/3 of the ScrollView width, switch to the next page
if abs(distance) > scrollViewWidth / 3 {
if distance > 0 {
target.rect.origin.x = context.originalTarget.rect.minX - scrollViewWidth
} else {
target.rect.origin.x = context.originalTarget.rect.minX + scrollViewWidth
}
} else {
// If the scroll distance is less than 1/3, return to the original position
target.rect.origin.x = context.originalTarget.rect.minX
}
}
}
However, this version still has issues: if scrolling occurs before the previous scroll has fully stopped, the starting position may already be misaligned, leading to inaccurate paging.
Version 3: Robust Paging Control
In the third version, we not only address the previous issues but also handle the following edge cases:
- Content size smaller than the container size
- Content size not an exact multiple of the container size
- Ensuring the stopping position is within valid bounds
struct CustomHorizontalPagingBehavior: ScrollTargetBehavior {
enum Direction {
case left, right, none
}
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
let scrollViewWidth = context.containerSize.width
let contentWidth = context.contentSize.width
// If the content width is less than or equal to the ScrollView width, align to the leftmost position
guard contentWidth > scrollViewWidth else {
target.rect.origin.x = 0
return
}
let originalOffset = context.originalTarget.rect.minX
let targetOffset = target.rect.minX
// Determine the scroll direction by comparing the original offset with the target offset
let direction: Direction = targetOffset > originalOffset ? .left : (targetOffset < originalOffset ? .right : .none)
guard direction != .none else {
target.rect.origin.x = originalOffset
return
}
let thresholdRatio: CGFloat = 1 / 3
// Calculate the remaining content width based on the scroll direction and determine the drag threshold
let remaining: CGFloat = direction == .left
? (contentWidth - context.originalTarget.rect.maxX)
: (context.originalTarget.rect.minX)
let threshold = remaining <= scrollViewWidth ? remaining * thresholdRatio : scrollViewWidth * thresholdRatio
let dragDistance = originalOffset - targetOffset
var destination: CGFloat = originalOffset
if abs(dragDistance) > threshold {
// If the drag distance exceeds the threshold, adjust the target to the previous or next page
destination = dragDistance > 0 ? originalOffset - scrollViewWidth : originalOffset + scrollViewWidth
} else {
// If the drag distance is within the threshold, align based on the scroll direction
if direction == .right {
// Scroll right (page left), round up
destination = ceil(originalOffset / scrollViewWidth) * scrollViewWidth
} else {
// Scroll left (page right), round down
destination = floor(originalOffset / scrollViewWidth) * scrollViewWidth
}
}
// Boundary handling: Ensure the destination is within valid bounds and aligns with pages
let maxOffset = contentWidth - scrollViewWidth
let boundedDestination = min(max(destination, 0), maxOffset)
if boundedDestination >= maxOffset * 0.95 {
// If near the end, snap to the last possible position
destination = maxOffset
} else if boundedDestination <= scrollViewWidth * 0.05 {
// If near the start, snap to the beginning
destination = 0
} else {
if direction == .right {
// For right-to-left scrolling, calculate from the right end
let offsetFromRight = maxOffset - boundedDestination
let pageFromRight = round(offsetFromRight / scrollViewWidth)
destination = maxOffset - (pageFromRight * scrollViewWidth)
} else {
// For left-to-right scrolling, keep original behavior
let pageNumber = round(boundedDestination / scrollViewWidth)
destination = min(pageNumber * scrollViewWidth, maxOffset)
}
}
target.rect.origin.x = destination
}
}
This version can easily handle cases where the content size is not an exact multiple of the container size.
struct Step2: View {
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(0 ..< 10) { page in
Text(page, format: .number)
.font(.title)
// Set the width to 1/3 of the ScrollView width
.containerRelativeFrame(.horizontal, count: 3, span: 1, spacing: 0, alignment: .center)
.frame(height: 200)
.background(.secondary.opacity(0.3))
.border(.red, width: 2)
}
}
}
.border(.blue, width: 2)
.scrollTargetBehavior(.horizontalPaging)
}
}
I have shared both support horizontal and vertical paging implementations in this Gist. Feel free to check it out.
Beyond Paging
By customizing ScrollTargetBehavior
, we not only achieved horizontal paging but can also extend it to support vertical scrolling or more complex scrolling logic. For example, combining scroll velocity (velocity
) to implement multi-page scrolling during fast swipes and single-page scrolling during light swipes.
Additionally, scrollTargetBehavior
can serve as a tool for dynamic data loading. Compared to using onAppear
in lazy views, it allows us to trigger data loading earlier during scrolling, thereby improving the scroll jump issue caused by dynamic data loading in SwiftUI’s lazy containers.
Although
onScrollGeometryChange
can achieve similar functionality, it is only available on iOS 18 and later, whereasScrollTargetBehavior
has been supported since iOS 17, making it more widely applicable.
Conclusion
This article walked through a real-world example, detailing how to use scrollTargetBehavior
to implement custom scrolling logic. From identifying the problem to iteratively refining the solution, we ultimately achieved a stable and flexible paging implementation. I hope this article provides inspiration and guidance for implementing complex scrolling behaviors in SwiftUI.