基本上所有的 APP
都会有 tableView
,那一般情况下就会有下拉刷新这个功能,就想着自己也来自定义一个下拉刷新的控件。
先看一下要实现的效果:
这是一部分的动画,实际上在这里我将触摸位置分成了三部分,左边,中间,右边,拖拽的位置不同,曲线的形变也不一样。
观察动画,首先是拖拽的时候会根据拖拽的幅度进行曲线形变,这就需要监听滑动手势,我在这里的做法是获取 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
的图。
知道了这三个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
只能有一个,同时存在有效的只有path
,path
是作用于position
和anchorPoint
的,所有记得设置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
每一天都去学习一些东西,最后都会帮助到自己的。
得与失是平衡的,你放下了娱乐和休息的时间,那么就会得到更多的知识。
不积跬步,无以至千里,不积小流,无以成江海。