ale00 ale00 - 2 months ago 35
iOS Question

Problems reodrering Collection View Cell with custom dimensions

I want to reorder cells in a Collection View with custom size for every cell.

In every cell of the Collection View there is a label with a word.

I set the dimension of every cell with this code:

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

let word = textArray[indexPath.row]

let font = UIFont.systemFont(ofSize: 17)
let fontAttributes = [NSFontAttributeName: font]
var size = (word as NSString).size(attributes: fontAttributes)
size.width = size.width + 2
return size
}


I reorder the Collection View with this code:

override func viewDidLoad() {
super.viewDidLoad()

self.installsStandardGestureForInteractiveMovement = false
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(gesture:)))
self.collectionView?.addGestureRecognizer(panGesture)

}

func handlePanGesture(gesture: UIPanGestureRecognizer) {
switch gesture.state {
case UIGestureRecognizerState.began :
guard let selectedIndexPath = self.collectionView?.indexPathForItem(at: gesture.location(in: self.collectionView)) else {
break
}
collectionView?.beginInteractiveMovementForItem(at: selectedIndexPath)
print("Interactive movement began")

case UIGestureRecognizerState.changed :
collectionView?.updateInteractiveMovementTargetPosition(gesture.location(in: gesture.view!))
print("Interactive movement changed")

case UIGestureRecognizerState.ended :
collectionView?.endInteractiveMovement()
print("Interactive movement ended")

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

override func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {

// Swap values if sorce and destination
let change = textArray[sourceIndexPath.row]


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

// Reload data to recalculate dimensions for the cells
collectionView.reloadData()
}


The view looks like this:
collection view

The problem is that during the reordering, the cells maintain the dimensions of the original cell at a indexPath, so during the the reordering, the view looks like this:
reordering
At the moment I've fixed the problem reloading data at the end of the reordering, to recalculate the right dimensions.
How can I mantain the right dimensions for the cells also during the interactive movement and reorder custom size cells?

Answer

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