Custom Cell Reorder Behavior in Collectionview

Custom Cell Reorder Behavior in CollectionView

I managed to achieve this by creating a subclass of UICollectionView and adding custom handling to interactive movement. While looking at possible hints on how to solve your issue, I've found this tutorial : http://nshint.io/blog/2015/07/16/uicollectionviews-now-have-easy-reordering/.
The most important part there was that interactive reordering can be done not only on UICollectionViewController. The relevant code looks like this :

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()
}
}

This needs to be inside your view controller in which your collection view is placed. I don't know if this will work with UICollectionViewController, some additional tinkering may be needed. What led me to subclassing UICollectionView was realisation that all other related classes/delegate methods are informed only about the first and last index paths (i.e. the source and destination), and there is no information about all the other cells that got rearranged, so It needed to be stopped at the core.

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

I do realize that there is a lot going on there, but I hope everything will be clear in a minute.

  1. Extension on UIView with helper method to get a snapshot of a cell.
  2. Extension on CGPoint with helper method to calculate distance between two points.
  3. SwapDescription helper structure - it is needed to prevent multiple swaps of the same pair of items, which resulted in glitchy animations. Its hashValue method could be improved, but was good enough for the sake of this proof of concept.
  4. beginInteractiveMovementForItemAtIndexPath(indexPath: NSIndexPath) -> Bool - the method called when the movement begins. Everything gets setup here. We get a snapshot of our cell and add it as a subview - this snapshot will be what the user actually drags on screen. The cell itself gets hidden. If you return false from this method, the interactive movement will not begin.
  5. updateInteractiveMovementTargetPosition(targetPosition: CGPoint) - method called after each user movement, which is a lot. We check if the distance from previous point is small enough to swap items - this prevents issue when the user would drag fast across screen and multiple items would get swapped with non-obvious results. If the swap can happen, we check if it is already happening, and if not we swap two items.
  6. endInteractiveMovement(), cancelInteractiveMovement(), cleanup() - after the movement ends, we need to restore our helpers to their default state.
  7. shouldSwap(newPoint: CGPoint) -> Bool - helper method to check if the movement was small enough so we can swap cells.

This is the result it gives :

result

Let me know if this is what you needed and/or if you need me to clarify something.

Here is a demo project.

Problems reordering Collection View Cell with custom dimensions

This has been bugging me all week so I sat down this evening to try and find a solution. I think what you need is a custom layout manager for your collection view, which can dynamically adjust the layout for each cell as the order is changed.

The following code obviously produces something a lot cruder than your layout above, but fundamentally achieves the behaviour you want: crucially moving to the new layout when the cells are reordered occurs "instantaneously" without any interim adjustments required.

The key to it all is the didSet function in the sourceData variable of the view controller. When this array's value is changed (via pressing the sort button - my crude approximation to your gesture recogniser), this automatically triggers a recalculation of the required cell dimensions which then also triggers the layout to clear itself down and recalculate and the collection view to reload the data.

If you have any questions on any of this, let me know. Hope it helps!

