Multiple calls to scrollToItem(at:at:animated:) in quick succession causes scrolling glitch in UICollectionView

120 Views Asked by At

In my setup I have a horizontal collection view with chip style, on initial load the first chip is selected by default and corresponding to the chip an API call is made and it's data is loaded into a vertical UICollectionView right below.

On tapping any of the chips in the UICollectionView an API call is initiated and the selected cell is scrolled into the centre of collectionview using scrollToItem(at: selectedChipIndexPath, at: .centerHorizontally , animated: true). Plus I have to reload the the last selected & current selected indexPath to indicate that new cell is selected.

Everything works fine if user taps on the chips, looks at the below loaded data and then taps on the next chip.

But if the user taps on consecutive chips quickly then scrollToItem(at: selectedChipIndexPath, at: .centerHorizontally , animated: true) glitches the scroll animation some times causing an unpleasant UX experience.

Here is my code:

horizontalCollectionView.reloadItems(at: [lastSelectedIndexPath, selectedChipIndexPath])

// API Call
/*
   This is an async call, which ones finishes informs the ViewController to the update it's vertical collection view with new data
*/
viewModel.fetchProductData(forPositon: selectedChipIndexPath)

DispatchQueue.main. asyncAfter ( .now() + .seconds(0.4)) {
   horizontalCollectionView.scrollToItem(at: selectedChipIndexPath, at: .centerHorizontally , animated: true)
}

If I don't delay the scrolling by few milliseconds then scroll animation of collection view starts to glitch very badly.

So can anyone point me in the right direction on how can I handle or queue up multiple scrollToItem(at:at:animated:) calls so that my collectionView scroll animation doesn't glitch.

I did try to wrap scrollToItem(at:at:animated:) in batch updates but it didn't work.

Update 1:

Here's how my UI looks mostly

Horizontal Collection View : For Product Categories

Vertical Collection View : For Products themselves

Visual Representation on how my UI looks

Update 2:

The solution provided by DonMag works but it introduces another problem, according to my designs I have to change the font type from regular to bold for the UILabel inside the chips when selected, doing this causes the label frame size to increase and for most text the label gets clipped since I am just manipulating the views instead of reloading the cells themselves.

I tried to invalidate the CollectionView Flow Layout. But it again brought me to the same problem of the scrolling glitch.

1

There are 1 best solutions below

7
DonMag On BEST ANSWER

Without seeing your full code, the issue is likely being caused by your repeated calls to .reloadItems(...)

If you are doing that only to change the appearance of the selected cell, it is unnecessary.

Instead, let the collection view track the selected cell (as it does by default), and have your cell modify its own appearance based on its selected state.

For example - if we create a cell with a single label in a typical manner like this:

class SomeCell: UICollectionViewCell {
    
    static let identifier: String = "SomeCell"
    
    let theLabel: UILabel = {
        let v = UILabel()
        v.textAlignment = .center
        return v
    }()
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() -> Void {
        theLabel.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(theLabel)
        let g = contentView.layoutMarginsGuide
        NSLayoutConstraint.activate([
            theLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
            theLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
            theLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
            theLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0),
        ])


        // default "un-selected" appearance
        contentView.layer.borderColor = UIColor(white: 0.9, alpha: 1.0).cgColor
        contentView.layer.borderWidth = 1.0
    }

    override func layoutSubviews() {
        contentView.layer.cornerRadius = contentView.bounds.height * 0.5
    }

    // the collection view will tell us whether the cell is selected or not
    //  so update the cell appearance here
    override var isSelected: Bool {
        didSet {
            contentView.backgroundColor = isSelected ? UIColor(red: 0.5, green: 1.0, blue: 0.5, alpha: 1.0) : .systemBackground
            contentView.layer.borderWidth = isSelected ? 0.0 : 1.0
        }
    }
}

We've overridden var isSelected: Bool ... the collection view will set that property when needed, and now our cell updates its "selected / un-selected" appearance automatically.

No need to call .reloadItems

Example view controller with horizontal collection view using the above cell - note that I'm creating 4 "repeating sets" of the sample tags (prefixed by 1-4), so we have plenty of cells to scroll:

class SomeViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate {
    
    var collectionView: UICollectionView!
    
    var sampleTags: [String] = [
        "Pomogranate",
        "Banana, Guava, Sapota",
        "Oranges, Mosambi",
        "Apple",
        "Blueberry",
        "Pineapple",
        "Strawberry",
    ]
    var tagsData: [String] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // let's create several sets of the sample tags
        //  so we have plenty of cells to scroll
        for i in 1...4 {
            sampleTags.forEach { s in
                tagsData.append("\(i): \(s)")
            }
        }
        
