iOS 下拉刷新

基本上所有的 APP 都会有 tableView,那一般情况下就会有下拉刷新这个功能,就想着自己也来自定义一个下拉刷新的控件。

先看一下要实现的效果:

Refresh.gif

这是一部分的动画,实际上在这里我将触摸位置分成了三部分,左边,中间,右边,拖拽的位置不同,曲线的形变也不一样。

观察动画,首先是拖拽的时候会根据拖拽的幅度进行曲线形变,这就需要监听滑动手势,我在这里的做法是获取 ScrollView的引用,并且设置KVO监听 ContentOffset的改变。

ScrollView 有一个属性,panGesture 滑动手势,所以能得到触摸位置。

//设置相关属性

self.superScrollView.addSubview(self)

self.superScrollView = superScrollView

//设置kvo

self.superScrollView.addObserver(self, forKeyPath:"contentOffset", options: NSKeyValueObservingOptions.new, context:nil)

ScrollView拖动的时候都会改变 ContentOffset,然后回调override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?),在这个方法里面进行操作。

直接上代码

开始先判断一下是否是向下拖动,向下拖动的话因为没有添加上拉加载的功能,所以不做处理。

if self.superScrollView.contentOffset.y > 0 {
            return
 }

拖动的时候也会有状态,如果正处于刷新状态的话,不应该被再出拖动,重新加载,所以定义一个Struct

enum MXRefreshStatus {
    case refreshing
    case none
}

回到KVO的监听方法

if self.refreshStatus == .none {
  //获取点击位置
  if self.touchPositionX == 0 {
  self.touchPositionX =    
     self.superScrollView.panGestureRecognizer.location(in: self.superScrollView).x
}
            
 let contentOffsetY = abs(self.superScrollView.contentOffset.y)
  //是否还在拖动
  if self.superScrollView.isDragging {
    //继续拖动
    //最高点坐标
    let highPointY = contentOffsetY - 64.0
    let path = self.updateWavePath(highPointY: highPointY, position: nil)
    self.waveLayer.path = path.cgPath
 }else{
    //没有拖动了,判断是否直接刷新
    if contentOffsetY >= 150{
      //改变状态
      self.refreshStatus = .refreshing
      //执行弹性动画
      self.waveLayer.add(self.waveLayerAnimation, forKey: "WaveAnimation")
      //固定住
      var contentInset = self.superScrollView.contentInset
                    
      contentInset.top = 214
      
      self.superScrollView.contentInset = contentInset
                    
      //开始执行 block
      self.operation(true)
      //设置延时操作
      DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + self.duration, execute: {
        //去除所有动画
        self.removeAllAnimtion()
        //修改状态
      self.refreshStatus = .none
      //回收刷新 View
      var contentInset = self.superScrollView.contentInset
                        
      contentInset.top = 64
                        
      self.superScrollView.contentInset = contentInset
      //修改点击位置
      self.touchPositionX = 0
      })
     }else{
        //不做操作,直接缩放回去
      if self.waveLayer.path != self.rectPath.cgPath {
        self.waveLayer.path = self.rectPath.cgPath
      }
        //修改点击位置
        self.touchPositionX = 0
        //修改状态
        self.refreshStatus = .none
        }
      }
}else{
  //正处于刷新状态,直接返回
   return
}

减去64是因为考虑了导航栏的存在,有了导航栏之后,所有的TableView都会下移64,并且ContentInset.top属性会为64。用以固定住 tableView不会回滚。

这里放几张斯坦福大学解释ContentOffset,ContentInset,ContentSize的图。

ContentSize.png

ContentInset.png

ContentOffset.png

知道了这三个attribute之后,应该就知道了刷新过程中如何将 ScrollView固定住,只需要设置ContentInset.top的值就行,同理,以后要在其他方向固定,也是设置这个属性。

在拖拽的过程中,曲线一直在形变,在调用updateWavePath

  let path = self.updateWavePath(highPointY: highPointY, position: nil)
                
self.waveLayer.path = path.cgPath

这就是绘制曲线形变的方法

