Cheok Yan Cheng
Cheok Yan Cheng

Reputation: 42710

UICollectionViewCell created from XIB will cause flickering during drag and drop

I implement a simple drag and drop sample.

import UIKit

class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {

    private var collectionView: UICollectionView?
    
    var colors: [UIColor] = [
        .link,
        .systemGreen,
        .systemBlue,
        .red,
        .systemOrange,
        .black,
        .systemPurple,
        .systemYellow,
        .systemPink,
        .link,
        .systemGreen,
        .systemBlue,
        .red,
        .systemOrange,
        .black,
        .systemPurple,
        .systemYellow,
        .systemPink
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .vertical
        layout.itemSize = CGSize(width: view.frame.size.width/3.2, height: view.frame.size.width/3.2)
        layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        
        //collectionView?.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
        
        let customCollectionViewCellNib = CustomCollectionViewCell.getUINib()
        collectionView?.register(customCollectionViewCellNib, forCellWithReuseIdentifier: "cell")
        
        collectionView?.delegate = self
        collectionView?.dataSource = self
        collectionView?.backgroundColor = .white
        view.addSubview(collectionView!)
        
        let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressGesture))
        collectionView?.addGestureRecognizer(gesture)
    }

    @objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
        guard let collectionView = collectionView else {
            return
        }
        
        switch gesture.state {
        case .began:
            guard let targetIndexPath = collectionView.indexPathForItem(at: gesture.location(in: self.collectionView)) else {
                return
            }
            collectionView.beginInteractiveMovementForItem(at: targetIndexPath)
        case .changed:
            collectionView.updateInteractiveMovementTargetPosition(gesture.location(in: collectionView))
        case .ended:
            collectionView.endInteractiveMovement()
        default:
            collectionView.cancelInteractiveMovement()
        }
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        collectionView?.frame = view.bounds
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return colors.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
        
        cell.backgroundColor = colors[indexPath.row]
        
        return cell
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: view.frame.size.width/3.2, height: view.frame.size.width/3.2)
    }
    
    func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
        return true
    }
    
    func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
        let item = colors.remove(at: sourceIndexPath.row)
        colors.insert(item, at: destinationIndexPath.row)
    }
}

However, I notice that, if my UICollectionViewCell is created with XIB, it will randomly exhibit flickering behaviour, during drag and drop.


The CustomCollectionViewCell is a pretty straightforward code.

CustomCollectionViewCell.swift

import UIKit

extension UIView {
    static func instanceFromNib() -> Self {
        return getUINib().instantiate(withOwner: self, options: nil)[0] as! Self
    }
    
    static func getUINib() -> UINib {
        return UINib(nibName: String(describing: self), bundle: nil)
    }
}


class CustomCollectionViewCell: UICollectionViewCell {

    override func awakeFromNib() {
        super.awakeFromNib()
        // Initialization code
    }

}

Flickering

By using the following code

let customCollectionViewCellNib = CustomCollectionViewCell.getUINib()
collectionView?.register(customCollectionViewCellNib, forCellWithReuseIdentifier: "cell")

It will have the following random flickering behaviour - https://youtu.be/CbcUAHlRJKI


No flickering

However, if the following code is used instead

collectionView?.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")

Things work fine. There are no flickering behaviour - https://youtu.be/QkV2HlIrXK8


May I know why it is so? How can I avoid the flickering behaviour, when my custom UICollectionView is created from XIB?

Please note that, the flickering behaviour doesn't happen all the time. It happens randomly. It is easier to reproduce the problem using real iPhone device, than simulator.

Here's the complete sample code - https://github.com/yccheok/xib-view-cell-cause-flickering

Upvotes: 3

Views: 1076

Answers (2)

Tarun Tyagi
Tarun Tyagi

Reputation: 10102

While we are rearranging cells in UICollectionView (gesture is active), it handles all of the cell movements for us (without having us to worry about changing dataSource while the rearrange is in flight).

At the end of this rearrange gesture, UICollectionView rightfully expects that we will reflect the change in our dataSource as well which you are doing correctly here.

func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
    let item = colors.remove(at: sourceIndexPath.row)
    colors.insert(item, at: destinationIndexPath.row)
}

Since UICollectionView expects a dataSource update from our side, it performs following steps -

  1. Call our collectionView(_:, moveItemAt:, to:) implementation to provide us a chance to reflect the changes in dataSource.
  2. Call our collectionView(_:, cellForItemAt:) implementation for the destinationIndexPath value from call #1, to re-create a new cell at that indexPath from scratch.
Okay, but why would it perform step 2 even if this is the correct cell to be at that indexPath?

