SwiftUI 滚动控制 API 的发展历程与 WWDC 2024 的新亮点

发表于

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

在 WWDC 2024 中,苹果再次为 SwiftUI 的 ScrollView 组件带来了一系列令人瞩目的新 API。这些新功能不仅增强了开发者对滚动行为的控制能力,也反映了 SwiftUI 框架设计理念的持续演进。本文将探讨这些最新的滚动控制 API,并回顾从 SwiftUI 诞生至今与滚动控制相关的所有重要 API 的发展历程。通过这个微观视角,我们将揭示 SwiftUI 在过去几年中设计风格的变迁,以及背后蕴含的宏观设计趋势。

2019:声明式滚动控制的初步探索

SwiftUI 在 WWDC 2019 惊艳亮相,立即在苹果生态系统的开发者中引起了巨大反响。这个新框架与 Swift 语言的无缝结合,为开发者带来了前所未有的简洁性和表现力,相较于其他声明式框架,SwiftUI 的代码更加简洁明了,令人耳目一新。

然而,尽管首个版本已经支持通过 ListScrollView + LazyVStack 等方式创建可滚动容器,但苹果并未提供有效的滚动控制 API。这一缺失给开发者带来了诸多不便。随着开发者逐步深入研究 SwiftUI 的底层实现,他们找到了通过操作底层 UIKit 组件来实现滚动控制的变通方法。尽管如此,开发者们仍然热切期待苹果能够推出一套更符合声明式编程范式的滚动控制体系。

事实上,苹果在 SwiftUI 的首个版本中确实进行了这方面的尝试,但可能由于实现效果不尽如人意,最终选择将这些尝试隐藏起来。从 iOS 13 开始,直至现在的 iOS 18,我们仍能在 SwiftUI 的 interface 文件中发现这些早期尝试的痕迹,其中就包括 _ScrollView

_ScrollView 组件中,开发者可以通过 _ScrollViewConfig 对滚动容器进行诸多精细的控制:

Swift
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public struct _ScrollView<Provider> : SwiftUICore.View where Provider : SwiftUI._ScrollableContentProvider {
  public var contentProvider: Provider
  public var config: SwiftUI._ScrollViewConfig
  public init(contentProvider: Provider, config: SwiftUI._ScrollViewConfig = _ScrollViewConfig())
}

public struct _ScrollViewConfig {
  public static let decelerationRateNormal: Swift.Double
  public static let decelerationRateFast: Swift.Double
  public enum ContentOffset {
    case initially(CoreFoundation.CGPoint)
    case binding(SwiftUICore.Binding<CoreFoundation.CGPoint>)
  }
  public var contentOffset: SwiftUI._ScrollViewConfig.ContentOffset
  public var contentInsets: SwiftUICore.EdgeInsets
  public var decelerationRate: Swift.Double
  public var alwaysBounceVertical: Swift.Bool
  public var alwaysBounceHorizontal: Swift.Bool
  public var gestureProvider: any SwiftUI._ScrollViewGestureProvider
  public var stopDraggingImmediately: Swift.Bool
  public var isScrollEnabled: Swift.Bool
  public var showsHorizontalIndicator: Swift.Bool
  public var showsVerticalIndicator: Swift.Bool
  public var indicatorInsets: SwiftUICore.EdgeInsets
  public init()
}

public protocol _ScrollViewGestureProvider {
  func scrollableDirections(proxy: SwiftUI._ScrollViewProxy) -> SwiftUI._EventDirections
  func gestureMask(proxy: SwiftUI._ScrollViewProxy) -> SwiftUICore.GestureMask
}

extension SwiftUI._ScrollViewGestureProvider {
  public func defaultScrollableDirections(proxy: SwiftUI._ScrollViewProxy) -> SwiftUI._EventDirections
  public func defaultGestureMask(proxy: SwiftUI._ScrollViewProxy) -> SwiftUICore.GestureMask
  public func scrollableDirections(proxy: SwiftUI._ScrollViewProxy) -> SwiftUI._EventDirections
  public func gestureMask(proxy: SwiftUI._ScrollViewProxy) -> SwiftUICore.GestureMask
}

public struct _ScrollViewProxy : Swift.Equatable {
  public func setContentOffset(_ newOffset: CoreFoundation.CGPoint, animated: Swift.Bool, completion: ((Swift.Bool) -> Swift.Void)? = nil)
  public func scrollRectToVisible(_ rect: CoreFoundation.CGRect, animated: Swift.Bool, completion: ((Swift.Bool) -> Swift.Void)? = nil)
  public func contentOffsetOfNextPage(_ directions: SwiftUI._EventDirections) -> CoreFoundation.CGPoint
}

