当我们构建可滚动的视图时,会遇到一些很常见的需求。比如监听视图内容滚动的偏移量,去触发布局的更新、在合适的时机加载数据或者根据用户当前正在查看的内容执行其他类型的操作。
然而,在使用 SwiftUI 的 ScrollView 时,目前没有内置方法来对此类滚动进行监听。虽然在滚动视图中嵌入 ScrollViewReader
确实使我们能够更改代码中的滚动位置,但奇怪的是(特别是考虑到它的名称)不允许我们以任何方式读取当前内容偏移量。
解决这个问题的一种方法是需要使用 UIKit
的 UIScrollView
,通过实现 scrollViewDidScroll
方法来获得任何类型的滚动发生时的通知。然而,如果在 SwiftUI 中使用 UIKit 的控件,在这种情况下,我们必须编写相当多的额外代码来弥合两个框架之间的差距。
这主要是因为我们只能在一个 UIHostingController
中嵌入 SwiftUI
内容,而不是在一个自我管理的 UIView
中 (至少在 iOS 上是如此的)。如果我们想用 UIScrollView
建立一个自定义的,可观察偏移量版本的 ScrollView
,那么我们需要把那个实现包装在一个视图控制器中,然后管理 UIHostingController
和键盘,滚动视图的内容大小,安全区域插入等等之间的关系。无论如何也不是不可能,但是仍然有相当多的额外工作。
因此,让我们看看是否可以找到一种完全 SwiftUI
原生的方法来执行此类内容偏移观察。
GeometryReader
在我们开始之前需要意识到的一件重要的事情是:UIScrollView
和 SwiftUI
的 ScrollView
都是通过偏移一个承载我们实际可滚动内容的容器来执行滚动的。然后它们将容器夹在边界上,以产生移动的错觉。如果我们能找到一种方法来观察那个容器的框架,那么我们就能找到一种方法来观察滚动视图的内容偏移量。
这就需要 GeometryReader
进来的地方出场了。虽然 GeometryReader
主要用于访问它所承载的视图的大小(或者更准确地说,视图的建议大小),但它还有另一个巧妙的技巧:可以使用它读取相对于给定坐标系的当前视图。
要使用这个功能,让我们从创建一个 PositionObservingView
开始,它允许我们将 CGPoint
值绑定到该视图相对于 CoordinateSpace
的当前位置,我们也将作为参数传递给它。然后,我们的新视图将嵌入一个几何阅读器作为背景(这将使几何阅读器与视图本身具有相同的大小),并将使用偏好键将解析帧的原点分配为我们的偏移量,代码如下:
swift
struct PositionObservingView<Content: View>: View {
var coordinateSpace: CoordinateSpace
@Binding var position: CGPoint
@ViewBuilder var content: () -> Content
var body: some View {
content()
.background(GeometryReader { geometry in
Color.clear.preference(
key: PreferenceKey.self,
value: geometry.frame(in: coordinateSpace).origin
)
})
.onPreferenceChange(PreferenceKey.self) { position in
self.position = position
}
}
}
我们在上面使用 SwiftUI
的偏好系统的原因是,我们的 GeometryReader
将作为视图更新过程的一部分被调用,并且我们不允许在该过程中直接改变视图的状态。因此,通过使用偏好系统,我们可以以异步方式将 CGPoint
值传递给视图,然后将这些值与 position
绑定。
现在我们需要做的就是实现上面使用的 PreferenceKey 类型,代码如下:
swift
private extension PositionObservingView {
struct PreferenceKey: SwiftUI.PreferenceKey {
static var defaultValue: CGPoint { .zero }
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { }
}
}
我们实际上不需要实现上面的 reduce
方法,因为我们只有一个视图在任何给定的层次结构中使用 preference key
传递值。
OK,现在我们有了一个视图它能够读取和观察自己在给定坐标系中的位置。现在让我们使用这个视图来构建一个 ScrollView
包装器,它将让我们实现最初的目标------能够在一个滚动视图中读取当前内容偏移量。
监听内容偏移量
让我们继续实现自定义滚动视图,我们将其命名为 OffsetObservingScrollView
:
less
struct OffsetObservingScrollView<Content: View>: View {
var axes: Axis.Set = [.vertical]
var showsIndicators = true
@Binding var offset: CGPoint
@ViewBuilder var content: () -> Content
private let coordinateSpaceName = UUID()
var body: some View {
ScrollView(axes, showsIndicators: showsIndicators) {
PositionObservingView(
coordinateSpace: .named(coordinateSpaceName),
position: Binding(
get: { offset },
set: { newOffset in
offset = CGPoint(
x: -newOffset.x,
y: -newOffset.y
)
}
),
content: content
)
}
.coordinateSpace(name: coordinateSpaceName)
}
}
现在,我们现在有了 SwiftUI
内置的 ScrollView
的替代品,它使我们能够观察当前的内容偏移量。我们可以将其绑定到任何我们想要的状态属性,例如,为了改变标题视图的布局,向我们的服务器报告分析事件,或者执行任何其他类型的基于滚动位置的操作。