使用 SwiftUI 开发无限四向滚动分页组件

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

SwiftUI 的出现为苹果生态的开发带来了革命性的变化,但在面对某些复杂需求时,它仍然存在一些挑战。最近,我开发了一个名为 Infinite4Pager 的组件,它支持无限四向滚动分页功能。

本文中我们将分析实现过程中的关键思路,讨论需要特别注意的事项,并坦诚地审视 SwiftUI 在应对这类场景时的不足之处。通过这个案例,我们不仅能学习到具体的技术实现,还能更好地理解如何在 SwiftUI 的框架下突破常规,创造性地解决问题。

需求背景

近期,一个有趣的挑战在我的开发社区中引起了热烈讨论。一位开发者在我的 Discord 频道中提出了一个独特的需求:用 SwiftUI 实现一个支持四向滚动的分页组件。这个组件可以被视为一个增强版的 TabView,不仅支持常规的左右滑动,还能上下滑动。更具挑战性的是,他希望能够实现懒加载功能,以优化内存使用,特别是在处理大量图片预览时。巧合的是,不久前我在推特上也收到了类似的询问,显示这确实是开发者们普遍关心的问题。

社区的反应迅速而热烈。很快,一位热心的开发者提出了基于 UIKit 的解决方案,利用 UICollectionView 并在其手势处理逻辑中添加自定义代码。然而,当讨论转向 SwiftUI 实现时,普遍观点认为这在 SwiftUI 中实现起来困难重重,甚至可能无法实现。

当我关注到这个讨论时,UIKit 的方案已经成型。虽然我同意在 SwiftUI 中实现这个功能确实存在一定挑战,但我认为这并非不可能。关键在于改变思维方式,跳出传统实现的框框,以 SwiftUI 特有的声明式和响应式编程范式来重新思考问题。这正是 SwiftUI 的魅力所在——它不仅是一个新的框架,更是一种全新的编程思维。

下面是基于 Infinite4Pager 的实现演示:

SwiftUI 的挑战

如果我们尝试直接将 UIKit 的实现逻辑迁移到 SwiftUI,我们将面临几个主要的挑战:

ScrollView 的有限定制性

尽管自 WWDC 2023 以来,Apple 显著增强了 SwiftUI 中 ScrollView 的控制能力,并在 WWDC 2024 中引入了更多强大的滚动控制 API,但 ScrollView 仍然存在一些开发者无法自定义的核心特性。最显著的限制之一是对其手势行为的干预。

ScrollView 的手势实现是内置且固定的,我们无法在其中插入自定义的判断逻辑。即使我们尝试使用并行的拖动手势来在滚动初期判断方向,一旦动态调整了 ScrollView 的滚动轴向,它就会自动重新调整滚动区域的对齐。尽管可以使用 scrollPosition 来修正位置,但这可能导致滚动抖动,甚至中断原有的拖动手势。

想深入了解 SwiftUI 滚动控制 API 的演变,请参阅 SwiftUI 滚动控制 API 的发展历程与 WWDC 2024 的新亮点

响应式编程的固有局限

SwiftUI 的响应式特性通常是其强大之处,但在某些场景下反而成为了精细控制的障碍。

例如,在 ForEach 的数据源中动态添加数据时,如果是在现有数据后追加以实现无限滚动,通常不会出现问题。但如果在数据源的前部插入数据,很可能会导致当前滚动位置发生变化,表现为滚动过程中的跳跃。这种情况在 ScrollView 配合惰性容器使用时尤为明显。

这是因为数据源变化时,惰性容器需要重新计算其高度,这个过程涉及到当前位置之前所有子视图的尺寸计算,从而引发上述问题。

关于惰性容器的高度变化问题,可以参考 List 还是 LazyVStack:SwiftUI 中的惰性容器选择 一文。

状态释放的滞后性

在 SwiftUI 中,惰性容器对子视图内存的管理策略与开发者的常规理解有所不同。它在释放内存资源方面不够积极,这在浏览大量图片等场景下可能导致内存占用过高,甚至引发应用崩溃。

想了解更多关于惰性容器的内存释放策略,请查阅 几个在 SwiftUI 中使用惰性容器的技巧和注意事项

这些挑战凸显了在 SwiftUI 中实现复杂自定义组件时需要采取不同于 UIKit 的思路和方法。接下来,我们将探讨如何巧妙利用 SwiftUI 的特性来克服这些困难。

解决方案

经过分析,我们发现 SwiftUI 的常规组件如 ScrollView、惰性容器和 ForEach 在这个特殊需求中可能成为阻碍而非助力。理解了这些限制后,我们的解决思路变得清晰明确:

  1. 自定义拖动手势模拟滚动:这使我们能够在拖动初期通过自定义逻辑限制滚动方向,克服 ScrollView 的局限性。

  2. 固定视图数量与动态内容更新:摒弃惰性容器和 ForEach 的方式,我们选择创建固定数量的视图,并根据位置变化动态更新视图内容。这种方法可以在滚动过程中保持较少的活跃视图数量,有效解决内存占用问题。

