CollectionView 中的自定义单元格重新排序行为

Posted

技术标签:

【中文标题】CollectionView 中的自定义单元格重新排序行为【英文标题】:Custom Cell Reorder Behavior in CollectionView 【发布时间】:2016-08-26 18:01:05 【问题描述】:

我可以像这样重新排序我的 collectionView:

但是,我不想让所有单元格水平移动,而是希望使用以下行为进行交换(即单元格的洗牌次数更少):

我一直在玩下面的委托方法:

func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath

但是,我不确定如何实现自定义重新排序行为。

【问题讨论】:

您找到答案了吗?我也被困在这个问题上。 【参考方案1】:

我设法通过创建UICollectionView 的子类并将自定义处理添加到交互式移动来实现这一点。在查看有关如何解决您的问题的可能提示时,我发现了本教程:http://nshint.io/blog/2015/07/16/uicollectionviews-now-have-easy-reordering/。 最重要的部分是不仅可以在UICollectionViewController 上进行交互式重新排序。相关代码如下所示:

var longPressGesture : UILongPressGestureRecognizer!

override func viewDidLoad() 
    super.viewDidLoad()

    // rest of setup        

    longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(ViewController.handleLongGesture(_:)))
    self.collectionView?.addGestureRecognizer(longPressGesture)



func handleLongGesture(gesture: UILongPressGestureRecognizer) 

    switch(gesture.state) 

    case UIGestureRecognizerState.Began:
        guard let selectedIndexPath = self.collectionView?.indexPathForItemAtPoint(gesture.locationInView(self.collectionView)) else 
            break
        
        collectionView?.beginInteractiveMovementForItemAtIndexPath(selectedIndexPath)
    case UIGestureRecognizerState.Changed:
        collectionView?.updateInteractiveMovementTargetPosition(gesture.locationInView(gesture.view!))
    case UIGestureRecognizerState.Ended:
        collectionView?.endInteractiveMovement()
    default:
        collectionView?.cancelInteractiveMovement()
    

这需要在放置集合视图的视图控制器内。我不知道这是否适用于UICollectionViewController,可能需要进行一些额外的修改。导致我对UICollectionView 进行子类化的原因是意识到所有其他相关的类/委托方法仅被告知第一个和最后一个索引路径(即源和目标),并且没有关于重新排列的所有其他单元格的信息,所以它需要从核心停止。

SwappingCollectionView.swift

import UIKit

extension UIView 
    func snapshot() -> UIImage 
        UIGraphicsBeginImageContext(self.bounds.size)
        self.layer.renderInContext(UIGraphicsGetCurrentContext()!)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return image
    


extension CGPoint 
    func distanceToPoint(p:CGPoint) -> CGFloat 
        return sqrt(pow((p.x - x), 2) + pow((p.y - y), 2))
    


struct SwapDescription : Hashable 
    var firstItem : Int
    var secondItem : Int

    var hashValue: Int 
        get 
            return (firstItem * 10) + secondItem
        
    


func ==(lhs: SwapDescription, rhs: SwapDescription) -> Bool 
    return lhs.firstItem == rhs.firstItem && lhs.secondItem == rhs.secondItem


class SwappingCollectionView: UICollectionView 

    var interactiveIndexPath : NSIndexPath?
    var interactiveView : UIView?
    var interactiveCell : UICollectionViewCell?
    var swapSet : Set<SwapDescription> = Set()
    var previousPoint : CGPoint?

    static let distanceDelta:CGFloat = 2 // adjust as needed

    override func beginInteractiveMovementForItemAtIndexPath(indexPath: NSIndexPath) -> Bool 

        self.interactiveIndexPath = indexPath

        self.interactiveCell = self.cellForItemAtIndexPath(indexPath)

        self.interactiveView = UIImageView(image: self.interactiveCell?.snapshot())
        self.interactiveView?.frame = self.interactiveCell!.frame

        self.addSubview(self.interactiveView!)
        self.bringSubviewToFront(self.interactiveView!)

        self.interactiveCell?.hidden = true

        return true
    

    override func updateInteractiveMovementTargetPosition(targetPosition: CGPoint) 

        if (self.shouldSwap(targetPosition)) 

            if let hoverIndexPath = self.indexPathForItemAtPoint(targetPosition), let interactiveIndexPath = self.interactiveIndexPath 

                let swapDescription = SwapDescription(firstItem: interactiveIndexPath.item, secondItem: hoverIndexPath.item)

                if (!self.swapSet.contains(swapDescription)) 

                    self.swapSet.insert(swapDescription)

                    self.performBatchUpdates(
                        self.moveItemAtIndexPath(interactiveIndexPath, toIndexPath: hoverIndexPath)
                        self.moveItemAtIndexPath(hoverIndexPath, toIndexPath: interactiveIndexPath)
                        , completion: (finished) in
                            self.swapSet.remove(swapDescription)                                
                            self.dataSource?.collectionView(self, moveItemAtIndexPath: interactiveIndexPath, toIndexPath: hoverIndexPath)
                            self.interactiveIndexPath = hoverIndexPath

                    )
                
            
        

        self.interactiveView?.center = targetPosition
        self.previousPoint = targetPosition
    

    override func endInteractiveMovement() 
        self.cleanup()
    

    override func cancelInteractiveMovement() 
        self.cleanup()
    

    func cleanup() 
        self.interactiveCell?.hidden = false
        self.interactiveView?.removeFromSuperview()
        self.interactiveView = nil
        self.interactiveCell = nil
        self.interactiveIndexPath = nil
        self.previousPoint = nil
        self.swapSet.removeAll()
    

    func shouldSwap(newPoint: CGPoint) -> Bool 
        if let previousPoint = self.previousPoint 
            let distance = previousPoint.distanceToPoint(newPoint)
            return distance < SwappingCollectionView.distanceDelta
        

        return false
    