从这些代码中,我们可以看出苹果最初的雄心壮志 —— 为 ScrollView 提供全面的控制能力。然而,仔细审视这些实现,我们会发现它们与 SwiftUI 的整体设计理念存在显著差异,未能充分体现声明式编程的特点。这可能是苹果最终决定不公开 _ScrollView API 的主要原因。

值得一提的是,除了 _ScrollView,SwiftUI 的首个版本还包含了另一个未公开的 API:_PagingView。这两个 API 都具有相似的特点:功能强大,但与 SwiftUI 其他 API 的风格格格不入。

这些早期尝试的痕迹向我们展示了苹果在平衡功能丰富性和 API 设计优雅性之间的苦恼。这个阶段可以被视为 SwiftUI 滚动控制 API 演变的起点,为后续的发展奠定了基础,同时也凸显了在声明式框架中实现复杂交互控制的挑战。

2020:基于标识符的滚动控制 - ScrollViewReader 的诞生

在 WWDC 2020 上,苹果为 SwiftUI 引入了 ScrollViewReader 容器,这标志着 SwiftUI 滚动控制能力的重大突破。这个新 API 提供了基于标识符的滚动控制功能,为开发者提供了一种符合声明式风格的方法来管理滚动行为。

下面是 ScrollViewReader 的一个典型使用示例:

Swift
@Namespace var topID
@Namespace var bottomID

var body: some View {
    ScrollViewReader { proxy in
        ScrollView {
            Button("Scroll to Bottom") {
                withAnimation {
                    proxy.scrollTo(bottomID)
                }
            }
            .id(topID)

            VStack(spacing: 0) {
                ForEach(0..<100) { i in
                    color(fraction: Double(i) / 100)
                        .frame(height: 32)
                }
            }

            Button("Top") {
                withAnimation {
                    proxy.scrollTo(topID)
                }
            }
            .id(bottomID)
        }
    }
}

ScrollViewReader 的设计巧妙地利用了 SwiftUI 的视图标识机制。对于 ForEach 中同时符合 IdentifiableHashable 协议的数据,SwiftUI 会自动将其作为子视图的标识符,可直接用于滚动控制。对于 ForEach 外的视图,开发者需要通过 id 修饰器显式设置标识符。这种设计不仅简化了滚动控制的实现,还加深了开发者对 SwiftUI 视图标识机制的理解。

然而,作为 SwiftUI 首个专门用于滚动控制的 API,ScrollViewReader 也存在一些局限性:

  1. 精确控制的局限:它缺乏基于滚动内容全局的精准控制能力。为了实现更精确的定位,开发者常需要在滚动内容中额外添加标识符。
  2. API 设计的歧义:ScrollViewReader 采用了标准容器的样式,这在表达上可能引起歧义。这个问题在 GeometryReader 中也同样存在。
  3. 作用域限制:ScrollViewProxy 只能在 ScrollViewReader 的闭包中直接使用。如果需要在闭包外控制滚动,就必须将其传递出来,这增加了代码的复杂性。
  4. 功能单一:它只能控制滚动位置,但无法让开发者感知当前的滚动位置或滚动状态。

尽管存在这些问题,ScrollViewReader 至今仍是唯一支持 List 的滚动控制 API,这保证了它在 SwiftUI 生态系统中的重要地位。它的出现不仅填补了 SwiftUI 在滚动控制方面的空白,还为后续的 API 设计提供了宝贵的经验和启示。

2023:功能全面爆发、API 设计日臻成熟

ScrollViewReader 发布后的两年里,SwiftUI 滚动控制功能的进展相对缓慢,仅有 scrollDisabled 等少数改进。然而,WWDC 2023 标志着这一领域的重大突破,苹果一口气推出了大量与滚动相关的新 API,其中多个直接涉及滚动控制。

想要全面了解 WWDC 2023 上所有与滚动相关的新 API,请阅读 深入了解 SwiftUI 5 中 ScrollView 的新功能

在设计新的滚动控制工具时,苹果汲取了 ScrollViewReader 的经验教训,推出了更加优雅的 scrollPosition 视图修饰器:

Swift
struct ScrollPosition: View {
  @State var rows = (0 ..< 100).map { _ in Row(id: UUID()) }
  @State var positionID: UUID?