具体实现方案如下:

  • 灵活的滚动区域配置:允许用户自定义滚动区域的视图数量。单向设置为 nil 表示该方向无限滚动,设为 0 则禁用该方向的滚动。
  • 动态视图绘制:基于用户指定的初始位置,绘制当前视图及其周围的上下左右视图。

image-20240710095631674

  • 有限滚动区域的处理:当滚动区域有限制时(如 5x5 大小),根据限制条件动态绘制可见视图。

image-20240710101036522

  • 智能手势处理
    • 在拖动开始阶段(onChange),根据初始拖动状态判断并限制滚动方向,实现视图的平滑移动。
    • 手势释放时(onEnded),根据预测位置和设定阈值决定是否翻页或回弹。
  • 无缝循环更新:当视图完全滚出容器后,重新调整当前视图位置并绘制新的周边视图,实现无限滚动效果。

这歌方案不仅规避了 SwiftUI 的限制,还充分利用了其响应式特性。令人欣喜的是,一旦确定了这个基本方案,核心代码的实现仅需短短几十分钟就能完成。

技术要点

在实现这个无限四向滚动分页组件时,我们需要解决几个关键的技术问题:

多视图绘制与容器布局

我们的目标是在不影响容器布局尺寸的前提下绘制多个视图。解决方案如下:

  • 容器尺寸应仅为单个视图的大小
  • 使用 overlay 修饰器来绘制包含主视图和周边视图的复合视图
  • 这种方法允许我们创建超出建议尺寸的视图,而不会影响主视图的布局
  • 应用 clipped 修饰器来隐藏超出容器尺寸的部分

深入了解这一技巧,请参阅 深入探索 SwiftUI 中的 Overlay 和 Background 修饰器

多视图的正确定位

对于类似九宫格的视图布局,我们有多种实现方式:

  • VStack + HStack 组合是一种简单有效的方法
  • 另一种方法是使用多个 overlay 配合对齐指南,例如:
Swift
overlay(alignment: .center) {
  getPage(currentHorizontalPage, currentVerticalPage)
}
.overlay(alignment: .top) {
  getAdjacentPage(direction: .vertical, offset: -1)
    .alignmentGuide(.top) { $0[.bottom] }
}
.overlay(alignment: .bottom) {
  getAdjacentPage(direction: .vertical, offset: 1)
    .alignmentGuide(.bottom) { $0[.top] }
}
  • 最终,我选择了基于 Grid 的实现

这些方法在效果和性能上差异不大,可根据个人偏好选择。

滚动完成的判断

确定一页滚动是否完成是实现无缝翻页的关键。我们的方法是:

  • 当满足翻页条件时,使用显式动画设置 offset 目标位置
  • 利用 WWDC 2023 引入的动画结束回调机制来触发新视图的绘制:
Swift
withAnimation(animation) {
    offset = newOffset
} completion: {
  // 执行新视图的创建操作
}

这种方法虽然便捷,但会提高系统版本要求。为兼容旧版本,可考虑:

  • 使用 GeometryReader 监控视图位置
  • 但这种方法在处理弹性动画时可能多次触发终点判断

每种方法都有其优缺点,选择时需要权衡项目需求和目标用户群。

SwiftUI 的局限性和注意事项

虽然实现这个无限四向滚动分页组件的核心功能相对直接,但在处理边缘情况时仍需要额外的考虑和努力。以下是一些值得注意的问题和解决方案:

系统中断滚动手势的处理

SwiftUI 中存在一个特殊情况:当用户在拖动过程中使用另外的一个或多个手指点击屏幕时,可能会触发系统手势,从而中断当前的拖动手势。这种中断不会调用 onEnded 闭包,可能导致视图停滞在滚动中间,影响用户体验。

解决方案:

我们采用 @GestureState 属性包装器来标注 isDragging 状态,用于记录当前的拖动状态。这种方法的优势在于:

  1. 当系统打断拖动手势后,即使没有调用 onEnded 闭包,SwiftUI 也会自动重置 isDragging 的值。

  2. 通过观察 isDragging 的变化,并结合判断 onEnded 闭包是否被调用,我们可以全面掌握手势的状态。

  3. 这种方法使我们能够确保视图不会停留在屏幕中间(即滚动过程中断的情况)。

通过这种实现,我们可以有效地处理系统中断滚动手势的情况,保证滚动交互的流畅性和可靠性,从而提升用户体验。

请阅读 在 SwiftUI 下定制手势探讨 SwiftUI 中的属性包装器:@AppStorage、@SceneStorage、@FocusState、@GestureState 和 @ScaledMetric ,了解 @GestureState 和自定义手势的更多内容。

拖动过程中误触子视图手势的问题

在拖动初始阶段,用户可能会不经意地触碰到子视图中的可交互区域,这可能会干扰正常的用户操作或导致误解。

