欢迎回来,这一节,我们实现 ScrollView
下拉刷新的功能。
RefreshableScrollView 的属性
先来看相关属性的部分:
public struct RefreshableScrollView<Content: View>: View {
@State private var previousScrollOffset: CGFloat = 0
@State private var scrollOffset: CGFloat = 0
// Keep the loading indication area above the scroll view.
@State private var frozen: Bool = false
@State private var rotation: Angle = .degrees(0)
// Trigger the action after scrolling over the threshold.
var threshold: CGFloat = 80
let content: Content
// Pull down to refresh
@Binding var refreshing: Bool
}
可以看到,RefreshableScrollView
也属于一个“高阶 UI 容器”。它有一个泛型参数 Content
表示要在滚动视图中显示的内容。在它的诸多属性中:
previousScrollOffset
可以理解成是RefreshableScrollView
在两次滚动事件之间,上一次滚动位置的偏移;scrollOffset
是RefreshableScrollView
当前的滚动位置偏移;至于为什么要区分这两个位置,我们稍后再说;frozen
和rotation
是要传给上一节创建的SymbolView
的值,用于“冻”住它的位置,并设置下拉图标的旋转角度;
以上这四个值,都是 RefreshableScrollView
自身管理的状态,不同的 RefreshableScrollView
对象,应该有它们自己的状态值,因此它们都定义成了 @State
。而 refreshing
则用于控制 RefreshableScrollView
当前是否在保持刷新数据的状态,这个状态的完成,依赖于外部我们请求数据的过程,它不应该属于 RefreshableScrollView
自身管理的状态,而只应该知道如何获取它的内容,因此,我们把它定义成了 @Binding
。
最后,threshold
表示下拉超过多长时,触发更新机制。content
是要显示的滚动内容。至于 RefreshableScrollView
的其它属性,和下拉刷新就没关系了。它们属于上拉刷新的部分,因此等稍后我们实现的时候再说。
在 init
方法里,要做的也只是逐个对这些属性设置初始值而已,并没有做其它的操作。由于其中还包含着对上拉刷新部分的初始化,这里我们就暂时先不列出代码了。
RefreshableScrollView 的外框
然后,我们来实现 RefreshableScrollView
的 body
。由外向内,先来看它的“外边框”:
public var body: some View {
ScrollView {
ZStack(alignment: .top) {
/// Scrolling content here.
}
}
.backgroundPreferenceValue(/* PreferenceKey */) {
/// Place a `FixedView` as background.
}
}
可以看到,RefreshableScrollView
是基于 ScrollView
实现的。在 ScrollView
后面,我们用 backgroundPreferenceValue
设置了它的背景,这个背景实际上就是之前实现的 FixedView
。当然,我们的目的并不是真的要设置背景,而是这样,就可以间接获取到 ScrollView
的实际尺寸了。
至于为什么要使用 backgroundPreferenceValue
,其实它和下拉刷新没关系,而是和上拉刷新相关。所以我也没有提到它使用的具体的 key。现在,只要知道,这里我们通过把背景设置成 FixedView
获取 ScrollView
的尺寸就好了。
在 ScrollView
内部,我们摆了一个顶部对齐的 ZStack
。这个 ZStack
内部,就应该是要滚动的内容了。
RefreshableScrollView 的内容
嗯,没错,应该是~
为什么是应该是呢?因为实际的实现要比想象的复杂一些 :) 我们对着代码来看:
ZStack(alignment: .top) {
MovingView()
VStack {
self.content
}
.alignmentGuide(.top, computeValue: { d in
(self.refreshing && self.frozen ? -self.threshold : 0.0) })
SymbolView(height: self.threshold,
loading: self.refreshing,
frozen: self.frozen,
rotation: self.rotation)
}
可以看到,滚动内容的顶部,是我们之前说过的 MovingView
,因此,它就可以随着内容的滚动始终保持在滚动内容的顶端,这样一来,通过它,就可以得到当前内容的滚动位置了。
接下来,是一个 VStack
,这里,才是真正要滚动显示的内容。值得说一下的,是我们设置它的位置的方法。alignmentGuide
除了支持用数值指定到 .top
的距离之外,它还有一个 computeValue
版本,允许我们指定一个计算方法。这个 closure 有一个 ViewDimensions
类型的参数,通过它可以在 View 自己的坐标系里,得到 View
的尺寸。不过这里,我们没有用到这个值。我们实现的逻辑是,只要 RefreshableScrollView
正在刷新,并且要“冻”住它的位置,就把它的位置设置成 -self.threshold
,也就是开始触发数据刷新的位置,否则,它距离顶部,就是 0。
看到这里,你可能会觉得,“冻”住的位置为什么是 -self.threshold
,如果 y 轴向下为正,应该是 self.threshold
才对啊。emm,应该说,这的确是 SwiftUI 中一个对于初学者而言,比较反直觉的东西。简单来说,alignmentGuide
的意思,是把参数指定的数值,对齐到指定的位置,只有把 -self.threshold
,对齐到 .top 0
这个位置,才是我们想要的效果。接下来,如果有机会,我们会专门和大家说说 SwiftUI 中各种形式的对齐有关的内容,现在大家知道它的含义就好了。
滚动视图的 ZStack
里,最后一个内容,是显示下拉和加载提示图标的 SymbolView
,我们就不重复说了。
因此,在 RefreshableScrollView
的滚动内容里,实际上叠了三层,分别是:MovingView
,表示滚动内容的 VStack
和 SymbolView
。
What's next?
至此,View
布局的部分就已经完成了。现在,稍微休息一下,整理下思路。下一节,我们完成随着下拉动态计算下拉距离,并触发更新机制的功能。