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

每一天都去学习一些东西,最后都会帮助到自己的。
得与失是平衡的,你放下了娱乐和休息的时间,那么就会得到更多的知识。
不积跬步,无以至千里,不积小流,无以成江海。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,980评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,422评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,130评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,553评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,408评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,326评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,720评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,373评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,678评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,722评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,486评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,335评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,738评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,283评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,692评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,893评论 2 335

推荐阅读更多精彩内容