这一节开始,我们来实现支持下拉刷新的滚动视图,它定义在 BxRefreshableScrollView.swift 里。
SymbolView
先从最简单的部分开始:SymbolView,它表示开始下拉后,在 ScrollView 顶部表示下拉幅度的图标。它有四个属性:
struct SymbolView: View {
var height: CGFloat
var loading: Bool
var frozen: Bool
var rotation: Angle
}
其中:
height表示这部分区域的高度,因为我们肯定不能随着ScrollView不断下拉一直拉伸这块区域,拉到一定份上,我们就应该固定它,然后进入加载数据的环节了;loading表示ScrollView是否正在加载数据,如果是,就显示上一节我们定义的ActivityIndicator;frozen表示是否在ScrollView上固定显示这块包含图标的区域;rotation表示在下拉过程中,表示下拉幅度的图标的旋转角度;

然后,是 body 的部分:
var body: some View {
Group {
if loading {
VStack {
Spacer()
ActivityIndicator()
Spacer()
}
.frame(height: height).fixedSize()
.offset(y: -height + (loading && frozen ? height : 0))
}
else {
Image(systemName: "arrow.down")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: height * 0.25, height: height*0.25).fixedSize()
.padding(height * 0.375)
.rotationEffect(rotation)
.offset(y: -height + (loading && frozen ? height : 0))
}
}
}
View 自身很简单,根据是否正在加载数据,我们分别显示了 ActivityIndicator 或 arrow.down 图标。值得说下的,是我们如何把 SymbolView 固定在 ScrollView 顶端的。这是通过下面这两行代码实现的:
.frame(height: height).fixedSize()
.offset(y: -height + (loading && frozen ? height : 0))
首先,frame 和 .fixedSize 把这个 View 的尺寸固定在了 height 高度,让它不随着自己的父容器的尺寸改变。其次,初始状态时,这个 View 的 offset 是 -height。稍后,构建整个滚动视图的时候就会知道,这个尺寸是在屏幕外看不到的。然后,随着下拉,当它完整显示出来之后,offset 就是 0 了。稍后,等实现滚动视图的时候就会看到,我们会让滚动视图配合 SymbolView 的位置,让它们同时显示在屏幕上。
另外,我们还要介绍一个设置 SymbolView 里图标旋转角度的方法:
func symbolRotation(_ scrollOffset: CGFloat) -> Angle {
if scrollOffset < threshold * 0.6 {
return .degrees(0)
}
else {
let h = Double(threshold)
let d = Double(scrollOffset)
let v = max(min(d - (h * 0.6), h * 0.4), 0)
return .degrees(180 * v / (h * 0.4))
}
}
它根据下拉的距离,也就是它的参数 scrollOffset,返回一个 SwiftUI 中的角度对象 Angle。当下拉距离小于我们设定阈值的 60% 之前,都不转动图标(如果你慢慢下拉 Demo,就会发现,有一段距离,箭头是不旋转的)。
超过了这个值之后,我们需要计算的,是继续滑动的距离,也就是 d - (h * 0.6),占 SymbolView 剩余高度的比例。根据这个比例,算出 180 度的占比,这个占比就是 symbolRotation 的返回值了。
MovingView 和 FixedView
接下来,我们来实现一开始说到的 MovingView 和 FixedView:
struct MovingView: View {
var body: some View {
GeometryReader {
Color.clear
/// Compare to:
/// ```
/// .anchorPreference(key: TagPreferenceKey.self,
/// value: .bounds,
/// transform: { [TagPreferenceData(bounds: $0)] })
/// ```
.preference(key: RefreshableKey.PrefKey.self,
value: [RefreshableKey.PrefData(
vType: .movingView,
bounds: $0.frame(in: .global))
])
}
.frame(height: 0)
}
}
struct FixedView: View {
var body: some View {
GeometryReader {
Color.clear.preference(
key: RefreshableKey.PrefKey.self,
value: [RefreshableKey.PrefData(
vType: .fixedView,
bounds: $0.frame(in: .global))
])
}
}
}
可以看到,这两个 View 都只是个透明背景的“摆设”。MovingView 是个高度为 0 的 View,我们会把它和 ScrollView 的内容顶部对其叠加在一起。而 FixedView 则是一个和 ScrollView 尺寸相同的 View 用于定位滚动内容的初始位置。
在这两个 View 的定义里,我们使用了 GeometryReader 在运行时获取它们的实际尺寸。保存的时候,这里使用了和之前 Tag 不同的方式,我们使用了 preference 方法,它的第一个参数,和之前使用的 anchorPreference 相同,即保存数据的键,因此我们传递了 RefreshableKey.PrefKey.self。但保存值的时候就有差异了,这次,我们无需指定要获取的数据类型,也没有生成数据的 closure,而是要直接写入要保存的数据,也就是 View 的尺寸。
为此,我们可以借助 GeometryProxy。它有一个 frame(in:) 方法。给它传递一个坐标系,它会返回对应的 View 在这个坐标系中的尺寸。由于稍后,我们只是要计算两个 View 的相对距离。因此这里统一设置成了 .global,表示按照屏幕坐标系计算。
What's next?
至此,为了实现下拉刷新,所有的准备工作就都完成了。在继续之前,请大家务必要理解在 MovingView 和 FixedView 中保存数据的方法。它们不是很困难,就是思维方式以及用法上,我们不是很习惯。因此,如果你刚接触这些内容,我们稍微慢一点,确保一切都 OK 后,再开始下一节的内容。