UPDATE: OK, I understand what you are trying to do now, and I think the attached updated code gets you there. Instead of using the in-built interaction methods, I think it is easier given the way I have implemented a custom layout manager to use delegation: when the pan gesture recognizer selects a cell, we create a subview based on that word which moves with the gesture. At the same time in the background we remove the word from the data source and refresh the layout. When the user selects a location to place the word, we reverse that process, telling the delegate to insert a word into the data source and refresh the layout. If the user drags the word outside the collection view or to a non-valid location, the word is simply put back where it began (use the cunning technique of storing the original index as the label's tag).

Hope that helps you out!

[Text courtesy of Wikipedia]

import UIKit

class ViewController: UIViewController, bespokeCollectionViewControllerDelegate {

let sourceText : String = "So Midas, king of Lydia, swelled at first with pride when he found he could transform everything he touched to gold; but when he beheld his food grow rigid and his drink harden into golden ice then he understood that this gift was a bane and in his loathing for gold, cursed his prayer"

var sourceData : [String]! {
didSet {
refresh()
}
}
var sortedCVController : UICollectionViewController!
var sortedLayout : bespokeCollectionViewLayout!
var sortButton : UIButton!
var sortDirection : Int = 0

override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.

sortedLayout = bespokeCollectionViewLayout(contentWidth: view.frame.width - 200)
sourceData = {
let components = sourceText.components(separatedBy: " ")
return components
}()

sortedCVController = bespokeCollectionViewController(sourceData: sourceData, collectionViewLayout: sortedLayout, frame: CGRect(origin: CGPoint(x: 100, y: 100), size: CGSize(width: view.frame.width - 200, height: view.frame.height - 200)))
(sortedCVController as! bespokeCollectionViewController).delegate = self
sortedCVController.collectionView!.frame = CGRect(origin: CGPoint(x: 100, y: 100), size: CGSize(width: view.frame.width - 200, height: view.frame.height - 200))

sortButton = {
let sB : UIButton = UIButton(frame: CGRect(origin: CGPoint(x: 25, y: 100), size: CGSize(width: 50, height: 50)))
sB.setTitle("Sort", for: .normal)
sB.setTitleColor(UIColor.black, for: .normal)
sB.addTarget(self, action: #selector(sort), for: .touchUpInside)
sB.layer.borderColor = UIColor.black.cgColor
sB.layer.borderWidth = 1.0
return sB
}()

view.addSubview(sortedCVController.collectionView!)
view.addSubview(sortButton)
}

func refresh() -> Void {
let dimensions : [CGSize] = {
var d : [CGSize] = [CGSize]()
let font = UIFont.systemFont(ofSize: 17)
let fontAttributes = [NSFontAttributeName : font]
for item in sourceData {
let stringSize = ((item + " ") as NSString).size(attributes: fontAttributes)
d.append(CGSize(width: stringSize.width, height: stringSize.height))
}
return d
}()

if self.sortedLayout != nil {
sortedLayout.dimensions = dimensions
if let _ = sortedCVController {
(sortedCVController as! bespokeCollectionViewController).sourceData = sourceData
}
self.sortedLayout.cache.removeAll()
self.sortedLayout.prepare()
if let _ = self.sortedCVController {

self.sortedCVController.collectionView?.reloadData()
}
}
}

func sort() -> Void {
sourceData = sortDirection > 0 ? sourceData.sorted(by: { $0 > $1 }) : sourceData.sorted(by: { $0 < $1 })
sortDirection = sortDirection + 1 > 1 ? 0 : 1
}

func didMoveWord(atIndex: Int) {
sourceData.remove(at: atIndex)
}

func didPlaceWord(word: String, atIndex: Int) {
print(atIndex)
if atIndex >= sourceData.count {
sourceData.append(word)
}
else
{
sourceData.insert(word, at: atIndex)
}

}

func pleaseRefresh() {
refresh()
}

}

protocol bespokeCollectionViewControllerDelegate {
func didMoveWord(atIndex: Int) -> Void
func didPlaceWord(word: String, atIndex: Int) -> Void
func pleaseRefresh() -> Void
}

class bespokeCollectionViewController : UICollectionViewController {

var sourceData : [String]
var movingLabel : UILabel!
var initialOffset : CGPoint!
var delegate : bespokeCollectionViewControllerDelegate!

init(sourceData: [String], collectionViewLayout: bespokeCollectionViewLayout, frame: CGRect) {
self.sourceData = sourceData
super.init(collectionViewLayout: collectionViewLayout)

self.collectionView = UICollectionView(frame: frame, collectionViewLayout: collectionViewLayout)
self.collectionView?.backgroundColor = UIColor.white
self.collectionView?.layer.borderColor = UIColor.black.cgColor
self.collectionView?.layer.borderWidth = 1.0

self.installsStandardGestureForInteractiveMovement = false

let pangesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(gesture:)))
self.collectionView?.addGestureRecognizer(pangesture)
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func handlePanGesture(gesture: UIPanGestureRecognizer) {
guard let _ = delegate else { return }

switch gesture.state {
case UIGestureRecognizerState.began:
guard let selectedIndexPath = self.collectionView?.indexPathForItem(at: gesture.location(in: self.collectionView)) else { break }
guard let selectedCell : UICollectionViewCell = self.collectionView?.cellForItem(at: selectedIndexPath) else { break }
initialOffset = gesture.location(in: selectedCell)

let index : Int = {
var i : Int = 0
for sectionCount in 0..<selectedIndexPath.section {
i += (self.collectionView?.numberOfItems(inSection: sectionCount))!
}
i += selectedIndexPath.row
return i
}()

movingLabel = {
let mL : UILabel = UILabel()
mL.font = UIFont.systemFont(ofSize: 17)
mL.frame = selectedCell.frame
mL.textColor = UIColor.black
mL.text = sourceData[index]
mL.layer.borderColor = UIColor.black.cgColor
mL.layer.borderWidth = 1.0
mL.backgroundColor = UIColor.white
mL.tag = index
return mL
}()

self.collectionView?.addSubview(movingLabel)

delegate.didMoveWord(atIndex: index)
case UIGestureRecognizerState.changed:
if let _ = movingLabel {
movingLabel.frame.origin = CGPoint(x: gesture.location(in: self.collectionView).x - initialOffset.x, y: gesture.location(in: self.collectionView).y - initialOffset.y)
}

case UIGestureRecognizerState.ended:
print("Interactive movement ended")
if let selectedIndexPath = self.collectionView?.indexPathForItem(at: gesture.location(in: self.collectionView)) {
guard let _ = movingLabel else { return }

let index : Int = {
var i : Int = 0
for sectionCount in 0..<selectedIndexPath.section {
i += (self.collectionView?.numberOfItems(inSection: sectionCount))!
}
i += selectedIndexPath.row
return i
}()

delegate.didPlaceWord(word: movingLabel.text!, atIndex: index)
UIView.animate(withDuration: 0.25, animations: {
self.movingLabel.alpha = 0
self.movingLabel.removeFromSuperview()
}, completion: { _ in
self.movingLabel = nil })
}
else
{
if let _ = movingLabel {
delegate.didPlaceWord(word: movingLabel.text!, atIndex: movingLabel.tag)
UIView.animate(withDuration: 0.25, animations: {
self.movingLabel.alpha = 0
self.movingLabel.removeFromSuperview()
}, completion: { _ in
self.movingLabel = nil })
}
}

default:
collectionView?.cancelInteractiveMovement()
print("Interactive movement canceled")
}
}

override func numberOfSections(in collectionView: UICollectionView) -> Int {
guard !(self.collectionViewLayout as! bespokeCollectionViewLayout).cache.isEmpty else { return 0 }

return (self.collectionViewLayout as! bespokeCollectionViewLayout).cache.last!.indexPath.section + 1
}

override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
guard !(self.collectionViewLayout as! bespokeCollectionViewLayout).cache.isEmpty else { return 0 }

var n : Int = 0
for element in (self.collectionViewLayout as! bespokeCollectionViewLayout).cache {
if element.indexPath.section == section {
if element.indexPath.row > n {
n = element.indexPath.row
}
}
}
print("Section \(section) has \(n) elements")
return n + 1
}

