首页 > 代码库 > 让UITableView支持长按拖动排序
让UITableView支持长按拖动排序
来自Leo的原创博客,转载请著名出处
我的StackOverflow
我的Github
https://github.com/LeoMobileDeveloper
注意:本文的代码是用Swift 2.3写的
效果
项目地址
DraggableTableView
所有Cell都可以拖拽。
固定第一个Cell
限制长按区域
实现原理
- 对UITableView添加LongPress手势
- 在longPress的时候,对选中的Cell进行截图,添加到TableView作为subView,并且隐藏当前的选中的Cell
- 随着手势移动,调整截图的位置,根据移动的位置,决定是否需要交换两个Cell的位置
- 当截图移动到顶部/底部的时候,调用
CADisplayLink
来向上/向下滚动TableView
接口设计
最直接的方式,可能是继承UITableView
,然后在子类中增加相关的逻辑来调整。但是,这种方式有明显的缺陷:对现有的代码影响较大。引入了由继承引起的耦合。
Swift中,继承并不是一个很好的设计方式,因为Swift是一个面向协议的语言。
本文采用extension
和protocol
的方式,来设计接口。
定义一个协议
@objc public protocol DragableTableDelegate:AnyObject{
//因为Cell拖动,必然要同步DataSource,所以这是个必须实现的方法
func tableView(tableView:UITableView,dragCellFrom fromIndexPath:NSIndexPath,toIndexPath:NSIndexPath)
//可选,返回长按的Cell是否可拖拽。用touchPoint来实现长按Cell的某一区域实现拖拽
optional func tableView(tableView: UITableView,canDragCellFrom indexPath: NSIndexPath, withTouchPoint point:CGPoint) -> Bool
//可选,返回cell是否可以停止在indexPath
optional func tableView(tableView: UITableView,canDragCellTo indexPath: NSIndexPath) -> Bool
}
然后,我们用Objective C的关联属性,来给用户提用接口。
public extension UITableView{
//关联属性用到的Key
private struct OBJC_Key{
static var dragableDelegateKey = 0
static var dragableHelperKey = 1
static var dragableKey = 2
}
// MARK: - 关联属性 -
var dragableDelegate:DragableTableDelegate?{
get{
return objc_getAssociatedObject(self, &OBJC_Key.dragableDelegateKey) as? DragableTableDelegate
}
set{
objc_setAssociatedObject(self, &OBJC_Key.dragableDelegateKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_ASSIGN)
}
}
//是否可拖拽
var dragable:Bool{
get{
let number = objc_getAssociatedObject(self, &OBJC_Key.dragableKey) as! NSNumber
return number.boolValue
}
set{
if newValue.boolValue {
//进行必要的初始化
setupDragable()
}else{
//清理必要的信息
cleanDragable()
}
let number = NSNumber(bool: newValue)
objc_setAssociatedObject(self, &OBJC_Key.dragableDelegateKey, number, objc_AssociationPolicy.OBJC_ASSOCIATION_ASSIGN)
}
}
//因为拖拽的过程中,要存储ImageView,CADispalyLink等信息,所以需要一个辅助类
private var dragableHelper:DragableHelper?{
get{
return objc_getAssociatedObject(self, &OBJC_Key.dragableHelperKey) as? DragableHelper
}
set{
objc_setAssociatedObject(self, &OBJC_Key.dragableHelperKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN)
}
}
//...
}
Tips:
dragableDelegate
在关联的时候,是OBJC_ASSOCIATION_ASSIGN
,和weak var
是一个效果。防止循环引用。
辅助类DragableHelper
因为用关联对象来存储数据,不得不为每一个属性提供get/set
方法。所以,我们把需要的属性,存储到一个类DragableHelper
。
这个类如下
private class DragableHelper:NSObject,UIGestureRecognizerDelegate{
//存储当前拖动的Cell
weak var draggingCell:UITableViewCell?
//这里的_DisplayLink是一个私有类,用来封装CADisplayLink
let displayLink: _DisplayLink
//长按手势
let gesture: UILongPressGestureRecognizer
//浮动的截图ImageView
let floatImageView: UIImageView
//当前操作的UITableView
weak var attachTableView:UITableView?
//当拖动到顶部/底部的时候,tableView向上或者向下滚动的速度
var scrollSpeed: CGFloat = 0.0
//初始化方法
init(tableView: UITableView, displayLink:_DisplayLink, gesture:UILongPressGestureRecognizer,floatImageView:UIImageView) {
self.displayLink = displayLink
self.gesture = gesture
self.floatImageView = floatImageView
self.attachTableView = tableView
super.init()
self.gesture.delegate = self
}
//判断手势是否要begin,用来限制长按区域的
@objc func gestureRecognizerShouldBegin(gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let attachTableView = attachTableView else{
return false
}
return
//返回值代理给TableView本身 attachTableView.lh_gestureRecognizerShouldBegin(gestureRecognizer)
}
}
_DisplayLink类
CADisplayLink
是一个用来在每一帧到来的时候,调整视图状态来生成动画的类。但是,有一点要注意,就是CADisplayLink
必须显示的调用
_link.invalidate()
才能断掉循环引用,相关资源才能得到释放?
那么,能在dealloc
中调用,来保证释放吗?
正常情况下是不行的,因为都没被释放,dealloc
也就不会被调用.
那么,如何破坏掉这种循环引用呢?OC中,我们可以使用NSProxy,详情可见我这篇博客。
Swift中,则可以这么实现一个基于block的displayLink
private class _DisplayLink{
var paused:Bool{
get{
return _link.paused
}
set{
_link.paused = newValue
}
}
private init (_ callback: Void -> Void) {
_callback = callback
_link = CADisplayLink(target: _DisplayTarget(self), selector: #selector(_DisplayTarget._callback))
_link.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSRunLoopCommonModes)
_link.paused = true
}
private let _callback: Void -> Void
private var _link: CADisplayLink!
deinit {
_link.invalidate()
}
}
/// 弱引用CADisplayLink,断掉循环引用
private class _DisplayTarget {
init (_ link: _DisplayLink) {
_link = link
}
weak var _link: _DisplayLink!
@objc func _callback () {
_link?._callback()
}
}
Cell截图
最基础的CoreGraphics
private extension UIView{
/**
Get the screenShot of a UIView
- returns: Image of self
*/
func lh_screenShot()->UIImage?{
UIGraphicsBeginImageContextWithOptions(CGSize(width: frame.width, height: frame.height), false, 0.0)
layer.renderInContext(UIGraphicsGetCurrentContext()!)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext();
return image
}
}
初始化和清除
上文讲到了,我们在设置dragable的时候,会进行必要的设置和初始化工作
private func setupDragable(){
if dragableHelper != nil{
cleanDragable()
}
//初始化手势
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(UITableView.handleLongPress))
addGestureRecognizer(longPressGesture)
//初始化_DisplayLink
let displayLink = _DisplayLink{ [unowned self] in
//displayLink的回调
}
//初始化显示截图的UIImageView
let imageView = UIImageView()
let helper = DragableHelper(tableView:self,displayLink: displayLink, gesture: longPressGesture, floatImageView: imageView)
dragableHelper = helper
}
private func cleanDragable(){
guard let helper = dragableHelper else{
return
}
removeGestureRecognizer(helper.gesture)
dragableHelper = nil
}
处理长按手势
是否开始手势
通过这个代理方法,来限制长按的区域
func lh_gestureRecognizerShouldBegin(gestureRecognizer: UIGestureRecognizer) -> Bool {
let location = gestureRecognizer.locationInView(self)
guard let currentIndexPath = indexPathForRowAtPoint(location),let currentCell = cellForRowAtIndexPath(currentIndexPath) else{
return false
}
let pointInCell = convertPoint(location, toView: currentCell)
//通过代理,检查是否需要触发手势
guard let canDrag = dragableDelegate?.tableView?(self, canDragCellFrom: currentIndexPath, withTouchPoint: pointInCell) else{
return true
}
return canDrag
}
手势开始
整个处理过程如下
- 获取当前Cell
- 截图,作为
subView
添加到tableView
中,设置好初始位置 - 设置transform和alpha,设置阴影
- 隐藏当前Cell
guard let currentIndexPath = indexPathForRowAtPoint(location),let currentCell = cellForRowAtIndexPath(currentIndexPath)else{
return
}
if let selectedRow = indexPathForSelectedRow{
deselectRowAtIndexPath(selectedRow, animated: false)
}
allowsSelection = false
currentCell.highlighted = false
dragableHelper.draggingCell = currentCell
//Configure imageview
let screenShot = currentCell.lh_screenShot()
dragableHelper.floatImageView.image = screenShot
dragableHelper.floatImageView.frame = currentCell.bounds
dragableHelper.floatImageView.center = currentCell.center
dragableHelper.floatImageView.layer.shadowRadius = 5.0
dragableHelper.floatImageView.layer.shadowOpacity = 0.2
dragableHelper.floatImageView.layer.shadowOffset = CGSizeZero
dragableHelper.floatImageView.layer.shadowPath = UIBezierPath(rect: dragableHelper.floatImageView.bounds).CGPath
addSubview(dragableHelper.floatImageView)
UIView.animateWithDuration(0.2, animations: {
dragableHelper.floatImageView.transform = CGAffineTransformMakeScale(1.1, 1.1)
dragableHelper.floatImageView.alpha = 0.5
})
currentCell.hidden = true
手势拖动
拖动的过程中,处理如下
- 调用方法
adjusFloatImageViewCenterY
方法.这个方法会调整截图ImageView
的中心,并且根据位置,决定是否要交换两个Cell - 根据拖动的位置,来设置
dragableHelper.scrollSpeed
.和displayLink
是否停止,当displayLink启动的时候,会以一定的速度,来调整contentOffset.y。从而,看起来显示tableView,向上或则向下滚动
adjusFloatImageViewCenterY(location.y)
dragableHelper.scrollSpeed = 0.0
if contentSize.height > frame.height {
let halfCellHeight = dragableHelper.floatImageView.frame.size.height / 2.0
let cellCenterToTop = dragableHelper.floatImageView.center.y - bounds.origin.y
if cellCenterToTop < halfCellHeight {
dragableHelper.scrollSpeed = 5.0*(cellCenterToTop/halfCellHeight - 1.1)
}
else if cellCenterToTop > frame.height - halfCellHeight {
dragableHelper.scrollSpeed = 5.0*((cellCenterToTop - frame.height)/halfCellHeight + 1.1)
}
dragableHelper.displayLink.paused = (dragableHelper.scrollSpeed == 0)
}
手势结束
- 停止CADisplayLink
- 截图ImageView移动到终止位置,并且移除
allowsSelection = true
dragableHelper.displayLink.paused = true
UIView.animateWithDuration(0.2,
animations: {
dragableHelper.floatImageView.transform = CGAffineTransformIdentity
dragableHelper.floatImageView.alpha = 1.0
dragableHelper.floatImageView.frame = dragableHelper.draggingCell!.frame
},
completion: { (completed) in
dragableHelper.floatImageView.removeFromSuperview()
dragableHelper.draggingCell?.hidden = false
dragableHelper.draggingCell = nil
})
adjusFloatImageViewCenterY 方法
这个方法首先会将截图ImageView移动到触摸中心,然后检查是否需要交换cell。
// MARK: - Private method -
func adjusFloatImageViewCenterY(newY:CGFloat){
guard let floatImageView = dragableHelper?.floatImageView else{
return
}
floatImageView.center.y = min(max(newY, bounds.origin.y), bounds.origin.y + bounds.height)
adjustCellOrderIfNecessary()
}
func adjustCellOrderIfNecessary(){
guard let dragableDelegate = dragableDelegate,floatImageView = dragableHelper?.floatImageView,toIndexPath = indexPathForRowAtPoint(floatImageView.center) else{
return
}
guard let draggingCell = dragableHelper?.draggingCell,dragingIndexPath = indexPathForCell(draggingCell) else{
return
}
guard dragingIndexPath.compare(toIndexPath) != NSComparisonResult.OrderedSame else{
return
}
if let canDragTo = dragableDelegate.tableView?(self, canDragCellTo: toIndexPath){
if !canDragTo {
return
}
}
draggingCell.hidden = true
beginUpdates()
dragableDelegate.tableView(self, dragCellFrom: dragingIndexPath, toIndexPath: toIndexPath)
moveRowAtIndexPath(dragingIndexPath, toIndexPath: toIndexPath)
endUpdates()
}
总结
看到这里了,给个Star吧。项目地址
DraggableTableView
让UITableView支持长按拖动排序