仿<即刻>下拉刷新效果实现

个人还是比较喜欢<即刻>这个应用,界面的效果与交互都是比较清爽,想模仿一下里面的部分交互效果,于是决定先从不是那么复杂的下拉刷新入手,并记录下自己实现的思路。另,本 Demo 是按照个人的思路来实现的,仅供学习交流,Demo 下载链接见最后。

界面分析

即刻APP本身效果图如下:

通过效果图,可以观察出以下几点结论:

  • 默认界面静止的情况下刷新控件是在 scrollView 的最上面,默认隐藏.(这句话是废话)
  • 当用户慢慢往下拖动的时候会出现一个灰色的 J 的字母慢慢被深色给填满
  • 当用户拖动距离大于某个范围的时候,控件的不再是紧贴着 scrollView 往下移动,此时的移动速度会放缓,保证刷新控件的 J 大概在 scrollView的内容顶部与界面顶部的中间位置
  • J 在没有完全填满的情况下松手,刷新控件会回原位(这句好像也是废话)
  • J 在完全填满的情况下松手,那么此时这个 J 会慢慢变成成一个圆圈,变化过程是 J 的底部的弧形会变成圆形,并且 J 的上半部分的竖线会慢慢变短,并且进行刷新状态
  • 进入刷新状态之后,圆圈进行旋转动画
  • 刷新完成圆圈慢慢变细并且刷新控件回到最初的位置

经过以上一堆乱七八糟的分析之后,接下来再使用 Reveal 查看一下刷新控件的层次结构,如下:

再通过查看层次结构,也可以总结出以下几条:

  • 刷新控件继承于 UIView,并且内部并没有其他子控件,所以推断出里面的内容都是自己画出来的
  • 刷新控件的大小 45 45,默认 centerY 值是 -35 [(-自已的高度 0.5) - 12.5]

接下来我们就可以根据我们分析的功能一条一条的来慢慢实现。

功能实现

1. 创建刷新控件

创建 Swift 项目,自定义刷新控件类,取名为 JKRefreshControl,并实现 initWithFrame 方法

1
2
3
4
5
6
7
8
9
10
class JKRefreshControl: UIView {
// MARK: - 初始化
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

先确定当前控件的大小,定义一个私有常量:

1
2
/// 刷新控件的宽高
private let RefreshControlWH: CGFloat = 45

添加 setupUI 方法,在该方法中初始化控件,并在 initWithFrame 里面调用该方法

1
2
3
4
5
6
private func setupUI(){
// 设置大小
frame.size = CGSize(width: RefreshControlWH, height: RefreshControlWH)
// 添加红色以供测试
backgroundColor = UIColor.red
}

在控制器中 viewDidLoad 初始化该控件,添加到 scrollView 中测试

1
2
3
4
5
6
override func viewDidLoad() {
super.viewDidLoad()
let refresh = JKRefreshControl()
scrollView.addSubview(refresh)
}

查看效果:

2. 调整刷新控件位置

  • 需求:刷新控件要放在 scrollView 的最顶端并默认隐藏,所以 y 值是为负,并且其中心 x 是根据其父控件的宽度来进行计算不能写死。
  • 思考:调整刷新控件的位置的代码可以写在刷新控件内部,为了别人使用起来方便(系统的刷新控件的大小与位置都不需要使用者去考虑)。
  • 问题:如何取到刷新控件的父控件,也就是说应该在哪个地方去取到父控件并设置值是最合适的。
  • UIView 有一个方法:willMoveToSuperView 可以利用一个,可以在这个方法里面取到父控件,并且可以使用 KVO 监听父控件的 frame 变化,根据父控件的 frame 变化去调整当前刷新控件的位置,代码实现如下:
1
2
3
4
5
6
7
8
9
10
11
override func willMove(toSuperview newSuperview: UIView?) {
super.willMove(toSuperview: newSuperview)
if let superView = newSuperview as? UIScrollView {
self.superView = superView
// 监听superView的frame变化
superView.addObserver(self, forKeyPath: "frame", options: NSKeyValueObservingOptions.new, context: nil)
// 监听superView的滚动
superView.addObserver(self, forKeyPath: "contentOffset", options: NSKeyValueObservingOptions.new, context: nil)
}
}

添加通过传入父控件的 frame 去确定刷新控件位置的代码:

1
2
3
4
5
6
7
/// 设置控件的初始位置
///
/// - parameter superViewFrame: 父控件的位置
private func setLocation(superViewFrame: CGRect) {
// 后面的减 12.5 是为了确定其 y 值与 官方 app 的 y 值一样
self.center = CGPoint(x: superViewFrame.width * 0.5, y: -self.frame.height * 0.5 - 12.5)
}

添加 KVO 的值改变的处理:

1
2
3
4
5
6
7
8
9
10
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "contentOffset" {
// TODO: 处理 contentOffsetY 值改变
}else if keyPath == "frame" {
// 取到改变后的值
let value = (change![NSKeyValueChangeKey.newKey] as! NSValue).cgRectValue
// 重新设置位置
self.setLocation(superViewFrame: value)
}
}