//MARK: wavePath Stroke
private func updateWavePath(highPointY : CGFloat,position : MXRefreshPosition?)->UIBezierPath{
        
        let path = UIBezierPath.init()
        
        let lineY = self.waveLayer.bounds.size.height
        
        path.move(to: CGPoint.init(x: 0, y: 0))
        
        path.addLine(to: CGPoint.init(x: self.waveLayer.frame.width, y: 0.0))
        
        path.addLine(to: CGPoint.init(x: self.waveLayer.frame.width, y: self.waveLayer.frame.height))
        //使用贝塞尔曲线
        //控制点
        var controlPoint : CGPoint!
            //触摸
            controlPoint = CGPoint.init(x: self.touchPositionX, y: highPointY + lineY)
            //绘制路径
            if (self.touchPositionX != 0 && self.touchPositionX <= self.superScrollView.frame.width / 3.0) || (position != nil && position == .left) {
                //左边
                let destinationPointX = self.waveLayer.frame.width / 3.0 * 2.0
                
                path.addLine(to: CGPoint.init(x: destinationPointX, y: lineY))
                
                path.addQuadCurve(to: CGPoint.init(x: 0, y: lineY), controlPoint: controlPoint)
                
            }else if (self.touchPositionX != 0 && self.touchPositionX >= (self.superScrollView.frame.width - self.superScrollView.frame.width / 3.0)) || (position != nil && position == .right) {
                //右边
                let destinationPointX = self.waveLayer.frame.width / 3.0
                
                path.addQuadCurve(to: CGPoint.init(x: destinationPointX, y: lineY), controlPoint: controlPoint)
                path.addLine(to: CGPoint.init(x: 0, y: lineY))
                
            }else{
                //中间
                let leftStartPositionX = self.waveLayer.frame.width / 4.0
                
                let rightEndPositionX = self.waveLayer.frame.width / 4.0 * 3.0
                
                path.addLine(to: CGPoint.init(x: rightEndPositionX, y: lineY))
                
                path.addQuadCurve(to: CGPoint.init(x: leftStartPositionX, y: lineY), controlPoint: controlPoint)
                
                path.addLine(to: CGPoint.init(x: 0, y: lineY))
            }
        //闭合路径,连接首尾
        path.close()
        
        return path
    }

这个是根据拖动Y的程度,去设置曲线的Control Point,原理是贝塞尔曲线,这里就不多说了,可以去查阅,有许多的资料专门介绍这个曲线。

同时监听手指松开的时候也只需要判断ScrollView.isDragging属性,拖拽结束时候判断已经拖动的距离,达到刷新条件就刷新,没有就直接缩回去。

达到刷新条件之后的弹性效果,我是采用CAKeyframeAnimation做的,还有一部分是使用CADisplayLink去实现,在每一帧去重新绘制,我觉得这个动画是一直会需要使用,不如就直接实例化,作为属性,每一次都只需add就行。

self.waveLayerAnimation = CAKeyframeAnimation.init(keyPath: "path")
        
self.waveLayerAnimation.values = [
  self.updateWavePath(highPointY: 100.0, position: .left).cgPath,
  self.updateWavePath(highPointY: -80.0, position: .left).cgPath,
  self.updateWavePath(highPointY: 60.0, position: .left).cgPath,
  self.updateWavePath(highPointY: -40.0, position: .left).cgPath,
  self.updateWavePath(highPointY: 10.0, position: .left).cgPath,
  self.updateWavePath(highPointY: -5.0, position: .left).cgPath,
  self.updateWavePath(highPointY: 1.0, position: .left).cgPath,
  self.rectPath.cgPath
]
        
self.waveLayerAnimation.isRemovedOnCompletion = false
        
self.waveLayerAnimation.fillMode = kCAFillModeForwards
        
self.waveLayerAnimation.duration = 0.5
        
self.waveLayerAnimation.autoreverses = false

动画的原理就是在duration内设置曲线的ControlPoint一直是在上下改变,曲线的弯曲方向也就会改变,同时慢慢减少,也就形成了bounce效果。

这里的曲线是单独设置的,不能和触摸绘制关联起来,所以在update里面。

 if position != nil {
   let controlX : CGFloat = self.waveLayer.frame.width / 2.0    
   controlPoint = CGPoint.init(x: controlX, y: highPointY + lineY)
   //Path
   path.addQuadCurve(to: CGPoint.init(x: 0, y: lineY), controlPoint: controlPoint)
}

圆的动画是在曲线的动画完成之后才执行的,所以就设置曲线的delegate

 //Delegate
self.waveLayerAnimation.delegate = self
self.waveLayerAnimation.setValue("WaveAnimation", forKey: "identifier")