  var body: some View {
    ScrollView {
      ForEach(rows) { row in
        row
      }
    }
    .scrollPosition(id: $positionID, anchor: .top)

    Button("Top") {
      positionID = rows.first?.id
    }

    Button("Bottom") {
      positionID = rows.last?.id
    }
  }
}

struct Row: View, Identifiable, Hashable {
  let id: UUID
  var body: some View {
    Rectangle()
      .foregroundStyle(.red.gradient)
      .frame(height: 100)
      .overlay(
        Text("\(id)")
      )
  }
}

相比 ScrollViewReaderscrollPosition 的设计更加简洁明了:

  1. 声明更加清晰,与 SwiftUI 的声明式风格更加协调。
  2. 无需传递 Proxy 对象,简化了使用流程。
  3. 滚动位置从单向调整升级为双向感知,更符合响应式编程范式。

遗憾的是,从这个版本开始,新的滚动控制 API 不再支持 List,这在某种程度上限制了其应用范围,同时也暗示了苹果可能在未来会更改 List 的底层实现或将 List 的独有功能集成到 LazyVStack 中。

此外,新增的 defaultScrollAnchor 大大简化了设置滚动视图初始位置的操作。在此之前,开发者通常需要在 onAppeartask 中显式操作 ScrollViewProxy

尽管 2023 年的 API 设计更加合理,但仍未提供以可滚动内容整体为单位的滚动控制能力,这个领域仍有改进空间。

除了核心的滚动控制 API,WWDC 2023 还为滚动容器内的子视图带来了一系列新功能:

  • scrollTargetBehavior:允许自定义 ScrollView 的滚动行为,包括分页、子视图对齐等多种选项。
  • NamedCoordinateSpace. scrollView:使子视图能够方便地获取其在滚动视图中的当前位置。
  • scrollTransition:通过枚举方式,让子视图根据其相对于滚动容器的状态快速调整外观。
  • scrollTargetLayout:允许开发者控制滚动状态感知的激活,在功能丰富性和性能优化之间找到平衡点。

这个版本的 SwiftUI 不仅带来了新功能,还展现了 API 设计风格的显著进步。新增了许多保持上下文完整性的修饰器,如 visualEffectscrollTransition、新版 animationtransaction 等:

Swift
CellView()
  .scrollTransition(.animated) { content, phase in
    content
      .scaleEffect(phase != .identity ? 0.6 : 1)
      .opacity(phase != .identity ? 0.3 : 1)
  }

ContentRow()
  .visualEffect { content, geometryProxy in
    content.offset(x: geometryProxy.frame(in: .global).origin.y)
  }

Rectangle()
  .transaction {
    $0.animation = .none
  } body: {
    $0.scaleEffect(scale ? 1.5 : 1)
  }

这种风格的 API 在描述上更加具体、提供的参数信息更加丰富且使用起来更加方便。在 WWDC 2024 中推出的新 API 中同样大量使用了这种方式。

应该说,在 2023 年,SwiftUI 在 API 的风格上已经逐渐成熟。

2024:突破性的滚动控制能力

尽管苹果在 2023 年已经大幅提升了滚动相关的 API 功能,但在 WWDC 2024 上,他们再次刷新了开发者的期待,为滚动控制带来了一系列创新和突破。这次更新的亮点在于,滚动控制不再局限于基于标识符的传统方法,而是引入了一种全新的、以可滚动视图内容整体为操作单位的控制逻辑,为开发者提供了更直观、更强大的工具。

全新升级的 scrollPosition

去年推出的 scrollPosition 功能在这个版本中得到了显著的增强和扩展。新版本中的 ScrollPosition 结构体巧妙地整合了多种滚动控制能力,提供了一个更加统一和强大的接口。

Swift
struct ScrollPosition: View {
  @State var rows = (0 ..< 100).map { _ in Row(id: UUID()) }
  @State var scrollPosition = ScrollPosition() 
  var body: some View {
    ScrollView {
      LazyVStack {
        ForEach(rows) { row in
          row
        }
      }
    }
    .scrollPosition($scrollPosition)

    Button("Content Top") {
      // 滚动到滚动容器内容的 top 位置
      scrollPosition.scrollTo(edge: .top)
    }

    Button("First Row") {
      // 滚动到第一个子视图
      scrollPosition.scrollTo(id: rows.first!.id)
    }

    Button("Top Position") {
      // 滚动到滚动容器内容的 Y 轴位置 0
      scrollPosition.scrollTo(y: 0)
    }
  }
}