        let fl = UICollectionViewFlowLayout()
        fl.scrollDirection = .horizontal
        fl.estimatedItemSize = .init(width: 80.0, height: 40.0)
        
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: fl)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(collectionView)
        
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            collectionView.heightAnchor.constraint(equalToConstant: 50.0),
        ])
        
        collectionView.register(SomeCell.self, forCellWithReuseIdentifier: SomeCell.identifier)
        collectionView.dataSource = self
        collectionView.delegate = self
        
        collectionView.layer.borderColor = UIColor.red.cgColor
        collectionView.layer.borderWidth = 1.0
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return tagsData.count
    }
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let c = collectionView.dequeueReusableCell(withReuseIdentifier: SomeCell.identifier, for: indexPath) as! SomeCell
        c.theLabel.text = tagsData[indexPath.item]
        return c
    }
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        
        // scroll the selected cell to the center
        collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
        
        DispatchQueue.main.async {
            self.viewModel.fetchProductData(forPositon: indexPath)
        }
        
    }
}

Edit - additional requirement: cell font change to bold when selected, without causing cell size change...

The easiest way to do that is to add two labels to the cell - one with Bold font, the other with Regular font.

Setup the constraints so the Bold label will control the width and the Regular label will be slightly wider than necessary.

Show the Bold label when selected, show the Regular when not.

So, slightly modified cell class from above:

class SomeCell: UICollectionViewCell {
    
    static let identifier: String = "SomeCell"
    
    public var title: String = "" {
        didSet {
            theLabel.text = title
            theBoldLabel.text = title
        }
    }
    
    private let theLabel = UILabel()
    private let theBoldLabel = UILabel()

    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    private func commonInit() -> Void {
        [theLabel, theBoldLabel].forEach { v in
            v.textAlignment = .center
            v.translatesAutoresizingMaskIntoConstraints = false
            contentView.addSubview(v)
        }
        let g = contentView.layoutMarginsGuide
        NSLayoutConstraint.activate([
            theBoldLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
            theBoldLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
            theBoldLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
            theBoldLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0),

            theLabel.topAnchor.constraint(equalTo: theBoldLabel.topAnchor, constant: 0.0),
            theLabel.leadingAnchor.constraint(equalTo: theBoldLabel.leadingAnchor, constant: 0.0),
            theLabel.trailingAnchor.constraint(equalTo: theBoldLabel.trailingAnchor, constant: 0.0),
            theLabel.bottomAnchor.constraint(equalTo: theBoldLabel.bottomAnchor, constant: 0.0),
        ])
        
        theLabel.font = .systemFont(ofSize: 17.0, weight: .regular)
        theBoldLabel.font = .systemFont(ofSize: 17.0, weight: .bold)

        // not strictly necessary, but the bold label controls the sizing
        // so let's make sure it sizes correctly
        theBoldLabel.setContentHuggingPriority(.required, for: .horizontal)
        theBoldLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
        
        // default "un-selected" appearance
        contentView.layer.borderColor = UIColor(white: 0.9, alpha: 1.0).cgColor
        contentView.layer.borderWidth = 1.0
        theBoldLabel.isHidden = true
    }
    
    // the collection view will tell us whether the cell is selected or not
    //  so update the cell appearance here
    override var isSelected: Bool {
        didSet {
            contentView.backgroundColor = isSelected ? UIColor(red: 0.5, green: 1.0, blue: 0.5, alpha: 1.0) : .systemBackground
            contentView.layer.borderWidth = isSelected ? 0.0 : 1.0
            theLabel.isHidden = isSelected
            theBoldLabel.isHidden = !isSelected
        }
    }
    override func layoutSubviews() {
        contentView.layer.cornerRadius = contentView.bounds.height * 0.5
    }
}

Then in the controller's cellForItemAt, instead of setting the text of the "single" label, we'll set the cell's title property, which will set the same text in both labels:

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let c = collectionView.dequeueReusableCell(withReuseIdentifier: SomeCell.identifier, for: indexPath) as! SomeCell
    //c.theLabel.text = tagsData[indexPath.item]
    c.title = tagsData[indexPath.item]
    return c
}

Edit 2 - responding to comment about selecting programmatically...

First, do NOT call:

cell.isSelected = true

That does not tell the collection view that the cell has been selected.

This is the proper way to programmatically select a cell (in this case, we want to select cell/item 5 and have it scroll to the center):

collectionView.selectItem(at: IndexPath(item: 5, section: 0), animated: true, scrollPosition: .centeredHorizontally)

Note that programmatically selecting the cell will NOT call the didSelectItemAt delegate func. That is called to let you know that the user selected a cell.

So, if you want to programmatically select a cell, and you want something to happen, follow it up with that "do something" code.

For example:

let idxPath: IndexPath = IndexPath(item: 5, section: 0)
collectionView.selectItem(at: idxPath, animated: true, scrollPosition: .centeredHorizontally)
DispatchQueue.main.async {
    self.viewModel.fetchProductData(forPositon: idxPath)
}