CAAnimationDelegate的回调是深拷贝,所以如果动画多的话,不能直接去用==比较,要单独区分开,我认为使用KVC比较好。

extension MXRefreshView : CAAnimationDelegate {
    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        switch anim.value(forKey: "identifier") as! String {
        case "WaveAnimation":
            //执行圆圈动画
            self.refreshLoadingImageView.startAnimation()
            break
        default:
            
            break
        }
    }
    
}

圆圈的动画很简单,只是设置CAKeyframeAnimation.path,这个值和values只能有一个,同时存在有效的只有pathpath是作用于positionanchorPoint的,所有记得设置keypath

直接给代码了

//BigCircle
self.bigLoadingCircleAnimation = CAKeyframeAnimation.init(keyPath: "path")
        
self.bigLoadingCircleAnimation.values = [
  UIBezierPath.init(arcCenter: CGPoint.init(x: self.bigLoadingCircle.frame.width / 2.0, y: self.bigLoadingCircle.frame.width / 2.0), radius: self.bigLoadingCircle.frame.width / 3.0, startAngle: 0, endAngle:CGFloat(M_PI * 2.0), clockwise: true).cgPath,
  UIBezierPath.init(arcCenter: CGPoint.init(x: self.bigLoadingCircle.frame.width / 2.0, y: self.bigLoadingCircle.frame.width / 2.0), radius: self.bigLoadingCircle.frame.width / 4.0, startAngle: 0, endAngle:CGFloat(M_PI * 2.0), clockwise: true).cgPath,
  UIBezierPath.init(arcCenter: CGPoint.init(x: self.bigLoadingCircle.frame.width / 2.0, y: self.bigLoadingCircle.frame.width / 2.0), radius: self.bigLoadingCircle.frame.width / 5.0, startAngle: 0, endAngle:CGFloat(M_PI * 2.0), clockwise: true).cgPath,
  UIBezierPath.init(arcCenter: CGPoint.init(x: self.bigLoadingCircle.frame.width / 2.0, y: self.bigLoadingCircle.frame.width / 2.0), radius: self.bigLoadingCircle.frame.width / 6.0, startAngle: 0, endAngle:CGFloat(M_PI * 2.0), clockwise: true).cgPath,
  UIBezierPath.init(arcCenter: CGPoint.init(x: self.bigLoadingCircle.frame.width / 2.0, y: self.bigLoadingCircle.frame.width / 2.0), radius: 2.5, startAngle: 0, endAngle:CGFloat(M_PI * 2.0), clockwise: true).cgPath
]
        
self.bigLoadingCircleAnimation.timingFunctions = [CAMediaTimingFunction.init(name: kCAMediaTimingFunctionLinear)]
        
self.bigLoadingCircleAnimation.isRemovedOnCompletion = false
        
 //相当于无限循环
self.bigLoadingCircleAnimation.repeatCount = Float.infinity
        
self.bigLoadingCircleAnimation.autoreverses = true
        
self.bigLoadingCircleAnimation.duration = 2.0
        
//MinCircle
//有多少个小圆,就有多少个动画,因为每个圆的动画有时延
for index in 0..<self.minLoadingCircles.count {
            
  let minLoadingCirclesAnimation = CAKeyframeAnimation.init(keyPath: "position")
            
   let circleMovePath = CGMutablePath.init()
            
  circleMovePath.addArc(center: CGPoint.init(x: self.frame.width / 2.0, y: self.frame.height + 6.0), radius: self.frame.width / 2.0 + 6.0, startAngle: 0.0, endAngle: CGFloat(M_PI * 2.0), clockwise: false)
   minLoadingCirclesAnimation.path = circleMovePath
            
  minLoadingCirclesAnimation.isRemovedOnCompletion = false

  minLoadingCirclesAnimation.repeatCount = Float.infinity
            
  minLoadingCirclesAnimation.autoreverses = false
            
  minLoadingCirclesAnimation.duration = 2.0
            
  self.minLoadingCirclesAnimations.append(minLoadingCirclesAnimation)
            
  //Delegate
  minLoadingCirclesAnimation.setValue(String.init(format: "MinCircleAnimation%d", index), forKey: "identifier")
  minLoadingCirclesAnimation.delegate = self
}

完整的代码放在GitHub

每一天都去学习一些东西,最后都会帮助到自己的。
得与失是平衡的,你放下了娱乐和休息的时间,那么就会得到更多的知识。
不积跬步,无以至千里,不积小流,无以成江海。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容