仿下拉刷新效果实现

3,812 阅读16分钟
原文链接: enjoysr.github.io

个人还是比较喜欢<即刻>这个应用,界面的效果与交互都是比较清爽,想模仿一下里面的部分交互效果,于是决定先从不是那么复杂的下拉刷新入手,并记录下自己实现的思路。另,本 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 方法

class JKRefreshControl: UIView {
    // MARK: - 初始化
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

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

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

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

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

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

override func viewDidLoad() {
    super.viewDidLoad()
        
    let refresh = JKRefreshControl()
    scrollView.addSubview(refresh)
}

查看效果:

2. 调整刷新控件位置

  • 需求:刷新控件要放在 scrollView 的最顶端并默认隐藏,所以 y 值是为负,并且其中心 x 是根据其父控件的宽度来进行计算不能写死。
  • 思考:调整刷新控件的位置的代码可以写在刷新控件内部,为了别人使用起来方便(系统的刷新控件的大小与位置都不需要使用者去考虑)。
  • 问题:如何取到刷新控件的父控件,也就是说应该在哪个地方去取到父控件并设置值是最合适的。
  • UIView 有一个方法:willMoveToSuperView 可以利用一个,可以在这个方法里面取到父控件,并且可以使用 KVO 监听父控件的 frame 变化,根据父控件的 frame 变化去调整当前刷新控件的位置,代码实现如下:
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 去确定刷新控件位置的代码:

/// 设置控件的初始位置
///
/// - 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 的值改变的处理:

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 上去和后面绘图的时候使用

// 填充的颜色
/// 主题颜色
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 中:

// 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 中的内容:

// MARK: - 更新界面
extension JKRefreshControl {
    
    /// 绘制 layer 中的内容
    fileprivate func drawInLayer() {
        
    }
}

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

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 方法中调用该方法,运行测试效果如下:

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

/// 处理contentOffsetY改变
private func dealContentOffsetYChanged() {
    
}

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

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 值:

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

所以代码为:

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 被填满了。

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

/// 拖动距离计算出来的填充比
var contentOffsetScale: CGFloat = 0 {
    didSet {
        // 当比例值大于 1 的时候,就设置为 1
        if contentOffsetScale > 1 {
            contentOffsetScale = 1
        }
        // 当比例值小于 0 的时候,就设置为 0
        if contentOffsetScale <= 0 {
            contentOffsetScale = 0
        }
    }
}

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

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

<
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 中添加实现代码:

/// 绘制 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 的滚动范围

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

/// 刷新控件的状态
///
/// - normal:     默认
/// - pulling:    松开就可刷新
/// - refreshing: 刷新中
enum JKRefreshState: Int {
    case normal = 0, pulling, refreshing
}

在类中定义状态的属性 refreshState

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

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

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 方法中调整顶部的距离

/// 刷新状态
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 的动画,代码如下:

/// 绘制 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 造成重复添加动画,代码定义为:

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

运行测试,效果如图:

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

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

class JKRefreshControl: UIControl {
    ...
}

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

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 状态的话调用监听的方法:

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

/// 结束刷新
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 是重置一些必要的属性:

/// 刷新状态
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 方法:

@objc private func loadData() {
   
    DispatchQueue.global().async {
        Thread.sleep(forTimeInterval: 3)
        DispatchQueue.main.async {
            print("刷新完毕, reload tableView")
            self.refresh.endRefreshing()
        }
    }
}

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

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

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

/// 开始刷新
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次触发刷新状态之后,还刷新完成的情况下,再次触发,代码定义为:

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

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

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