首页 > 代码库 > 让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是一个面向协议的语言。

本文采用extensionprotocol的方式,来设计接口。

定义一个协议

@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
}

手势开始

整个处理过程如下

  1. 获取当前Cell
  2. 截图,作为subView添加到tableView中,设置好初始位置
  3. 设置transform和alpha,设置阴影
  4. 隐藏当前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

手势拖动

拖动的过程中,处理如下

  1. 调用方法adjusFloatImageViewCenterY方法.这个方法会调整截图ImageView的中心,并且根据位置,决定是否要交换两个Cell
  2. 根据拖动的位置,来设置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)
}

手势结束

  1. 停止CADisplayLink
  2. 截图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

<script type="text/javascript"> $(function () { $(‘pre.prettyprint code‘).each(function () { var lines = $(this).text().split(‘\n‘).length; var $numbering = $(‘
    ‘).addClass(‘pre-numbering‘).hide(); $(this).addClass(‘has-numbering‘).parent().append($numbering); for (i = 1; i <= lines; i++) { $numbering.append($(‘
  • ‘).text(i)); }; $numbering.fadeIn(1700); }); }); </script>

    让UITableView支持长按拖动排序