这个新 API 的一大亮点在于其简洁而直观的使用方式。特别是当开发者只需要对滚动容器的内容进行整体操作,而不需要精确控制到子视图级别时,新 API 的优势更加明显。在这种情况下,我们不再需要确保 ForEach 迭代的数据同时符合 IdentifiableHashable 协议,也无需使用 id 修饰器来显式标注标识符。这种设计大大简化了代码结构,提高了开发效率。例如,以下代码展示了如何用简洁的方式实现将滚动容器滚动至顶端的功能:

Swift
ScrollView {
  LazyVStack {
    // 无需关心迭代数据类型,或使用 id 来添加标识符
    ForEach(0..<100) { row in 
      Text("\(row)")
    }
  }
}
.scrollPosition($scrollPosition)

Button("Content Top") {
  // 滚动到滚动容器内容的 top 位置
  scrollPosition.scrollTo(edge: .top)
}

Button("Top Position") {
  // 滚动到滚动容器内容的 Y 轴位置 0
  scrollPosition.scrollTo(y: 0)
}

defaultScrollAnchor 能力获得提升

在 WWDC 2024 中,defaultScrollAnchor 功能也得到了进一步增强,这次改进不仅扩展了其定义初始滚动位置的能力,更引入了一个新特性:在特定场景下动态调整视图对齐方式。这一功能使开发者能够在内容尺寸小于滚动容器,或当内容与容器尺寸发生变化时,精确控制视图的对齐行为。

以下代码展示了这一新特性的实际应用:

Swift
ScrollView {
  Rectangle()
    .foregroundColor(.orange)
    .frame(width: 200, height: 100)
}
.scrollPosition($scrollPosition)
.defaultScrollAnchor(.bottom, for: .alignment) // 内容尺寸小于滚动容器时,与 bottom 对齐
.frame(height: 400)
.border(.blue)

image-20240622150128887

虽然开发者无法直接从 ScrollPosition 中获取滚动容器的当前状态,但苹果提供了一套全新的、更加符合人体工学原理的 API。这些新工具不仅简化了开发流程,还能帮助开发者更直观地感知和控制滚动行为。

onScrollPhaseChange

onScrollPhaseChange 引入了基于枚举的滚动状态描述(ScrollPhase),为开发者提供了前所未有的滚动状态感知能力。这种精确的状态描述甚至在 UIKit API 中也难以如此便捷地获取。

Swift
ScrollView {
    // ...
}
.onScrollPhaseChange { _, newPhase in
    if newPhase == .decelerating || newPhase == .idle {
        selection = updateSelection()
    }
}

以下是 ScrollPhase 的详细定义:

Swift

public enum ScrollPhase : Equatable {
    case idle
    case tracking
    case interacting
    case decelerating
    case animating
  
    public var isScrolling: Bool { get }
}

这种细致的状态划分使开发者能够更精确地控制和响应滚动行为,从而创造出更加流畅和直观的用户体验。

onScrollGeometryChange

onScrollGeometryChange 为开发者开启了一扇新的窗口,使我们能够在滚动过程中实时响应以滚动内容整体为单位的几何信息。其设计理念与 onGeometryChange 高度一致,但专注于滚动视图的特定需求。ScrollGeometry 结构体提供了丰富的信息:

Swift
public struct ScrollGeometry : Equatable, Sendable {
    public var contentOffset: CGPoint
    public var contentSize: CGSize
    public var contentInsets: EdgeInsets
    public var containerSize: CGSize
    public var visibleRect: CGRect { get }
    public var bounds: CGRect { get }
}

以下代码展示了如何利用新 API( onScrollPhaseChange + onScrollGeometryChange )来检测滚动容器的滚动方向:

Swift
struct ScrollDirection: View {
  @State var direction = Direction.none
  var body: some View {
    Text(direction.rawValue)
    ScrollView {
      ForEach(0 ..< 100) {
        Text("\($0)")
      }
    }
    .onScrollPhaseChange { _, phase in
      if phase == .idle {
        direction = .none
      }
    }
    .onScrollGeometryChange(for: CGFloat.self) { geometry in
      geometry.contentOffset.y
    } action: { oldY, newY in
      if oldY < newY {
        direction = .up
      } else {
        direction = .down
      }
    }
  }
}

enum Direction: String {
  case up
  case down
  case none
}

MapKit 框架同样提供了 onScrollGeometryChange 功能,使开发者能够轻松响应地图容器的位置变化。这种跨框架的一致性设计大大降低了学习曲线,提高了开发效率。

onScrollVisibilityChange

