so I'm building my own Drag&Drop system. For that to work, I need a way to create "gaps" between cells in collectionViews, where the user hovers dragged items.

I'm trying out stuff right now and got a basic demo working using a custom flow layout that moves the cells around.

The demo I did consists of a simple collectionView(using IGListKit but that doesn't matter for this problem, I'm pretty sure) and a UIPanGestureRecognizer that allows you to pan over the collectionView to create a gap beneath your finger.

I achieve this by invalidating the layout every time the pan gesture reconizer changes. It's working that way, but when I simultaneously scroll while panning over the collectionView, the cells seem to glitch a little bit. It looks like this (it looks as if the rendering of the cells can't keep up):

enter image description here

I'm pretty sure the problem is within the makeAGapfunction that contains this call:

collectionView?.performBatchUpdates({
            self.invalidateLayout()
            self.collectionView?.layoutIfNeeded()
}, completion: nil)

If I don't animate the invalidation, like this

self.invalidateLayout()
self.collectionView?.layoutIfNeeded()

The glitch does not appear at all. It has something to do with the animation. Do you have any ideas?

Thanks

PS: Here's the code (there's more IGListKit stuff but that's not important):

class MyCustomLayout: UICollectionViewFlowLayout {

    fileprivate var cellPadding: CGFloat = 6

    fileprivate var cache = [UICollectionViewLayoutAttributes]()

    fileprivate var contentHeight: CGFloat = 300
    fileprivate var contentWidth: CGFloat = 0

    var gap: IndexPath? = nil
    var gapPosition: CGPoint? = nil

    override var collectionViewContentSize: CGSize {
        return CGSize(width: contentWidth, height: contentHeight)
    }

    override func prepare() {
        // cache contains the cached layout attributes
        guard cache.isEmpty, let collectionView = collectionView else {
            return
        }

        // I'm using IGListKit, so every cell is in its own section in my case
        for section in 0..<collectionView.numberOfSections {
            let indexPath = IndexPath(item: 0, section: section)
            // If a gap has been set, just make the current offset (contentWidth) bigger to
            // simulate a "missing" item, which creates a gap
            if let gapPosition = self.gapPosition {
                if gapPosition.x >= (contentWidth - 100) && gapPosition.x < (contentWidth + 100) {
                    contentWidth += 100
                }
            }

            // contentWidth is used as x origin
            let frame = CGRect(x: contentWidth, y: 10, width: 100, height: contentHeight)
            contentWidth += frame.width + cellPadding

            let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
            attributes.frame = frame
            cache.append(attributes)
        }
    }

    public func makeAGap(at indexPath: IndexPath, position: CGPoint) {
        gap = indexPath
        self.cache = []
        self.contentWidth = 0
        self.gapPosition = position


        collectionView?.performBatchUpdates({
            self.invalidateLayout()
            self.collectionView?.layoutIfNeeded()
        }, completion: nil)

        //invalidateLayout() // Using this, the glitch does NOT appear
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {

        var visibleLayoutAttributes = [UICollectionViewLayoutAttributes]()

        // Loop through the cache and look for items in the rect
        for attributes in cache {
            if attributes.frame.intersects(rect) {
                visibleLayoutAttributes.append(attributes)
            }
        }
        return visibleLayoutAttributes
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return cache[indexPath.item]
    }
}

class ViewController: UIViewController {

    /// IGListKit stuff: Data for self.collectionView ("its cells", which represent the rows)
    public var data: [String] {
        return (0...100).compactMap { "\($0)" }
    }

    /// This collectionView will consist of cells, that each have their own collectionView.
    private lazy var collectionView: UICollectionView = {
        let layout = MyCustomLayout()
        layout.scrollDirection = .horizontal
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.backgroundColor = UIColor(hex: 0xeeeeee)

        adapter.collectionView = collectionView
        adapter.dataSource = self
        view.addSubview(collectionView)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.frame = CGRect(x: 0, y: 50, width: 1000, height: 300)

        return collectionView
    }()

    /// IGListKit stuff. Data manager for the collectionView
    private lazy var adapter: ListAdapter = {
        let adapter = ListAdapter(updater: ListAdapterUpdater(), viewController: self, workingRangeSize: 0)
        return adapter
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        _ = collectionView

        let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(pan:)))
        pan.maximumNumberOfTouches = 1
        pan.delegate = self
        view.addGestureRecognizer(pan)
    }

    @objc private func handlePan(pan: UIPanGestureRecognizer) {
        guard let indexPath = collectionView.indexPathForItem(at: pan.location(in: collectionView)) else {
            return
        }

        (collectionView.collectionViewLayout as? MyCustomLayout)?.makeAGap(at: indexPath, position: pan.location(in: collectionView))
    }
}

extension ViewController: ListAdapterDataSource, UIGestureRecognizerDelegate {
    func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
        return data as [ListDiffable]
    }

    func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
        return RowListSection()
    }

    func emptyView(for listAdapter: ListAdapter) -> UIView? {
        return nil
    }

    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
}

1 Answers

0
Quantm On

Okay, this keeps happening. I spend hours and hours trying to fix a problem, give up to ask a question here and minutes later I find the/a solution.

So, I can't entirely explain why this glitch is occurring, but it seems like it's got to do with the drawing of the screen. I added a CADisplayLinkto synchronize the layout invalidation with the refresh rate of the screen and now the glitch is gone (code snippet below for anyone interested).

However, I would love to know what exactly is happening there and why synchronizing the invalidation fixes the drawing glitch. I'm gonna look into it as well but I'm not that experienced so if anyone knows about stuff like this, I'd highly appreciate a new (more detailed) answer to this question :)

override func viewDidLoad() {
        super.viewDidLoad()
        _ = collectionView

        let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(pan:)))
        pan.maximumNumberOfTouches = 1
        pan.delegate = self
        view.addGestureRecognizer(pan)

        let displayLink = CADisplayLink(target: self, selector: #selector(update))
        displayLink.add(to: .current, forMode: .common)
    }

    var indexPath: IndexPath? = nil
    var position: CGPoint? = nil

    @objc private func update() {
        if let indexPath = self.indexPath, let position = self.position {
            (collectionView.collectionViewLayout as? MyCustomLayout)?.makeAGap(at: indexPath, position: position)
        }
    }

    @objc private func handlePan(pan: UIPanGestureRecognizer) {
        guard let indexPath = collectionView.indexPathForItem(at: pan.location(in: collectionView)) else {
            return
        }

        // buffer the values
        self.indexPath = indexPath
        position = pan.location(in: collectionView)
}