iOS 下拉刷新

基本上所有的 APP 都會(huì)有 tableView,那一般情況下就會(huì)有下拉刷新這個(gè)功能,就想著自己也來(lái)自定義一個(gè)下拉刷新的控件。

先看一下要實(shí)現(xiàn)的效果:

Refresh.gif

這是一部分的動(dòng)畫,實(shí)際上在這里我將觸摸位置分成了三部分,左邊,中間,右邊,拖拽的位置不同,曲線的形變也不一樣。

觀察動(dòng)畫,首先是拖拽的時(shí)候會(huì)根據(jù)拖拽的幅度進(jìn)行曲線形變,這就需要監(jiān)聽滑動(dòng)手勢(shì),我在這里的做法是獲取 ScrollView的引用,并且設(shè)置KVO監(jiān)聽 ContentOffset的改變。

ScrollView 有一個(gè)屬性,panGesture 滑動(dòng)手勢(shì),所以能得到觸摸位置。

//設(shè)置相關(guān)屬性

self.superScrollView.addSubview(self)

self.superScrollView = superScrollView

//設(shè)置kvo

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

ScrollView拖動(dòng)的時(shí)候都會(huì)改變 ContentOffset,然后回調(diào)override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?),在這個(gè)方法里面進(jìn)行操作。

直接上代碼

開始先判斷一下是否是向下拖動(dòng),向下拖動(dòng)的話因?yàn)闆]有添加上拉加載的功能,所以不做處理。

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

拖動(dòng)的時(shí)候也會(huì)有狀態(tài),如果正處于刷新狀態(tài)的話,不應(yīng)該被再出拖動(dòng),重新加載,所以定義一個(gè)Struct

enum MXRefreshStatus {
    case refreshing
    case none
}

回到KVO的監(jiān)聽方法

if self.refreshStatus == .none {
  //獲取點(diǎn)擊位置
  if self.touchPositionX == 0 {
  self.touchPositionX =    
     self.superScrollView.panGestureRecognizer.location(in: self.superScrollView).x
}
            
 let contentOffsetY = abs(self.superScrollView.contentOffset.y)
  //是否還在拖動(dòng)
  if self.superScrollView.isDragging {
    //繼續(xù)拖動(dòng)
    //最高點(diǎn)坐標(biāo)
    let highPointY = contentOffsetY - 64.0
    let path = self.updateWavePath(highPointY: highPointY, position: nil)
    self.waveLayer.path = path.cgPath
 }else{
    //沒有拖動(dòng)了,判斷是否直接刷新
    if contentOffsetY >= 150{
      //改變狀態(tài)
      self.refreshStatus = .refreshing
      //執(zhí)行彈性動(dòng)畫
      self.waveLayer.add(self.waveLayerAnimation, forKey: "WaveAnimation")
      //固定住
      var contentInset = self.superScrollView.contentInset
                    
      contentInset.top = 214
      
      self.superScrollView.contentInset = contentInset
                    
      //開始執(zhí)行 block
      self.operation(true)
      //設(shè)置延時(shí)操作
      DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + self.duration, execute: {
        //去除所有動(dòng)畫
        self.removeAllAnimtion()
        //修改狀態(tài)
      self.refreshStatus = .none
      //回收刷新 View
      var contentInset = self.superScrollView.contentInset
                        
      contentInset.top = 64
                        
      self.superScrollView.contentInset = contentInset
      //修改點(diǎn)擊位置
      self.touchPositionX = 0
      })
     }else{
        //不做操作,直接縮放回去
      if self.waveLayer.path != self.rectPath.cgPath {
        self.waveLayer.path = self.rectPath.cgPath
      }
        //修改點(diǎn)擊位置
        self.touchPositionX = 0
        //修改狀態(tài)
        self.refreshStatus = .none
        }
      }
}else{
  //正處于刷新狀態(tài),直接返回
   return
}

減去64是因?yàn)榭紤]了導(dǎo)航欄的存在,有了導(dǎo)航欄之后,所有的TableView都會(huì)下移64,并且ContentInset.top屬性會(huì)為64。用以固定住 tableView不會(huì)回滾。

這里放幾張斯坦福大學(xué)解釋ContentOffset,ContentInset,ContentSize的圖。

ContentSize.png

ContentInset.png

ContentOffset.png

知道了這三個(gè)attribute之后,應(yīng)該就知道了刷新過程中如何將 ScrollView固定住,只需要設(shè)置ContentInset.top的值就行,同理,以后要在其他方向固定,也是設(shè)置這個(gè)屬性。

在拖拽的過程中,曲線一直在形變,在調(diào)用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))
        //使用貝塞爾曲線
        //控制點(diǎn)
        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
    }

這個(gè)是根據(jù)拖動(dòng)Y的程度,去設(shè)置曲線的Control Point,原理是貝塞爾曲線,這里就不多說了,可以去查閱,有許多的資料專門介紹這個(gè)曲線。

同時(shí)監(jiān)聽手指松開的時(shí)候也只需要判斷ScrollView.isDragging屬性,拖拽結(jié)束時(shí)候判斷已經(jīng)拖動(dòng)的距離,達(dá)到刷新條件就刷新,沒有就直接縮回去。

達(dá)到刷新條件之后的彈性效果,我是采用CAKeyframeAnimation做的,還有一部分是使用CADisplayLink去實(shí)現(xiàn),在每一幀去重新繪制,我覺得這個(gè)動(dòng)畫是一直會(huì)需要使用,不如就直接實(shí)例化,作為屬性,每一次都只需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

動(dòng)畫的原理就是在duration內(nèi)設(shè)置曲線的ControlPoint一直是在上下改變,曲線的彎曲方向也就會(huì)改變,同時(shí)慢慢減少,也就形成了bounce效果。

這里的曲線是單獨(dú)設(shè)置的,不能和觸摸繪制關(guān)聯(lián)起來(lái),所以在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)
}

圓的動(dòng)畫是在曲線的動(dòng)畫完成之后才執(zhí)行的,所以就設(shè)置曲線的delegate

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

CAAnimationDelegate的回調(diào)是深拷貝,所以如果動(dòng)畫多的話,不能直接去用==比較,要單獨(dú)區(qū)分開,我認(rèn)為使用KVC比較好。

extension MXRefreshView : CAAnimationDelegate {
    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        switch anim.value(forKey: "identifier") as! String {
        case "WaveAnimation":
            //執(zhí)行圓圈動(dòng)畫
            self.refreshLoadingImageView.startAnimation()
            break
        default:
            
            break
        }
    }
    
}

圓圈的動(dòng)畫很簡(jiǎn)單,只是設(shè)置CAKeyframeAnimation.path,這個(gè)值和values只能有一個(gè),同時(shí)存在有效的只有path,path是作用于positionanchorPoint的,所有記得設(shè)置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
        
 //相當(dāng)于無(wú)限循環(huán)
self.bigLoadingCircleAnimation.repeatCount = Float.infinity
        
self.bigLoadingCircleAnimation.autoreverses = true
        
self.bigLoadingCircleAnimation.duration = 2.0
        
//MinCircle
//有多少個(gè)小圓,就有多少個(gè)動(dòng)畫,因?yàn)槊總€(gè)圓的動(dòng)畫有時(shí)延
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

每一天都去學(xué)習(xí)一些東西,最后都會(huì)幫助到自己的。
得與失是平衡的,你放下了娛樂和休息的時(shí)間,那么就會(huì)得到更多的知識(shí)。
不積跬步,無(wú)以至千里,不積小流,無(wú)以成江海。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容