这一节开始,我们来实现支持下拉刷新的滚动视图,它定义在 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 后,再开始下一节的内容。