我确实意识到那里发生了很多事情,但我希望一切都会在一分钟内弄清楚。

    UIView 上的扩展,使用辅助方法获取单元格的快照。 CGPoint 上的扩展,使用辅助方法计算两点之间的距离。 SwapDescription 辅助结构 - 需要防止同一对项目的多次交换,这会导致动画故障。它的hashValue 方法可以改进,但对于这个概念证明来说已经足够了。 beginInteractiveMovementForItemAtIndexPath(indexPath: NSIndexPath) -&gt; Bool - 运动开始时调用的方法。一切都在这里设置。我们得到我们单元格的快照并将其添加为子视图 - 此快照将是用户实际在屏幕上拖动的内容。单元格本身被隐藏。如果您从此方法返回false,则不会开始交互移动。 updateInteractiveMovementTargetPosition(targetPosition: CGPoint) - 每次用户移动后调用的方法,很多。我们检查与前一点的距离是否足够小以交换项目 - 这可以防止用户在屏幕上快速拖动并且多个项目会以不明显的结果交换时出现问题。如果交换可以发生,我们检查它是否已经发生,如果没有,我们交换两个项目。 endInteractiveMovement()cancelInteractiveMovement()cleanup() - 运动结束后,我们需要将助手恢复到默认状态。 shouldSwap(newPoint: CGPoint) -&gt; Bool - 检查移动是否足够小以便我们可以交换单元格的辅助方法。

这是它给出的结果:

如果这是您需要的和/或如果您需要我澄清一些事情,请告诉我。

这是demo project。

【讨论】:

这是一个很棒的解决方案和解释。我现在面临的一个问题是我的“collectionView(moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath)”方法不再被调用。这是我在单元格更改后处理数据的地方。谢谢。跨度> 哎呀,我知道我忘记了一些事情!我已经编辑了我的答案 - 重要的事情发生在 performBatchUpdates 的完成块中 - 调用数据源上提到的方法。 太棒了,现在它被调用了。另外(关于处理数据),我现在只是将源中的项目与目标交换,而不是在我的数组的 sourceIndexPath.row 中删除和重新插入对象。谢谢! 好吧,我还没有进行如此彻底的测试;)您可以尝试使动画看起来更自然的一件事(特别是当距离很长时)是替换 @987654343 所做的自动动画@ 与UIView.animateWithDuration。例如,这将让您设置 CurveEaseInOut 选项 - 该项目将逐渐加速然后减速。这感觉不自然然后线性运动。不过,这需要编写更多代码。 在 Objective C 中是否可用【参考方案2】:

@Losiowaty 解决方案的Swift 5 解决方案:

var longPressGesture : UILongPressGestureRecognizer!

override func viewDidLoad()

    super.viewDidLoad()

    // rest of setup
    longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongGesture))
    self.collectionView?.addGestureRecognizer(longPressGesture)


@objc func handleLongGesture(gesture: UILongPressGestureRecognizer)

    switch(gesture.state)
    
    case UIGestureRecognizerState.began:
        guard let selectedIndexPath = self.collectionView?.indexPathForItem(at: gesture.location(in: self.collectionView)) else 
            break
        
        collectionView?.beginInteractiveMovementForItem(at: selectedIndexPath)
    case UIGestureRecognizerState.changed:
        collectionView?.updateInteractiveMovementTargetPosition(gesture.location(in: gesture.view!))
    case UIGestureRecognizerState.ended:
        collectionView?.endInteractiveMovement()
    default:
        collectionView?.cancelInteractiveMovement()
    



import UIKit

extension UIView 
    func snapshot() -> UIImage 
        UIGraphicsBeginImageContext(self.bounds.size)
        self.layer.render(in: UIGraphicsGetCurrentContext()!)
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return image!
    


