从 iOS 17 开始,SwiftUI 引入了 scrollTargetBehavior
,让开发者能够更精准地控制滚动行为。无论是实现视图停靠对齐,还是自定义翻页效果,scrollTargetBehavior
都提供了强大的支持。更重要的是,开发者可以通过自定义 ScrollTargetBehavior
来满足特定的需求。本文将从一个实际案例出发,逐步解析如何使用 scrollTargetBehavior
,并最终实现一个自定义的滚动控制逻辑。
发现问题:默认 paging 的局限
几天前,一位网友提出了一个关于 scrollTargetBehavior
的问题:在使用默认的 paging
行为时,横屏模式下(Landscape)的滚动会出现偏移,无法精准对齐页面。
struct Step0: View {
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(0 ..< 20) { page in
Text(page, format: .number)
.font(.title)
// 以 ScrollView 容器的宽度作为子视图的宽度
.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)
}
}
这个问题让我感到有些意外,因为翻页逻辑本应是一个相对简单的功能。为了快速解决问题,我首先尝试使用 Introspect
库,直接启用底层 UIScrollView
的 isPagingEnabled
属性。
ScrollView {
....
}
.introspect(.scrollView, on: .iOS(.v17), .iOS(.v18)) {
$0.isPagingEnabled = true
}
然而,结果与使用 .scrollTargetBehavior(.paging)
完全一致,横屏模式下依然存在偏移问题。这让我意识到,默认的 paging
行为可能并未真正依赖 ScrollTargetBehavior
实现。
我已将此问题反馈给苹果(FB16486510)。
替代方案:viewAligned 能解决问题吗?
考虑到网友代码中每个视图的宽度恰好等于滚动容器的宽度,我建议他尝试 viewAligned
,该模式可确保滚动结束时视图边缘与容器边缘对齐。
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 需配合 scrollTargetLayout 使用
.scrollTargetLayout()
}
.border(.blue, width: 2)
// 限制每次滚动一个视图
.scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne))
}
}
viewAligned
提供了几种不同的控制精度:
alwaysByOne
:每次滚动一个视图alwaysByFew
:滚动少量视图never
:不限制滚动数量
然而,实际测试中发现,alwaysByOne
并不能确保每次只滚动一个视图。在当前代码中,由于每个子视图的宽度与滚动容器一致,恰好实现了类似 paging
的效果。但如果子视图宽度较小,滚动距离将变得不可控。
此外,viewAligned
要求滚动容器中的内容必须由多个子视图组成,因此不适用于 Swift Charts 这类应用场景。至此,定制 paging
行为似乎成为唯一可行的解决方案。
自定义 Paging 实现
ScrollTargetBehavior
赋予了开发者自定义滚动行为的能力。其声明如下:
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public protocol ScrollTargetBehavior {
/// 更新滚动目标位置
func updateTarget(_ target: inout ScrollTarget, context: Self.TargetContext)
/// The context in which a scroll behavior updates the scroll target.
typealias TargetContext = ScrollTargetBehaviorContext
}
SwiftUI 会在滚动手势结束时调用 updateTarget
,开发者可以在此调整 target
位置
ScrollTargetBehaviorContext
提供了以下关键信息:
- originalTarget:手势开始时的目标位置
- velocity:速度矢量
- contentSize:滚动内容的尺寸
- containerSize:滚动容器的尺寸
- axis:可滚动的轴
ScrollTarget
除了用于设置目标位置外,还会提供 ScrollView
根据手势操作而计算的滚动目标。
接下来,我们将通过多个版本的迭代,逐步完善自定义的 paging
实现。
版本一:基于速度矢量的简单实现
在第一个版本中,我们根据滚动的速度矢量判断滚动方向,并在初始位置的基础上增加或减少滚动容器的宽度。
struct CustomHorizontalPagingBehavior: ScrollTargetBehavior {
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
// 当前 ScrollView 宽度
let scrollViewWidth = context.containerSize.width
// 根据滚动方向,调整目标位置
if context.velocity.dx > 0 {
// 向右滚动, 目标位置为起始位置 + ScrollView 宽度
target.rect.origin.x = context.originalTarget.rect.minX + scrollViewWidth
} else if context.velocity.dx < 0 {
// 向左滚动, 目标位置为起始位置 - ScrollView 宽度
target.rect.origin.x = context.originalTarget.rect.minX - scrollViewWidth
}
}
}
extension ScrollTargetBehavior where Self == CustomHorizontalPagingBehavior {
static var horizontalPaging: CustomHorizontalPagingBehavior { .init() }
}
// 使用方法
.scrollTargetBehavior(.horizontalPaging)
这个实现看似合理,但存在一个明显问题:如果滚动手势结束时速度为零,翻页操作将不会触发。
版本二:基于滚动距离的改进
在第二个版本中,我们通过计算目标位置与起始位置的差值来判断滚动方向,并根据预设条件(如滚动距离超过容器宽度的 1/3)来决定是否翻页。
struct CustomHorizontalPagingBehavior: ScrollTargetBehavior {
func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
// 当前 ScrollView 宽度
let scrollViewWidth = context.containerSize.width
// 滚动距离
let distance = context.originalTarget.rect.minX - target.rect.minX
// 根据滚动距离,调整目标位置
// 如果滚动距离超过 ScrollView 宽度的 1/3,就切换到下一页
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 {
// 滚动距离不足 1/3,回到原位置
target.rect.origin.x = context.originalTarget.rect.minX
}
}
}
然而,这个版本仍然存在问题:如果在上次滚动未完全停止时再次滚动,起始位置可能已经偏移,导致后续翻页不准确。
版本三:完善的翻页控制
在第三个版本中,我们不仅需要解决之前的问题,还要增加对以下边缘情况的处理:
- 内容尺寸小于容器尺寸
- 内容尺寸不是容器尺寸的整数倍
- 确保停止位置在合法范围内
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
// 如果内容宽度小于或等于ScrollView宽度,对齐到最左边位置
guard contentWidth > scrollViewWidth else {
target.rect.origin.x = 0
return
}
let originalOffset = context.originalTarget.rect.minX
let targetOffset = target.rect.minX
// 通过比较原始偏移量和目标偏移量来确定滚动方向
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
// 根据滚动方向计算剩余内容宽度并确定拖动阈值
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 {
// 如果拖动距离超过阈值,调整目标到上一页或下一页
destination = dragDistance > 0 ? originalOffset - scrollViewWidth : originalOffset + scrollViewWidth
} else {
// 如果拖动距离在阈值内,根据滚动方向对齐
if direction == .right {
// 向右滚动(向左翻页),向上取整
destination = ceil(originalOffset / scrollViewWidth) * scrollViewWidth
} else {
// 向左滚动(向右翻页),向下取整
destination = floor(originalOffset / scrollViewWidth) * scrollViewWidth
}
}
// 边界处理:确保目标位置在有效范围内并与页面对齐
let maxOffset = contentWidth - scrollViewWidth
let boundedDestination = min(max(destination, 0), maxOffset)
if boundedDestination >= maxOffset * 0.95 {
// 如果接近末尾,贴合到最后可能的位置
destination = maxOffset
} else if boundedDestination <= scrollViewWidth * 0.05 {
// 如果接近开始,贴合到起始位置
destination = 0
} else {
if direction == .right {
// 对于从右向左滚动,从右端计算
let offsetFromRight = maxOffset - boundedDestination
let pageFromRight = round(offsetFromRight / scrollViewWidth)
destination = maxOffset - (pageFromRight * scrollViewWidth)
} else {
// 对于从左向右滚动,保持原始行为
let pageNumber = round(boundedDestination / scrollViewWidth)
destination = min(pageNumber * scrollViewWidth, maxOffset)
}
}
target.rect.origin.x = destination
}
}
可以轻松应对内容尺寸不是容器尺寸整数倍的情况。
struct Step2: View {
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(0 ..< 10) { page in
Text(page, format: .number)
.font(.title)
// 宽度为 ScrollView 的 1/3
.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)
}
}
我将同时支持水平和垂直翻页的实现放在了 Gist 中,欢迎查看。
超越 Paging
通过自定义 ScrollTargetBehavior
,我们不仅实现了横向翻页功能,还可以进一步扩展,支持纵向滚动或更复杂的滚动逻辑。例如,结合滚动速度(velocity
)实现快速滑动时滚动多页,轻滑时滚动一页的效果。
此外,scrollTargetBehavior
还可以作为一种动态加载数据的工具。相比惰性视图中的 onAppear
,它允许我们在滚动过程中更早地触发数据加载,从而改善 SwiftUI 惰性容器因动态加载数据导致的滚动跳跃问题。
尽管
onScrollGeometryChange
也能实现类似功能,但它仅适用于 iOS 18 及以上,而ScrollTargetBehavior
从 iOS 17 就已可用,具备更广泛的适用性。
总结
本文通过一个实际案例,详细解析了如何使用 scrollTargetBehavior
实现自定义的滚动控制逻辑。从发现问题到逐步优化,我们最终实现了一个稳定且灵活的翻页功能。希望这篇文章能为你在 SwiftUI 中实现复杂滚动行为提供启发和帮助。
"加入我们的 Discord 社区,与超过 2000 名苹果生态的中文开发者一起交流!"