onScrollVisibilityChange 为子视图提供了一个独特的、区别于 onAppear 的触发时机。当子视图进入滚动视图的可视区域后,这个修饰器会通过闭包反馈子视图是否达到了预设的可见性阈值。下面的示例代码展示了如何利用这一特性:当子视图在滚动容器中的可见面积超过 30% 时,会为其添加一个醒目的红色边框。

Swift
struct OnScrollVisibilityChangeDemo: View {
  @State var visibilityID: Int?
  var body: some View {
    ScrollView {
      ForEach(0 ..< 100) { i in
        Rectangle()
          .foregroundStyle(.green.gradient)
          .frame(height: 100)
          .border(visibilityID == i ? .red : .clear, width: 8)
          .overlay(
            Text("\(i)")
          )
          .onScrollVisibilityChange(threshold: 0.3) { visibility in
            if visibility {
              visibilityID = i
            }
          }
      }
    }
  }
}

onScrollVisibilityChange 不仅为非惰性容器提供了一个理想的触发时机,其灵活性更是体现在 threshold 参数上。这个参数的巧妙之处在于它接受正值和负值,使得开发者能够在惰性容器中精确调整其触发时机相对于 onAppear 的先后顺序。

onScrollTargetVisibilityChange

onScrollTargetVisibilityChange 在功能上与 onScrollVisibilityChange 相似,但它直接作用于 ScrollView 的外层。这个修饰器能够识别并报告当前在滚动容器可视区域内、满足预设阈值的所有子视图的标识。

下面的代码示例展示了如何利用这一特性来动态改变子视图的外观:当子视图的可见面积超过 90% 时,其背景色会从绿色变为红色。

Swift
struct OnScrollVisibilityChangeDemo: View {
  @State var ids = [Int]()
  var body: some View {
    ScrollView {
      LazyVStack {
        ForEach(0 ..< 100) { i in
          Rectangle()
            .foregroundStyle(ids.contains(where: { $0 == i }) ? .red : .green)
            .frame(height: 200)
            .overlay(
              Text("\(i)")
            )
            .id(i)
        }
      }
      .scrollTargetLayout()
    }
    .onScrollTargetVisibilityChange(idType: Int.self, threshold: 0.9) { ids in
      self.ids = ids
    }
  }
}

要成功使用 onScrollTargetVisibilityChange,需要满足两个条件:

  1. 在滚动内容容器上启用 scrollTargetLayout
  2. 确保子视图通过 id 修饰器明确标注了标识符,或者 ForEach 迭代的数据同时符合 IdentifiableHashable 协议。

WWDC 2024 新 API 的设计特点

WWDC 2024 推出的这些新 API 延续并深化了 WWDC 2023 的设计理念,进一步扩充了以 on 为前缀的场景响应修饰器家族。特别值得一提的是,在双闭包版本的响应修饰器中,苹果巧妙地引入了专门用于数据类型转换的处理逻辑。这一设计不仅提高了代码的针对性和清晰度,还为开发者提供了更大的灵活性。

这些精确的响应机制大大减少了开发者对 onChange 修饰器的依赖,使得视图代码更加简洁明了。随着 SwiftUI API 设计风格日趋成熟,我们有理由期待在未来的版本中看到更多类似的修饰器,比如 onNavigatorPhaseChangeonTabViewGeometryChange 等。这种趋势不仅将进一步提升 SwiftUI 的表现力,也将为开发者提供更多精准、直观的控制手段。

总结

本文探讨了 SwiftUI 滚动控制 API 从框架诞生至今的演变历程。我们不仅梳理了各个阶段的重要 API,还对其设计理念和实现方式进行了分析和比较。

回顾这一演变过程,我们可以清晰地看到苹果在 API 设计上的不断创新和优化。从最初的实验性尝试,到逐步形成统一、优雅的声明式风格。

通过学习和借鉴这些官方 API 的设计思路,我们可以:

  1. 更好地理解 SwiftUI 的设计哲学和最佳实践。
  2. 在自己的项目中构建更加优雅、高效的视图扩展。
  3. 确保我们的代码风格与 SwiftUI 的整体生态系统保持一致,提高代码的可读性和可维护性。
  4. 预见未来 API 的发展趋势,使我们的项目具有更强的前瞻性和适应性。

总之,通过对 SwiftUI 滚动控制 API 的演变的探索,我们不仅能够更熟练地运用这些工具,还能在更宏观的层面上提升自己的 API 设计能力,从而创造出更现代、更统一、更符合 SwiftUI 精神的应用程序。

与全球开发者一同,每周探索 Swift 世界的精彩内容

如果文章对你有所帮助,可以请我喝杯