另,因为后面需要根据 scrollView 的滚动距离去计算当前控件的状态,所以也可以在这个地方去使用 KVO 监听 scrollView 的 contentOffset 属性。关于为什么要什么 KVO 去监听滚动而不使用 scrollView 的代理方法呢,因为如果刷新控件成为 scrollView 的代理,那么就不能允许其他类比如控制器成为 scrollView 的代理,我们要做的就是让外界关心事情越少越好。

运行测试效果:

3. 内部图案分析

  • 内部的图案再看层次结构分析它并不是一个 View,是画出来的
  • 有一个灰色背景的 J,可以一个使用 CAShapeLayer 来实现
  • 根据用户拖动而变化的 J 我的方式是使用两个 CAShapeLayer 来实现,一个 layer 实现字母 J 下面1/4圆的绘制,另一个 layer 实现字母 J 上面竖直图形的绘制

如图所示:

定义一些常量,供设置到 layer 上去和后面绘图的时候使用

1
2
3
4
5
6
7
8
9
10
11
// 填充的颜色
/// 主题颜色
private let ThemeColor = UIColor(red: 59/255, green: 84/255, blue: 106/255, alpha: 1)
/// 线宽
private let LineWidth: CGFloat = 5
/// 顶部矩形高度
private let LineHeight: CGFloat = 16
/// 内圆半径
private let InnerRadius: CGFloat = 8
/// 绘制的中心点
private let DrawCenter = CGPoint(x: RefreshControlWH * 0.5, y: RefreshControlWH * 0.5)

定义 3 个 layer,并在 setupUI 方法中添加到刷新控件的 layer 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// MARK: - 懒加载layer
// 背景灰色的layer,显示 `J`
fileprivate lazy var bgGrayLayer: CAShapeLayer = {
let layer = CAShapeLayer()
let bgColor = UIColor(red: 222/255, green: 226/255, blue: 229/255, alpha: 1)
layer.fillColor = bgColor.cgColor
layer.strokeColor = bgColor.cgColor
return layer
}()
// 底部layer,显示 `J` 的下半部分
fileprivate lazy var bottomLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.fillColor = UIColor.clear.cgColor
layer.strokeColor = ThemeColor.cgColor
// 设置线宽
layer.lineWidth = self.lineWidth
// 设置frame,用于转圈
layer.frame = self.bounds
return layer
}()
// 顶部layer,显示 `J` 的上半部分
fileprivate lazy var topLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.strokeColor = ThemeColor.cgColor
layer.lineWidth = self.lineWidth
return layer
}()
...
private func setupUI(){
frame.size = CGSize(width: RefreshControlWH, height: RefreshControlWH)
backgroundColor = UIColor.clear
// 添加三个layer
layer.addSublayer(bgGrayLayer)
layer.addSublayer(bottomLayer)
layer.addSublayer(topLayer)
}

4. 背景灰色 J 的实现

添加一个 extension, 专门用于更新界面,并提供 drawInLayer 方法绘制 layer 中的内容:

1
2
3
4
5
6
7
8
// MARK: - 更新界面
extension JKRefreshControl {
/// 绘制 layer 中的内容
fileprivate func drawInLayer() {
}
}

drawInLayer 方法中实现绘制灰色背景 J 的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fileprivate func drwaInLayer() {
// 绘制默认状态与松手就刷新状态的代码
// 绘制灰色背景 layer 内容
// 画 1/4 圆
let path = UIBezierPath(arcCenter: DrawCenter, radius: InnerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: false)
// 添加左边竖线
path.addLine(to: CGPoint(x: path.currentPoint.x, y: DrawCenter.y - LineHeight))
// 添加顶部横线
path.addLine(to: CGPoint(x: path.currentPoint.x + LineWidth, y: path.currentPoint.y))
// 添加右边竖线
path.addLine(to: CGPoint(x: path.currentPoint.x, y: path.currentPoint.y + LineHeight))
// 添加外圆
path.addArc(withCenter: DrawCenter, radius: InnerRadius + LineWidth, startAngle: endAngle, endAngle: startAngle - 0.05, clockwise: true)
path.close()
// 设置路径
bgGrayLayer.path = path.cgPath
}

aaa在画外圆的时候,endAngle 减去了 0.05,原因是官方的效果的字母 J 的底部并不是一个标准的1/4圆,会少一点

setupUI 方法中调用该方法,运行测试效果如下:

5. 处理 ScrollView 拖动改变控件的 y 值

我们上面说了,当用户拖动距离大于某个范围的时候,控件的不再是紧贴着 scrollView 往下移动,此时的移动速度会放缓,保证刷新控件的 J 大概在 scrollView的内容顶部与界面顶部的中间位置,所以添加 dealContentOffsetYChanged 方法用于处理 scrollView 的 contentOffsetY 值改变之后去处理刷新控件的位置:

1
2
3
4
/// 处理contentOffsetY改变
private func dealContentOffsetYChanged() {
}

在 KVO 的值改变处理方法中调用该方法:

1
2
3
4
5
6
7
8
9
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "contentOffset" {
// 处理contentOffsetY改变
self.dealContentOffsetYChanged()
}else if keyPath == "frame" {
let value = (change![NSKeyValueChangeKey.newKey] as! NSValue).cgRectValue
self.setLocation(superViewFrame: value)
}
}

需要实现的功能:当拖动的范围的2分之1大于控件的中心 y 值的时候,需要设置刷新控件的中心 y 值为 scrollView 内容顶部到 scrollView 的顶部的中间位置,具体见下图:

添加 defaultCenterY 属性记录控件的默认 y 值:

1
2
3
4
/// 默认的centerY
lazy var defaultCenterY: CGFloat = {
return -self.frame.height * 0.5 - 12.5
}()

所以代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private func dealContentOffsetYChanged() {
// 取出偏移的y值
let contentOffsetY = superView.contentOffset.y;
// 1. 设置 控件的 y 值
// 通过偏移量与顶部间距计算数当前控件的中心点
let result = (contentOffsetY + superView.contentInset.top) / 2
// 判断计算出来的值是否比默认的Y值还要小,如果小,就设置该 y 值
if result < defaultCenterY {
self.center = CGPoint(x: self.center.x, y: result)
}else{
// 否则继续设置为默认Y值
self.center = CGPoint(x: self.center.x, y: defaultCenterY)
}
}

运行查看效果:

6. 根据拖动的距离填充字母 J

要根据拖动的距离填充字母,那么需要知道拖动距离与填充的范围的比例关系:

  • 例如:拖动的距离为50,填充字母 J 的范围比例为 50%
  • 而这个比例的公式为:比例 = 拖动的距离 / 控件的高度
  • 当拖动的距离已经完全将控件显示出来的话,那么就代表 J 被填满了。

所以,我们可以定义一个属性值去记录当前拖动范围求出来的比例:

1
2
3
4
5
6
7
8
9
10
11
12
13
/// 拖动距离计算出来的填充比
var contentOffsetScale: CGFloat = 0 {
didSet {
// 当比例值大于 1 的时候,就设置为 1
if contentOffsetScale > 1 {
contentOffsetScale = 1
}
// 当比例值小于 0 的时候,就设置为 0
if contentOffsetScale <= 0 {
contentOffsetScale = 0
}
}
}

注:当比例值大于 1 的时候,就设置为 1,当比例值小于 0 的时候,就设置为 0

这个值在 dealContentOffsetYChanged 方法中计算出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private func dealContentOffsetYChanged() {
// 1. 设置 控件的 y 值 (上面已实现,代码省略)
...
// 2. TODO: 见文章下面
// 3. 计算 scale
// 通过拖动的距离计算.公式为:比例 = 拖动的距离 / 控件的高度
let scale = -(superView.contentOffset.y + superView.contentInset.top) / RefreshingStayHeight
self.contentOffsetScale = scale
// 重新绘制内容
self.drawInLayer()
}

接下来的任务就是要通过比例关系去填充字母,实现的思路是:

  • 如果比例小于等于 0.5,只填充 bottomLayerbottomLayer 的填充范围是:比例 * 2
  • 如果比例大于 0.5,填满 bottomLayer,并且填充 topLayertopLayer 的填充范围是:(比例 - 0.5) * 2

所以在 drawInLayer 中添加实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/// 绘制 layer 中的内容
fileprivate func drawInLayer() {
// 绘制默认状态与松手就刷新状态的代码
// 绘制灰色背景 layer 内容,上面已经有该代码,省略
...
// 通过比例绘制填充 layer
// 如果小于0.016.在画度半圆的时候会反方向画,所以加个判断
if contentOffsetScale < 0.016 {
bgGrayLayer.path = nil
bottomLayer.path = nil
topLayer.path = nil
return
}
/// 提供内部方法,专门用于获取绘制底部的圆的 path
func pathForBottomCircle(contentOffsetScale: CGFloat) -> UIBezierPath {
// 记录传入的比例
var scale = contentOffsetScale
// 如果比例大于 0.5,那么设置为 0.5
if scale > 0.5 {
scale = 0.5
}
// 计算出开始角度与结束角度
let targetStartAngle = startAngle
let targetEndAngle = startAngle - startAngle * scale * 2
// 初始化 path 并返回
let drawPath = UIBezierPath(arcCenter: DrawCenter, radius: InnerRadius + LineWidth * 0.5, startAngle: targetStartAngle, endAngle: targetEndAngle, clockwise: false)
return drawPath
}
bottomLayer.path = pathForBottomCircle(contentOffsetScale: contentOffsetScale).cgPath
// 判断如果拖动比例小于0.5,只画半圆
if contentOffsetScale <= 0.5 {
topLayer.path = nil
}else {
// 画顶部竖线
let topPath = UIBezierPath()
topPath.lineCapStyle = .square
topPath.move(to: CGPoint(x: DrawCenter.x + InnerRadius + LineWidth * 0.5, y: DrawCenter.y))
topPath.addLine(to: CGPoint(x: DrawCenter.x + InnerRadius + LineWidth * 0.5, y: DrawCenter.y - (contentOffsetScale - 0.5) * 2 * LineHeight))
topLayer.path = topPath.cgPath
}
}

运行测试,效果如图:

写到这一步为止,刷新控件的拖动变化效果就已经实现完毕,接下来的任务是当字母被填满的时候松手,进行转圈的动画,但是:

  • 在做转圈动画之前,我们需要先完成当字母被填满的时候松手控件会停留在顶部位置,而不会回到导航栏下面
  • 所以我们需要弄清楚刷新控件的几种状态,通过刷新控件的几种状态去设置不同状态下刷新控件的位置
    • 默认状态:被导航栏盖住(已完成)
    • 松手就可以刷新的状态:根据用户拖动去计算位置(已完成)
    • 刷新中状态:在导航栏的下面,并显示到界面上,实现思路是调整 scrollview 的 contentInset 的 top 实现增加 scrollView 的滚动范围

7. 通过拖动 scrollView 调整刷新控件状态