override func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
let change = sourceData[sourceIndexPath.row]

sourceData.remove(at: sourceIndexPath.row)
sourceData.insert(change, at: destinationIndexPath.row)
}

override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")

let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)

// Clean
for subview in cell.subviews {
subview.removeFromSuperview()
}

let label : UILabel = {
let l : UILabel = UILabel()
l.font = UIFont.systemFont(ofSize: 17)
l.frame = CGRect(origin: CGPoint.zero, size: cell.frame.size)
l.textColor = UIColor.black

let index : Int = {
var i : Int = 0
for sectionCount in 0..<indexPath.section {
i += (self.collectionView?.numberOfItems(inSection: sectionCount))!
}
i += indexPath.row
return i
}()

l.text = sourceData[index]
return l
}()

cell.addSubview(label)

return cell
}

}

class bespokeCollectionViewLayout : UICollectionViewLayout {

var cache : [UICollectionViewLayoutAttributes] = [UICollectionViewLayoutAttributes]()
let contentWidth: CGFloat
var dimensions : [CGSize]!

init(contentWidth: CGFloat) {
self.contentWidth = contentWidth

super.init()
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func prepare() -> Void {
guard self.dimensions != nil else { return }
if cache.isEmpty {
var xOffset : CGFloat = 0
var yOffset : CGFloat = 0

var rowCount = 0
var wordCount : Int = 0

while wordCount < dimensions.count {
let nextRowCount : Int = {
var totalWidth : CGFloat = 0
var numberOfWordsInRow : Int = 0

while totalWidth < contentWidth && wordCount < dimensions.count {
if totalWidth + dimensions[wordCount].width >= contentWidth {
break
}
else
{
totalWidth += dimensions[wordCount].width
wordCount += 1
numberOfWordsInRow += 1
}

}
return numberOfWordsInRow
}()

var columnCount : Int = 0
for count in (wordCount - nextRowCount)..<wordCount {
let index : IndexPath = IndexPath(row: columnCount, section: rowCount)
let newAttribute : UICollectionViewLayoutAttributes = UICollectionViewLayoutAttributes(forCellWith: index)
let cellFrame : CGRect = CGRect(origin: CGPoint(x: xOffset, y: yOffset), size: dimensions[count])
newAttribute.frame = cellFrame
cache.append(newAttribute)

xOffset += dimensions[count].width
columnCount += 1
}

xOffset = 0
yOffset += dimensions[0].height

rowCount += 1

}
}
}

override var collectionViewContentSize: CGSize {
guard !cache.isEmpty else { return CGSize(width: 100, height: 100) }
return CGSize(width: self.contentWidth, height: cache.last!.frame.maxY)
}

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var layoutAttributes = [UICollectionViewLayoutAttributes]()
if cache.isEmpty {
self.prepare()
}
for attributes in cache {
if attributes.frame.intersects(rect) {
layoutAttributes.append(attributes)
}
}
return layoutAttributes
}
}


Related Topics



Leave a reply



Submit