解决方案:

  • 利用前文提到的 isDragging 状态,容器在拖动过程中会通过 disable 修饰器暂时禁用子视图的点击响应功能。
  • 需要特别注意的是:disable 修饰器能够同时屏蔽 onTapGestureButton 的交互,而 allowsHitTesting 只能屏蔽 onTapGesture,可能会导致 Button 仍然被误触。
  • 此外,我们还提供了一个名为 infinite4pagerIsDragging 的环境值。开发者可以在自定义视图中利用这个状态值来实现更精确的手势控制。

弹性动画引发的视图间隙问题

在使用强烈弹性动画进行页面切换时,主视图与相邻视图之间可能会出现短暂的间隙。这个问题不仅在滚动视图的中部显现,当容器内包含大量子视图时(如日历应用场景),滚动过程中也会出现细微的视图间隙。

原因分析: 这很可能源于 SwiftUI 在对复合视图(主视图 + 临近视图)应用动画插值时,无法精确同步每个元素的位置信息所致。

解决方案

  1. 动画优化:当前实现采用 easeOut 动画效果,以减少间隙出现的可能性。
  2. 同步处理:利用 geometryGroup 尽可能同步子视图中的动画(提供完整的动画插值信息)。虽然这不能彻底解决问题,但可显著改善包含大量子视图场景中的动画同步性。
  3. 视觉优化:对于非全屏容器尺寸的视图,将容器背景色设置为与视图匹配的颜色,可有效降低用户对视图间隙的感知。
  4. 技术限制认知:值得注意的是,即便使用了 geometryGroup,SwiftUI 在快速动画中仍可能表现出复合视图元素位置的微小不同步。这是 SwiftUI 当前版本的一个固有限制,开发者需要意识到这一点。

快速连续翻页的挑战

与基于滚动容器的分页组件相比,我们的实现在快速连续翻页方面面临一些限制。

主要障碍:

  • 缺乏动态创建临近视图的机制。
  • 即便实现动态构建,SwiftUI 的响应式特性可能导致视图闪烁。
  • 为优化内存使用,仅在滚动方向一侧动态添加视图会增加在容器 overlay 中对齐的复杂性。

这些挑战突显了 SwiftUI 在处理复杂、高度自定义 UI 组件时的一些局限性。虽然这些问题并非不可解决,但确实需要更多的创新思考和巧妙的编程技巧。在未来的 SwiftUI 版本中,我们希望看到更多针对这类复杂交互需求的原生支持。

数据加载时机

由于本实现没有使用惰性容器,我们无法像常规 SwiftUI 视图那样通过 onAppearonDisappear 来判断哪个视图当前正在显示(停留在容器中心)。为了解决这个问题,我们提供了一种替代方案:使用环境值判断当前页面。

开发者可以在视图中通过响应环境值 pagerCurrentPage,并结合当前视图的行列信息来进行判断。这里是一个示例实现:

Swift
struct PageView: View {
  let h: Int
  let v: Int
  @Environment(\.pagerCurrentPage) var mainPage
  @State var isCurrent = false
  var body: some View {
    Rectangle()
      .foregroundStyle(.white)
      .border(.blue, width: 3)
      .overlay(
        Text("\(h),\(v): \(isCurrent)")
      )
      .task(id: mainPage) {
        if let mainPage {
          if mainPage.horizontal == h, mainPage.vertical == v {
            isCurrent = true
          }
        }
      }
  }
}

在这个例子中:

  • 我们使用 @Environment(\.pagerCurrentPage) 来获取当前主页面的信息。
  • 通过 task(id:) 修饰器,我们可以在 mainPage 值变化时执行更新操作。
  • 我们比较当前视图的行列信息 (hv) 与主页面信息,来判断该视图是否为当前显示的页面。

尽管库中提供的 onPageVisible 修饰器能够提供更详细的信息(如视图在容器中的可见度),但我们建议在使用这个功能时要谨慎( 更多情况下请使用 pagerCurrentPage ):

  • 适用场景onPageVisible 适合用于触发数据加载、日志记录等非视觉操作,且判断是否加载的标志不要保存在视图内。
  • 注意事项:不建议使用 onPageVisible 来触发视图内的动画,因为这可能导致动画撕裂,影响用户体验。

结合使用上述两种方法,在保证性能的同时,为开发者提供了足够的灵活性来处理复杂的动画开启和数据加载逻辑。

总结

深入理解 SwiftUI 的局限性不仅有助于我们及早规避潜在问题,更能激发我们寻找创新解决方案的灵感。正是在这些挑战中,我们得以深化对框架的理解,并推动其应用边界。

本文旨在探讨如何充分利用 SwiftUI 的特性和布局逻辑来构建自定义组件。尽管 SwiftUI 在某些方面仍有待改进,但通过灵活运用其响应式编程模型和声明式语法,我们能有少量的代码便快速构建出在大多数场景中实现令人可以接受的解决方案。希望本文能够启发更多开发者,在 SwiftUI 的世界里不断创新,挖掘这一强大工具的全部潜力。

每周一晚,与全球开发者同步,掌握 Swift & SwiftUI 最新动向
可随时退订,干净无 spam