使用枚举定义刷新控件的状态:

1
2
3
4
5
6
7
8
/// 刷新控件的状态
///
/// - normal: 默认
/// - pulling: 松开就可刷新
/// - refreshing: 刷新中
enum JKRefreshState: Int {
case normal = 0, pulling, refreshing
}

在类中定义状态的属性 refreshState

1
2
/// 刷新状态,默认为默认状态
fileprivate var refreshState: JKRefreshState = .normal

监听 scrollView 的滚动,在 dealContentOffsetYChanged 方法中调整刷新控件的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private func dealContentOffsetYChanged() {
// 1. 设置 控件的 y 值 (上面已实现,代码省略)
// 通过偏移量与顶部间距计算数当前控件的中心点
let result = (contentOffsetY + superView.contentInset.top) / 2
...
// 2. 更改控件的状态
// 如果正在被拖动
if superView.isDragging {
// 如果空白中心点小于控件的默认中心y值,并且当前状态是默认状态,就进入 `松手就刷新的状态`
if result < defaultCenterY && refreshState == .normal {
refreshState = .pulling
}else if result >= defaultCenterY && refreshState == .pulling {
// 如果空白中心点大于等于控件的默认中心y值,并且当前状态是默认状态,就进入 `默认状态`
refreshState = .normal
}
}else {
// 用户已松手,判断当前状态如果是 `pulling` 状态就进行刷新状态
if refreshState == .pulling {
refreshState = .refreshing
}
}
// 3. 计算 scale (上面已实现,代码省略)
...
}

refreshStatedidSet 方法中调整顶部的距离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// 刷新状态
fileprivate var refreshState: JKRefreshState = .normal {
didSet{
switch refreshState {
case .refreshing:
// 调整顶部距离
var inset = self.superView.contentInset
// 将原有的顶部距离加上刷新控件的高度
inset.top = inset.top + RefreshingStayHeight
// 调整
DispatchQueue.main.async {
UIView.animate(withDuration: RefreshControlHideDuration, animations: {
self.superView.contentInset = inset
self.superView.setContentOffset(CGPoint(x: 0, y: -inset.top), animated: false)
}, completion: { (_) in
// TODO: 需要调用外界的刷新方法
})
}
default:
break
}
}
}

关于为什么需要使用主队列异步去调整,并且还要设置 contentOffset 请见文章:你的下拉刷新是否“抖”了一下 (在模拟器上有时候不太好使,在真机上没有问题)

8. 刷新中控件转圈效果的实现

松开就可刷新状态刷新中状态的效果可以分成三部分:

  • J 底部的 1/4 圆慢慢变成整圆
  • J 的上面部分竖线慢慢变短
  • 变成整圆之后进行旋转

三部分效果分别如下:

只要将这三种效果合成一种效果就能实现即刻的效果,所以在 drawInLayer 方法中,判断如果是刷新中状态的话,就去执行 layer 的动画,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/// 绘制 layer 中的内容
fileprivate func drawInLayer() {
// 开始角度
let startAngle = CGFloat(M_PI) / 2
// 结束角度
let endAngle: CGFloat = 0
if refreshState == .refreshing {
// 判断如果正在刷新的话,就不需要再次执行动画
if isRefreshingAnim {
return
}
// 调整执行动画属性为true
isRefreshingAnim = true
// 清空背景灰色的layer
bgGrayLayer.path = nil
// 1. 底部半圆到整圆
let bottomPath = UIBezierPath(arcCenter: DrawCenter, radius: InnerRadius + LineWidth * 0.5, startAngle: 0, endAngle: CGFloat(M_PI) * 2 - 0.1, clockwise: true)
bottomLayer.path = bottomPath.cgPath
// 执行动画
let bottomAnim = CABasicAnimation(keyPath: "strokeEnd")
bottomAnim.fromValue = NSNumber(value: 0.25)
bottomAnim.toValue = NSNumber(value: 1.0)
bottomAnim.duration = 0.15
bottomLayer.add(bottomAnim, forKey: nil)
// 2. 竖线变短动画
// 顶部Path
let topPath = UIBezierPath()
topPath.lineCapStyle = .square
topPath.move(to: CGPoint(x: DrawCenter.x + InnerRadius + LineWidth * 0.5, y: DrawCenter.y))
topPath.addLine(to: CGPoint(x: DrawCenter.x + InnerRadius + LineWidth * 0.5, y: DrawCenter.y - (contentOffsetScale - 0.5) * 2 * LineHeight))
topLayer.path = topPath.cgPath
// 竖线变短动画
let topAnim = CABasicAnimation(keyPath: "strokeEnd")
topAnim.fromValue = NSNumber(value: 1)
topAnim.toValue = NSNumber(value: 0)
topAnim.duration = 0.15
topLayer.strokeEnd = 0;
topLayer.add(topAnim, forKey: nil)
// 3. 0.15秒之后 转圈
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.15, execute: {
// 执行转圈动画
let bottomPath = UIBezierPath(arcCenter: DrawCenter, radius: InnerRadius + LineWidth * 0.5, startAngle: 0, endAngle: CGFloat(M_PI) * 2 - 0.1, clockwise: true)
self.bottomLayer.path = bottomPath.cgPath
// 围绕 z 轴转圈
let bottomAnim = CABasicAnimation(keyPath: "transform.rotation.z")
bottomAnim.fromValue = NSNumber(value: 0)
bottomAnim.toValue = NSNumber(value: 2 * M_PI)
bottomAnim.duration = 0.5
bottomAnim.repeatCount = MAXFLOAT
self.bottomLayer.add(bottomAnim, forKey: "runaroundAnim")
})
// 直接返回,不再执行下面的代码
return
}
// 绘制默认状态与松手就刷新状态的代码 (上面已实现,代码省略)
...
}

以上代码中的 isRefreshingAnim 是用来记录当前是否正在执行刷新动画的属性,防止用户在刷新过程中来回拖动 scrollView 造成重复添加动画,代码定义为:

1
2
// 是否正在执行刷新中的动画,防止用户来回拖动 scrollView 造成重复添加动画
fileprivate var isRefreshingAnim: Bool = false

运行测试,效果如图:

9. 添加进入刷新中的监听事件

仿照系统的 UIRefreshControl 添加刷新的事件,所以我们可以将我们的刷新控件继承于 UIControl,那么我们的控件就拥有了添加事件的功能:

1
2
3
class JKRefreshControl: UIControl {
...
}

