个人还是比较喜欢<即刻>这个应用,界面的效果与交互都是比较清爽,想模仿一下里面的部分交互效果,于是决定先从不是那么复杂的下拉刷新
入手,并记录下自己实现的思路。另,本 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 { 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.addObserver(self, forKeyPath: "frame", options: NSKeyValueObservingOptions.new, context: nil) superView.addObserver(self, forKeyPath: "contentOffset", options: NSKeyValueObservingOptions.new, context: nil) } }
|
添加通过传入父控件的 frame 去确定刷新控件位置的代码:
1 2 3 4 5 6 7
| private func setLocation(superViewFrame: CGRect) { 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" { }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
| 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 }() fileprivate lazy var bottomLayer: CAShapeLayer = { let layer = CAShapeLayer() layer.fillColor = UIColor.clear.cgColor layer.strokeColor = ThemeColor.cgColor layer.lineWidth = self.lineWidth layer.frame = self.bounds return layer }() 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.addSublayer(bgGrayLayer) layer.addSublayer(bottomLayer) layer.addSublayer(topLayer) }
|
4. 背景灰色 J
的实现
添加一个 extension
, 专门用于更新界面,并提供 drawInLayer
方法绘制 layer 中的内容:
1 2 3 4 5 6 7 8
| extension JKRefreshControl { 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() { 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
方法中调用该方法,运行测试效果如下:

我们上面说了,当用户拖动距离大于某个范围的时候,控件的不再是紧贴着 scrollView 往下移动,此时的移动速度会放缓,保证刷新控件的 J
大概在 scrollView的内容顶部与界面顶部的中间位置,所以添加 dealContentOffsetYChanged
方法用于处理 scrollView 的 contentOffsetY 值改变之后去处理刷新控件的位置:
1 2 3 4
| 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" { 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
| 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() { let contentOffsetY = superView.contentOffset.y; let result = (contentOffsetY + superView.contentInset.top) / 2 if result < defaultCenterY { self.center = CGPoint(x: self.center.x, y: result) }else{ 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 { if contentOffsetScale > 1 { contentOffsetScale = 1 } 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() { ... let scale = -(superView.contentOffset.y + superView.contentInset.top) / RefreshingStayHeight self.contentOffsetScale = scale self.drawInLayer() }
|
接下来的任务就是要通过比例关系去填充字母,实现的思路是:
- 如果比例小于等于 0.5,只填充
bottomLayer
,bottomLayer
的填充范围是:比例 * 2
- 如果比例大于 0.5,填满
bottomLayer
,并且填充 topLayer
,topLayer
的填充范围是:(比例 - 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
| fileprivate func drawInLayer() { ... if contentOffsetScale < 0.016 { bgGrayLayer.path = nil bottomLayer.path = nil topLayer.path = nil return } func pathForBottomCircle(contentOffsetScale: CGFloat) -> UIBezierPath { var scale = contentOffsetScale if scale > 0.5 { scale = 0.5 } let targetStartAngle = startAngle let targetEndAngle = startAngle - startAngle * scale * 2 let drawPath = UIBezierPath(arcCenter: DrawCenter, radius: InnerRadius + LineWidth * 0.5, startAngle: targetStartAngle, endAngle: targetEndAngle, clockwise: false) return drawPath } bottomLayer.path = pathForBottomCircle(contentOffsetScale: contentOffsetScale).cgPath 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 的滚动范围
使用枚举定义刷新控件的状态:
1 2 3 4 5 6 7 8
| 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() { let result = (contentOffsetY + superView.contentInset.top) / 2 ... if superView.isDragging { if result < defaultCenterY && refreshState == .normal { refreshState = .pulling }else if result >= defaultCenterY && refreshState == .pulling { refreshState = .normal } }else { if refreshState == .pulling { refreshState = .refreshing } } ... }
|
在 refreshState
的 didSet
方法中调整顶部的距离
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 }) } 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
| fileprivate func drawInLayer() { let startAngle = CGFloat(M_PI) / 2 let endAngle: CGFloat = 0 if refreshState == .refreshing { if isRefreshingAnim { return } isRefreshingAnim = true bgGrayLayer.path = nil 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) 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) 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 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
| 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() { let animation = CABasicAnimation(keyPath: "lineWidth") animation.toValue = 0 animation.duration = 0.5 bottomLayer.lineWidth = 0 bottomLayer.add(animation, forKey: nil) 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 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: 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: { self.superView.setContentOffset(CGPoint(x: 0, y: -contentInsetY.top - RefreshingStayHeight), animated: false) }) { (_) in self.refreshState = .refreshing 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) navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Refresh", style: UIBarButtonItemStyle.plain, target: refresh, action: #selector(JKRefreshControl.beginRefreshing)) }
|
运行测试,效果如图:

小结
其实如果你看到这一句话的话,那么我猜可能存在两种情况:
- 你很耐心的居然看完了😂
- 你是看了开头中间没看,直接想滚动到最底部找 Demo 链接的…如果我猜对了,请给个 star 支持哈哈
GitHub:JKRefreshControl by: EnjoySR