It's because UICollectionView doesn't know for sure whether you actually made those dataSource changes or not. What happens if you don't make those changes? - now your dataSource & UI are out of sync.

In order to make sure that your dataSource changes are correctly reflected in the UI, it has to do this step.

Now when the cell is being re-created, you sometimes see the flicker. Let the UI reload the first time, put a breakpoint in the cellForItemAt: implementation at the first line and rearrange a cell. Right after rearrange completes, your program will pause at that breakpoint and you can see following on the screen. enter image description here


Why does it not happen with UICollectionViewCell class (not XIB)?

It does (as noted by others) - it's less frequent. Using the above steps by putting a breakpoint, you can catch it in that state.


How to solve this?
  1. Get a reference to the cell that's currently being dragged.
  2. Return this instance from cellForItemAt: implementation.
var currentlyBeingDraggedCell: UICollectionViewCell?
var willRecreateCellAtDraggedIndexPath: Bool = false
@objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
    guard let cv = collectionView else { return }
    
    let location = gesture.location(in: cv)
    switch gesture.state {
    case .began:
        guard let targetIndexPath = cv.indexPathForItem(at: location) else { return }
        currentlyBeingDraggedCell = cv.cellForItem(at: targetIndexPath)
        cv.beginInteractiveMovementForItem(at: targetIndexPath)
        
    case .changed:
        cv.updateInteractiveMovementTargetPosition(location)
        
    case .ended:
        willRecreateCellAtDraggedIndexPath = true
        cv.endInteractiveMovement()
    
    default:
        cv.cancelInteractiveMovement()
    }
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    
    if willRecreateCellAtDraggedIndexPath,
       let currentlyBeingDraggedCell = currentlyBeingDraggedCell {
        self.willRecreateCellAtDraggedIndexPath = false
        self.currentlyBeingDraggedCell = nil
        return currentlyBeingDraggedCell
    }
    
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
    
    cell.contentView.backgroundColor = colors[indexPath.item]
        
    return cell
}

Will this solve the problem 100%?

NO. UICollectionView will still remove the cell from it's view hierarchy and ask us for a new cell - we are just providing it with an existing cell instance (that we know is going to be correct according to our own implementation).

You can still catch it in the state where it disappears from UI before appearing again. However this time there's almost no work to be done, so it will be significantly faster and you will see the flickering less often.


BONUS

iOS 15 seems to be working on similar problems via UICollectionView.reconfigureItems APIs. See an explanation in following Twitter thread. enter image description here Whether these improvements will land in rearrange or not, we will have to see.


Other Observations

Your UICollectionViewCell subclass' XIB looks like following enter image description here

However it should look like following (1st one is missing contentView wrapper, you get this by default when you drag a Collection View Cell to the XIB from the View library OR create a UICollectionViewCell subclass with XIB). enter image description here

And your implementation uses -

cell.backgroundColor = colors[indexPath.row]

You should use contentView to do all the UI customization, also note the indexPath.item(vs row) that better fits with cellForItemAt: terminology (There are no differences in these values though). cellForRowAt: & indexPath.row are more suited for UITableView instances.

cell.contentView.backgroundColor = colors[indexPath.item]

UPDATE

Should I use this workaround for my app in production?

NO.

As noted by OP in the comments below -

The proposed workaround has 2 shortcomings.

(1) Missing cell

(2) Wrong content cell.

This is clearly visible in https://www.youtube.com/watch?v=uDRgo0Jczuw Even if you perform explicit currentlyBeingDraggedCell.backgroundColor = colors[indexPath.item] within if block, wrong content cell issue is still there.


Upvotes: 2

paiv
paiv

Reputation: 5601

The flickering is caused by the cell being recreated at its new position. You can try holding to the cell.

(only the relevant code is shown)

// keeps a reference to the cell being dragged
private weak var draggedCell: UICollectionViewCell?
// the flag is set when the dragging completes
private var didInteractiveMovementEnd = false

@objc func handleLongPressGesture(_ gesture: UILongPressGestureRecognizer) {
    switch gesture.state {
    case .began:
        // keep cell reference
        draggedCell = collectionView.cellForItem(at: targetIndexPath)
        collectionView.beginInteractiveMovementForItem(at: targetIndexPath)

    case .ended:
        // reuse the cell in `cellForItem`
        didInteractiveMovementEnd = true
        collectionView.performBatchUpdates {
            collectionView.endInteractiveMovement()
        } completion: { completed in
            self.draggedCell = nil
            self.didInteractiveMovementEnd = false
        }
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    // reuse the dragged cell
    if didInteractiveMovementEnd, let draggedCell = draggedCell {
        return draggedCell
    }
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
    ...
}

Upvotes: 1

Related Questions