控制器 中给刷新控件添加监听事件,并指定 event.valueChanged

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
override func viewDidLoad() {
super.viewDidLoad()
let refresh = JKRefreshControl()
refresh.addTarget(self, action: #selector(loadData), for: .valueChanged)
scrollView.addSubview(refresh)
}
/// 模拟异步加载数据
@objc private func loadData() {
DispatchQueue.global().async {
Thread.sleep(forTimeInterval: 3)
DispatchQueue.main.async {
print("刷新完毕, reload tableView")
}
}
}

刷新控件 的状态被改变成 refresh 状态的话调用监听的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
case .refreshing:
// 调整顶部距离
var inset = self.superView.contentInset
inset.top = inset.top + RefreshingStayHeight
DispatchQueue.main.async {
UIView.animate(withDuration: RefreshControlHideDuration, animations: {
self.superView.contentInset = inset
self.superView.setContentOffset(CGPoint(x: 0, y: -inset.top), animated: false)
}, completion: { (_) in
// 调用外界的刷新方法
self.sendActions(for: .valueChanged)
})
}

运行测试,可以看到当松手 3 秒后,就会打印 刷新完毕, 但是刷新完毕之后,刷新控件并没有回到最初的位置(被导航栏盖住),所以接下来需要实现的当刷新完毕之后的效果。

10. 刷新完毕动画效果实现

当刷新完毕之后,转圈圆环的 layer 会慢慢变细,具体见顶部的效果图,所以我们也可以类似于系统的刷新控件 UIRefreshControl 提供结束刷新的方法 endRefreshing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// 结束刷新
func endRefreshing() {
// 执行转圈的layer的线宽的动画
let animation = CABasicAnimation(keyPath: "lineWidth")
animation.toValue = 0
animation.duration = 0.5
// 设置最终线宽为 0,保证动画执行完毕之后不再显示
bottomLayer.lineWidth = 0
bottomLayer.add(animation, forKey: nil)
// 重置 contentInset
var inset = self.superView.contentInset
inset.top = inset.top - RefreshingStayHeight
UIView.animate(withDuration: RefreshControlHideDuration, animations: {
self.superView.contentInset = inset
self.superView.setContentOffset(CGPoint(x: 0, y: -inset.top), animated: false)
}, completion: { (_) in
// 在所有动画执行完毕之后将状态设置为 normal
self.refreshState = .normal
})
}

在所有动画执行完毕之后将状态设置为 normal,并且在 normal 是重置一些必要的属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// 刷新状态
fileprivate var refreshState: JKRefreshState = .normal {
didSet {
switch refreshState {
case .refreshing:
...
case .normal:
// 移除两个layer的路径
bottomLayer.path = nil
topLayer.path = nil
// 为默认状态时,重置属性
bottomLayer.removeAllAnimations()
topLayer.strokeEnd = 1
bottomLayer.lineWidth = LineWidth
isRefreshingAnim = false
default:
break
}
}
}

在控制器中数据加载完成之后调用 endRefreshing 方法:

1
2
3
4
5
6
7
8
9
10
@objc private func loadData() {
DispatchQueue.global().async {
Thread.sleep(forTimeInterval: 3)
DispatchQueue.main.async {
print("刷新完毕, reload tableView")
self.refresh.endRefreshing()
}
}
}

写到此,基本功能效果已经实现,效果如图:

11. 添加主动进入刷新状态的方法

在<即刻>中,停留在发现页面的时候,点击底部 tabBar 发现 按钮,会主动进行刷新状态,要实现这个功能,只需要添加一个方法 `` 让外界主动调用即可,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// 开始刷新
func beginRefreshing() {
// 如果已经开始刷新,就直接返回,防止用户多次点击
if isBeginRefreshing {
return
}
isBeginRefreshing = true
let contentInsetY = superView.contentInset
UIView.animate(withDuration: 0.25, animations: {
// 调整 contentOffset
self.superView.setContentOffset(CGPoint(x: 0, y: -contentInsetY.top - RefreshingStayHeight), animated: false)
}) { (_) in
// 在动画执行完毕调整刷新状态为 `刷新中`
self.refreshState = .refreshing
// 重绘 layer
self.drawInLayer()
}
}

上面代码中的 isBeginRefreshing 是定义一个标志,用于判断用户在第1次触发刷新状态之后,还刷新完成的情况下,再次触发,代码定义为:

1
2
3
4
5
6
7
8
9
10
11
// 是否已经开始执行刷新,防止用户在未刷新完成的情况下重复触发
fileprivate var isBeginRefreshing: Bool = false
```
并且在 `refreshState` 设置为 `normal` 状态的时候重置:
```swift
case .normal:
// 重置一些其他的属性(上面已实现,代码省略)
...
// 重置是否开始刷新的状态
isBeginRefreshing = false

在控制器中添加测试代码:

1
2
3
4
5
6
7
8
9
override func viewDidLoad() {
super.viewDidLoad()
let refresh = JKRefreshControl()
refresh.addTarget(self, action: #selector(loadData), for: .valueChanged)
scrollView.addSubview(refresh)
// 添加主动触发刷新的 item
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Refresh", style: UIBarButtonItemStyle.plain, target: refresh, action: #selector(JKRefreshControl.beginRefreshing))
}

运行测试,效果如图:

小结

其实如果你看到这一句话的话,那么我猜可能存在两种情况:

  1. 你很耐心的居然看完了😂
  2. 你是看了开头中间没看,直接想滚动到最底部找 Demo 链接的…如果我猜对了,请给个 star 支持哈哈

GitHub:JKRefreshControl by: EnjoySR

这个家伙很懒...<br>所以什么都没有留下