精确掌控 SwiftUI 滚动:自定义 Paging 实现

发表于

从 iOS 17 开始,SwiftUI 引入了 scrollTargetBehavior,让开发者能够更精准地控制滚动行为。无论是实现视图停靠对齐,还是自定义翻页效果,scrollTargetBehavior 都提供了强大的支持。更重要的是,开发者可以通过自定义 ScrollTargetBehavior 来满足特定的需求。本文将从一个实际案例出发,逐步解析如何使用 scrollTargetBehavior,并最终实现一个自定义的滚动控制逻辑。

发现问题:默认 paging 的局限

几天前,一位网友提出了一个关于 scrollTargetBehavior 的问题:在使用默认的 paging 行为时,横屏模式下(Landscape)的滚动会出现偏移,无法精准对齐页面。

Swift
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 库,直接启用底层 UIScrollViewisPagingEnabled 属性。

Swift
ScrollView {
    .... 
}
.introspect(.scrollView, on: .iOS(.v17), .iOS(.v18)) {
    $0.isPagingEnabled = true
}

然而,结果与使用 .scrollTargetBehavior(.paging) 完全一致,横屏模式下依然存在偏移问题。这让我意识到,默认的 paging 行为可能并未真正依赖 ScrollTargetBehavior 实现。

我已将此问题反馈给苹果(FB16486510)。

替代方案:viewAligned 能解决问题吗?

考虑到网友代码中每个视图的宽度恰好等于滚动容器的宽度,我建议他尝试 viewAligned,该模式可确保滚动结束时视图边缘与容器边缘对齐。

Swift
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 赋予了开发者自定义滚动行为的能力。其声明如下:

Swift
@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 实现。

版本一:基于速度矢量的简单实现

在第一个版本中,我们根据滚动的速度矢量判断滚动方向,并在初始位置的基础上增加或减少滚动容器的宽度。

Swift
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)来决定是否翻页。

Swift
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
        }
    }
}

然而,这个版本仍然存在问题:如果在上次滚动未完全停止时再次滚动,起始位置可能已经偏移,导致后续翻页不准确。

版本三:完善的翻页控制

在第三个版本中,我们不仅需要解决之前的问题,还要增加对以下边缘情况的处理:

  • 内容尺寸小于容器尺寸
  • 内容尺寸不是容器尺寸的整数倍
  • 确保停止位置在合法范围内
Swift
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
  }
}

可以轻松应对内容尺寸不是容器尺寸整数倍的情况。

Swift
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 名苹果生态的中文开发者一起交流!"

每周精选 Swift 与 SwiftUI 精华!