Swift好文收藏

iOS 仿探探、陌陌的卡牌滑动库:YHDragContainer

2020-04-22  本文已影响0人  探索者的旅途

先看看效果

未命名.gif

传送门: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

上一篇 下一篇

猜你喜欢

热点阅读