extension CGPoint 
    func distanceToPoint(p:CGPoint) -> CGFloat 
        return sqrt(pow((p.x - x), 2) + pow((p.y - y), 2))
    


struct SwapDescription : Hashable 
    var firstItem : Int
    var secondItem : Int

    var hashValue: Int 
        get 
            return (firstItem * 10) + secondItem
        
    


func ==(lhs: SwapDescription, rhs: SwapDescription) -> Bool 
    return lhs.firstItem == rhs.firstItem && lhs.secondItem == rhs.secondItem


class SwappingCollectionView: UICollectionView 

    var interactiveIndexPath : IndexPath?
    var interactiveView : UIView?
    var interactiveCell : UICollectionViewCell?
    var swapSet : Set<SwapDescription> = Set()
    var previousPoint : CGPoint?

    static let distanceDelta:CGFloat = 2 // adjust as needed

    override func beginInteractiveMovementForItem(at indexPath: IndexPath) -> Bool
    
        self.interactiveIndexPath = indexPath

        self.interactiveCell = self.cellForItem(at: indexPath)

        self.interactiveView = UIImageView(image: self.interactiveCell?.snapshot())
        self.interactiveView?.frame = self.interactiveCell!.frame

        self.addSubview(self.interactiveView!)
        self.bringSubviewToFront(self.interactiveView!)

        self.interactiveCell?.isHidden = true

        return true
    

    override func updateInteractiveMovementTargetPosition(_ targetPosition: CGPoint) 

        if (self.shouldSwap(newPoint: targetPosition))
        
            if let hoverIndexPath = self.indexPathForItem(at: targetPosition), let interactiveIndexPath = self.interactiveIndexPath 

                let swapDescription = SwapDescription(firstItem: interactiveIndexPath.item, secondItem: hoverIndexPath.item)

                if (!self.swapSet.contains(swapDescription)) 

                    self.swapSet.insert(swapDescription)

                    self.performBatchUpdates(
                        self.moveItem(at: interactiveIndexPath as IndexPath, to: hoverIndexPath)
                        self.moveItem(at: hoverIndexPath, to: interactiveIndexPath)
                        , completion: (finished) in
                            self.swapSet.remove(swapDescription)
                            self.dataSource?.collectionView?(self, moveItemAt: interactiveIndexPath, to: hoverIndexPath)
                            self.interactiveIndexPath = hoverIndexPath

                    )
                
            
        

        self.interactiveView?.center = targetPosition
        self.previousPoint = targetPosition
    

    override func endInteractiveMovement() 
        self.cleanup()
    

    override func cancelInteractiveMovement() 
        self.cleanup()
    

    func cleanup() 
        self.interactiveCell?.isHidden = false
        self.interactiveView?.removeFromSuperview()
        self.interactiveView = nil
        self.interactiveCell = nil
        self.interactiveIndexPath = nil
        self.previousPoint = nil
        self.swapSet.removeAll()
    

    func shouldSwap(newPoint: CGPoint) -> Bool 
        if let previousPoint = self.previousPoint 
            let distance = previousPoint.distanceToPoint(p: newPoint)
            return distance < SwappingCollectionView.distanceDelta
        

        return false
    

【讨论】:

你真的不需要“自我”。对于那里的每一行代码...... 我知道,只是我喜欢的风格。随意调整它以适应您自己喜欢的风格。【参考方案3】:

如果您想像这样跟踪被拖动的单元格的数量:

@objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) 

    switch gesture.state 
    case .began:
        guard let targetIndexPath = reorderCollectionView.indexPathForItem(at: gesture.location(in: reorderCollectionView)) else 
            return
        
        reorderCollectionView.beginInteractiveMovementForItem(at: targetIndexPath)
   

    case .changed:
        reorderCollectionView.updateInteractiveMovementTargetPosition(gesture.location(in: reorderCollectionView))
        reorderCollectionView.performBatchUpdates 
            self.reorderCollectionView.visibleCells.compactMap  $0 as? ReorderCell
                .enumerated()
                .forEach  (index, cell)  in
                    cell.countLabel.text = "\(index + 1)"
                
        
       
        
    case .ended:
        reorderCollectionView.endInteractiveMovement()
    default:
        reorderCollectionView.cancelInteractiveMovement()
    

 

【讨论】:

以上是关于CollectionView 中的自定义单元格重新排序行为的主要内容,如果未能解决你的问题,请参考以下文章

使用自定义 collectionView 布局更改单元格大小时如何使用 ScrollToItem(at:)

更改单元格中的数据后,UITableView 中的自定义单元格将无法正确重新加载

UICollectionView - 不要添加新的自定义单元格

如何在 collectionView 单元格中设置 NSLayoutConstraint 常量?

在 CollectionView 中添加第二个自定义单元格 - Swift

重新加载collectionview单元格而不是节标题