观察SwiftUI ScrollView的内容偏移量
在构建各种可滚动UI时,通常希望观察当前滚动位置(UIScrollView
称之为内容偏移),以触发布局更改,在需要时加载其他数据,或根据用户当前正在查看的内容执行其他类型的操作。
然而,当涉及到SwiftUI的ScrollView
时,目前(在撰写本文时)没有内置的方式来执行此类滚动观察。虽然在滚动视图中嵌入ScrollViewReader
确实能够在代码中的更改滚动位置,但奇怪的是(特别是考虑到它的名称),它不允许我们以任何方式读取当前(滚动)内容偏移量。
解决这个问题的一种方法是利用UIKit的UIScrollView
的丰富功能,由于其代理协议和scrollViewDidScroll
方法,它提供了一种在发生任何类型的滚动时获得通知的简单方法。然而,尽管我通常非常喜欢使用UIViewRepresentable
和其他 SwiftUI/UIKit互操作性机制,但在这种情况下,我们必须编写相当多的额外代码来弥合两个框架之间的差距。
这主要是因为——至少在iOS上——我们只能将SwiftUI内容嵌入到UIHostingController
中,而不是在自我管理的UIView
中。因此,如果我们想使用UIScrollView
构建一个自定义的、可观察的ScrollView
版本,那么我们必须将该实现包装在视图控制器中,然后管理我们的UIHostingController
与键盘、滚动视图的内容大小、安全区域嵌入等之间的关系。虽然不是不可能,但仍然有相当多的额外工作和复杂性。
因此,让我们看看是否能找到一种完全SwiftUI原生的方式来执行此类内容偏移观测。
使用GeometryReader解析框架
在开始之前,要意识到的关键一点是,UIScrollView
和SwiftUI的ScrollView
都通过修改一个容器偏移执行滚动,该容器托管实际可滚动内容。然后,他们将该容器裁剪到边界上,以产生视口移动的错觉。因此,如果能找到观察该容器框架(frame)的方法,那么基本上也会找到观察滚动视图内容偏移的方法。
这就是我们的老朋友GeometryReader
发挥作用的地方(没有它,就不会是一个合适的SwiftUI布局解决方法,对吗?)。虽然GeometryReader
主要用于访问它所托管的视图的size
大小(或者更准确地说,该视图的拟议大小),但它还有另一个巧妙的技巧——可以要求它读取相对于给定坐标系的当前视图的frame
框架。
要使用该功能,让我们从创建一个PositionObservingView
开始,它允许将CGPoint
值绑定到该视图相对于 CoordinateSpace
的当前位置,我们也将将其作为参数传递。然后,新视图将嵌入一个GeometryReader
作为背景(这将使该GeometryReader
具有与视图本身相同的大小),并将使用首选项键将已经解析的框架的origin
原点设置为偏移量——像这样:
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
}
}
}
要了解如何使用
@ViewBuilder
属性构建自定义SwiftUI容器视图的更多信息,请查看这篇文章。
上面代码中使用SwiftUI的首选项系统的原因是,GeometryReader
将作为视图更新过程的一部分被调用,在此过程中,不允许直接改变视图的状态。因此,通过使用首选项,我们可以以异步方式将CGPoint
值传递到视图,然后将这些值分配给position
绑定变量。
现在,需要做的就是实现上面使用的PreferenceKey
类型,如下:
private extension PositionObservingView {
struct PreferenceKey: SwiftUI.PreferenceKey {
static var defaultValue: CGPoint { .zero }
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
// No-op
}
}
}
实际上不需要实现上述任何类型的
reduce
算法,因为只有单个视图在任何给定的层次结构中使用该首选项键的传递值(因为上述实现完全包含在PositionObservingView
中)。
好吧,现在有了一个能够读取和观察自己在给定坐标系中位置的视图。让我们使用该视图构建一个ScrollView
包装器, 实现我们的原始目标——能够在此类滚动视图中读取当前内容偏移量。
从位置到内容偏移
新的ScrollView
包装器基本上将有两个职责——一,它需要将内部PositionObservingView
的位置转换为当前滚动位置(或内容偏移),二,它还需要定义一个CordinateSpace
坐标空间,内部视图可以使用它来解析其位置。除此之外,它只需将其配置参数转发到其底层的ScrollView
, 以决定滚动视图在哪些轴上运行,以及是否显示滚动指示器。
好消息是,将内部视图的位置转换为内容偏移就像给CGPoint
的x和y分量取负一样简单。这是因为,如前所述,滚动视图的内容偏移量本质上只是容器相对于滚动视图边界移动的距离。
因此,继续实施我们的自定义滚动视图,将其命名为OffsetObservingScrollView
(这种情况下拼写出ContentOffset
确实有点太冗长了):
struct OffsetObservingScrollView<Content: View>: View {
var axes: Axis.Set = [.vertical]
var showsIndicators = true
@Binding var offset: CGPoint
@ViewBuilder var content: () -> Content
// The name of our coordinate space doesn’t have to be
// stable between view updates (it just needs to be
// consistent within this view), so we’ll simply use a
// plain UUID for it:
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)
}
}
注意我们如何通过使用闭包定义getter和setter,为内部视图的位置参数创建完全自定义的绑定。如上述情况,在将一个值分配给另一个绑定之前需进行转换时,自定义的绑定是一个很好的选择。
就是这样!现在有了一个SwiftUI内置ScrollView的临时替代品,它能够观察当前内容偏移量——可以将其绑定到任何状态属性,例如更改头部视图的布局,向服务器报告分析事件,或执行任何其他类型的基于滚动位置的操作。你可以在这里找到一个使用上述OffsetObservingScrollView
的完整示例,以实现可折叠的头部视图。
我希望你发现这篇文章有趣且有用。如果有任何问题、评论或反馈,请随时在Mastodon 上找到我,或通过电子邮件与我联系。
感谢您的阅读!