iOS 仿探探、陌陌的卡牌滑动库:YHDragContainer
先看看效果

传送门:YHDragContainer
为什么写这个库?
由于项目原因,我经常需要用到滑牌效果。最开始,我也是在网上找各种三方库,也的确找到了一些,但是都不是很满意,要么某些我想要的功能没有,要么就是感觉滑牌效果不好,要么就是有些Bug,导致我总是要去改动源码。这样折腾了几次之后,我决定自己写一个,因此就有了这个库。
需要哪些功能?
- 可点击
- 可滑动
- 可撤销
- 可循环
- 可复用
可点击
使用UITapGestureRecognizer
即可实现点击
可滑动
使用UIPanGestureRecognizer
即可实现点击
可撤销
需要由外界来告诉框架需要撤销的卡片是什么,框架本身不应该存储每张卡片,否则卡片数量过多,会造成内存飙升
可循环
需要一个索引值来记录顶层卡片显示到了第几张,然后来判断滑动到特定的索引值时,是否需要循环
可复用
需要一个复用池
上面的是主要功能,还有很多细节需要实现,比如可显示卡片数量,滑动时最大移除距离,滑动时最大移除速度,侧滑角度等等。
架构
数据源 + 代理 + 复用池
技术难点
- 滑动过程中,动态控制手指触摸的那张卡片的位置
- 滑动过程中,动态改变下层卡片的位置
- 如何实现复用
初始加载(刷新)
for index in 0..<showCount {
let y = self.correctCellSpacing() * CGFloat(index) // 纠正
let frame = CGRect(x: 0, y: y, width: cardWidth, height: cardHeight) // 纠正
let tmpScale: CGFloat = 1.0 - (scale * CGFloat(index)) // 获取scale
let transform = CGAffineTransform(scaleX: tmpScale, y: tmpScale) // 获取transform
// 遍历
if let cell = self.dataSource?.dragCard(self, indexOfCell: index) {
cell.isUserInteractionEnabled = false // 先全部置为不能交互
cell.layer.anchorPoint = CGPoint(x: 0.5, y: 1.0) // 锚点设置
insertSubview(cell, at: 0) // 添加到界面
cell.transform = .identity // 设置transform
cell.frame = frame // 设置frame
if animation { // 动画
UIView.animate(withDuration: 0.25, animations: {
cell.transform = transform
}, completion: nil)
} else {
cell.transform = transform
}
// 构造卡片信息
let info = YHDragCardInfo(cell: cell, transform: cell.transform, frame: cell.frame)
self.infos.append(info)
// 构造卡片信息
let stableInfo = YHDragCardStableInfo(transform: cell.transform, frame: cell.frame)
self.stableInfos.append(stableInfo)
// 如果不能响应拖动
if !disableDrag {
self.addPanGesture(for: cell)
}
// 如果不能响应点击
if !disableClick {
self.addTapGesture(for: cell)
}
// 把index = 0 打卡片中心点赋值给变量
if index == 0 {
self.initialFirstCellCenter = cell.center
}
} else {
fatalError("cell不能为空")
}
}
// 把最顶部的卡片设置为可交互,否则就拖动不了了
self.infos.first?.cell.isUserInteractionEnabled = true
// 显示顶层卡片的回调
if let topCell = self.infos.first?.cell {
self.delegate?.dragCard?(self, didDisplayCell: topCell, withIndexAt: self.currentIndex)
}
}
滑动过程中,动态控制手指触摸的那张卡片的位置
1、采用UIPanGestureRecognizer
2、需要定义一些属性
/// 顶层卡片的索引(直接与用户发生交互)
private var currentIndex: Int = 0
/// 初始化顶层卡片的位置
private var initialFirstCellCenter: CGPoint = .zero
/// 存储的卡片信息
private var infos: [YHDragCardInfo] = [YHDragCardInfo]()
/// 存储卡片位置信息(一直存在的)
/// 用于在滑动过程中,动态改变下层卡片的位置
private var stableInfos: [YHDragCardStableInfo] = [YHDragCardStableInfo]()
/// 是否正在撤销
/// 避免在短时间内多次调用revoke方法,必须等上一张卡片revoke完成,才能revoke下一张卡片
private var isRevoking: Bool = false
/// 是否正在调用`nextCard`方法
/// 避免在短时间内多次调用nextCard方法,必须`nextCard`完成,才能继续下一次`nextCard`
private var isNexting: Bool = false
/// 复用池子
private var reusableCells: [YHDragCardCell] = []
2、获取位置和速度
let movePoint = panGesture.translation(in: self)
let velocity = panGesture.velocity(in: self)
3、手势状态为began
时,添加下一张卡片到最底部
// 通过数据源获取卡片
cell = self.dataSource?.dragCard(self, indexOfCell: self.currentIndex + showCount)
_cell.isUserInteractionEnabled = false // 不能响应交互
insertSubview(_cell, at: 0) // 添加到界面上
// 构造卡片信息,添加到数组
let info = YHDragCardInfo(cell: _cell, transform: _cell.transform, frame: _cell.frame)
self.infos.append(info)
4、手势状态为changed
时,动态改变卡片位置
let currentPoint = CGPoint(x: cell.center.x + movePoint.x, y: cell.center.y + movePoint.y)
// 设置手指拖住的那张卡牌的位置
cell.center = currentPoint
// 垂直方向上的滑动比例
let verticalMoveDistance: CGFloat = cell.center.y - self.initialFirstCellCenter.y
var verticalRatio = verticalMoveDistance / self.correctVerticalRemoveDistance()
if verticalRatio < -1.0 {
verticalRatio = -1.0
} else if verticalRatio > 1.0 {
verticalRatio = 1.0
}
// 水平方向上的滑动比例
let horizontalMoveDistance: CGFloat = cell.center.x - self.initialFirstCellCenter.x
var horizontalRatio = horizontalMoveDistance / self.correctHorizontalRemoveDistance()
if horizontalRatio < -1.0 {
horizontalRatio = -1.0
} else if horizontalRatio > 1.0 {
horizontalRatio = 1.0
}
// 设置手指拖住的那张卡牌的旋转角度
let rotationAngle = horizontalRatio * self.correctRemoveMaxAngleAndToRadius()
cell.transform = CGAffineTransform(rotationAngle: rotationAngle)
// 复位
panGesture.setTranslation(.zero, in: self)
if self.removeDirection == .horizontal {
// 下层卡牌变化
self.moving(ratio: abs(horizontalRatio))
} else {
// 下层卡牌变化
self.moving(ratio: abs(verticalRatio))
}
// 滑动过程中的方向设置
var horizontal: YHDragCardMoveDirection = .none
var vertical: YHDragCardMoveDirection = .none
if horizontalRatio > 0.0 {
horizontal = .right
} else if horizontalRatio < 0.0 {
horizontal = .left
}
if verticalRatio > 0.0 {
vertical = .down
} else if verticalRatio < 0.0 {
vertical = .up
}
// 滑动过程中的回调
let direction = YHDragCardDirection(horizontal: horizontal, vertical: vertical, horizontalRatio: horizontalRatio, verticalRatio: verticalRatio)
self.delegate?.dragCard?(self, currentCell: cell, withIndex: self.currentIndex, currentCardDirection: direction, canRemove: false)
5、手势状态为changed
时,下层卡片位置的变化
private func moving(ratio: CGFloat) {
// 1、infos数量小于等于visibleCount
// 2、infos数量大于visibleCount(infos数量最多只比visibleCount多1)
var ratio = ratio
if ratio.isLess(than: .zero) {
ratio = 0.0
} else if ratio > 1.0 {
ratio = 1.0
}
// index = 0 是最顶部的卡片
// index = info.count - 1 是最下面的卡片
for (index, info) in self.infos.enumerated() {
if self.infos.count <= self.visibleCount {
if index == 0 { continue }
} else {
if index == self.infos.count - 1 || index == 0 { continue }
}
let willInfo = self.infos[index - 1]
let currentTransform = info.transform
let currentFrame = info.frame
let willTransform = willInfo.transform
let willFrame = willInfo.frame
info.cell.transform = CGAffineTransform(scaleX:currentTransform.a - (currentTransform.a - willTransform.a) * ratio,
y: currentTransform.d - (currentTransform.d - willTransform.d) * ratio)
var frame = info.cell.frame
frame.origin.y = currentFrame.origin.y - (currentFrame.origin.y - willFrame.origin.y) * ratio;
info.cell.frame = frame
}
}
6、手势状态为ended
时,判断是移除卡片还是复位
let horizontalMoveDistance: CGFloat = cell.center.x - self.initialFirstCellCenter.x
let verticalMoveDistance: CGFloat = cell.center.y - self.initialFirstCellCenter.y
if self.removeDirection == .horizontal {
if (abs(horizontalMoveDistance) > self.horizontalRemoveDistance || abs(velocity.x) > self.horizontalRemoveVelocity) &&
abs(verticalMoveDistance) > 0.1 && // 避免分母为0
abs(horizontalMoveDistance) / abs(verticalMoveDistance) >= tan(self.correctDemarcationAngle()){
// 消失
self.disappear(horizontalMoveDistance: horizontalMoveDistance, verticalMoveDistance: verticalMoveDistance, removeDirection: horizontalMoveDistance.isLess(than: .zero) ? .left : .right)
} else {
// 复位
self.restore()
}
} else {
if (abs(verticalMoveDistance) > self.self.verticalRemoveDistance || abs(velocity.y) > self.verticalRemoveVelocity) &&
abs(verticalMoveDistance) > 0.1 && // 避免分母为0
abs(horizontalMoveDistance) / abs(verticalMoveDistance) <= tan(self.correctDemarcationAngle()) {
// 消失
self.disappear(horizontalMoveDistance: horizontalMoveDistance, verticalMoveDistance: verticalMoveDistance, removeDirection: verticalMoveDistance.isLess(than: .zero) ? .up : .down)
} else {
// 复位
self.restore()
}
}
7、手势状态为.cancelled
或者.failed
时,也复位卡片
复用
原理:卡片滑出去时,把卡片加入复用池,当加载卡片的时候,先从复用池里面取,如果没有,则新建卡片
原理很简单,但是实现起来还是有些麻烦的地方,其复用和UITableView
的复用还有些区别。首先是卡片可以复位,需要考虑到复位的情况。其次,顶层卡片在触发消失的时候,其动画时间是和下层卡片的动画时间不一样的,顶层卡片的动画时间略长,这样就会导致一个问题就是,一个卡片滑出去了,下层卡片其实已经加载好几次了,这会导致reusableCells的数量可能会大于self.visibleCount + 1
还有很多逻辑处理,可以看源码,注释很详细
结语
该框架经过几个版本的迭代,基本稳定下来了,后期不出意外应该不会做大改动,但是会时不时的进行细节上面的优化更新。在写该框架的时候,也参考了很多其他的同类型卡牌滑动库。个人觉得该框架在功能以及交互体验上是优于其他库的。但是,也不可避免的会出现Bug,欢迎大家提出。如果有什么有趣的功能,我觉得合适的话会加上。如果觉得写得不错的话,麻烦给个Star,算是一点小动力。
传送门:YHDragContainer