Search code examples
iosobjective-cuicollectionview

Reload section of UICollectionView and preserve scroll position


I have a horizontal UICollectionView with several sections, each containing several cells as follows:

Section 0: one cell to cancel selection.
Section 1: recently selected items.
Section 2 and beyond: each section has several items that can be selected.

When a cell from section 2 or later is selected, a copy of that item is inserted to the start section 1 in the data source, and I want to reload section 1 to reflect the updated recent items. But, I want to preserve the scroll position. I've tried:

[collectionView reloadSections:setWithIndex1] and
[collectionView reloadItemsAtIndexPaths:arrayWithAllSection1IndexPaths]
I've tried using [collectionView performBatchUpdates:], but everything I've tried makes the scroll offset reset to the beginning of the collection view. I've tried a sanity check by starting a fresh app with a basic collection view and reloading a section using reloadSections, and it has the desired behavior of not resetting the scroll offset. But doing the same in my existing codebase does, undesirably, reset the offset.

I've poured over my collectionView-related code looking for reloadData's, setContentOffsets's, and similar things, but for the life of me I can't find what's causing it. Is there anything I'm missing that could be resetting the scroll position after an update?


Solution

  • If you are OK with doing it without any animation I would do as follows:

    • start with disabling animations
    UIView.setAnimationsEnabled(false)
    
    • before inserting a new item (cell), store the value of visible rect for the selected cell
    let selectedCell = collectionView.cellForItem(at: indexPath)
    let visibleRect = collectionView.convert(collectionView.bounds, to: selectedCell)
    
    • perform the insertion and when it's finished, check what is the new visible rect for the selected cell, compare it to the old value and add their difference to collectionView's contentOffset.
    selectedObjects.insert(allObjects[indexPath.item], at: 0)
    collectionView.performBatchUpdates({
    
       // INSERTING NEW ITEM
       let indexPathForNewItem = IndexPath(item: 0, section: 1)
       collectionView.insertItems(at: [indexPathForNewItem])
    }) { (finished) in
    
       // GETTING NEW VISIBLE RECT FOR SELECTED CELL
       let updatedVisibleRect = collectionView.convert(collectionView.bounds, to: selectedCell)
    
       // UPDATING COLLECTION VIEW CONTENT OFFSET
       var contentOffset = collectionView.contentOffset
       contentOffset.x = contentOffset.x + (visibleRect.origin.x - updatedVisibleRect.origin.x)
       collectionView.contentOffset = contentOffset
    }
    
    • Finish by enabling animations back
    UIView.setAnimationsEnabled(true)
    

    I tried it on a simple collection view adjusted to the behaviour you described. Here's the whole implementation (collecionView is in the storyboard, so if you want to give my solution a test, don't forget to connect the outlet.)

    import UIKit
    
    class ViewController: UIViewController {
    
        @IBOutlet weak var collectionView: UICollectionView!
        let reuseIdentifier = "cell.reuseIdentifier"
    
        var allObjects: [UIColor] = [.red, .yellow, .orange, .purple, .blue]
        var selectedObjects: [UIColor] = []
    
    
        override func viewDidLoad() {
            super.viewDidLoad()
            self.collectionView.delegate = self
            self.collectionView.dataSource = self
            self.collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: self.reuseIdentifier)
        }
    }
    
    extension ViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
    
        func numberOfSections(in collectionView: UICollectionView) -> Int {
            return 3
        }
    
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            switch section {
            case 0: return 1
            case 1: return selectedObjects.count
            case 2: return allObjects.count
            default: return 0
            }
        }
    
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: self.reuseIdentifier, for: indexPath)
    
            switch indexPath.section {
            case 0: cell.contentView.backgroundColor = .black
            case 1: cell.contentView.backgroundColor = selectedObjects[indexPath.item]
            case 2: cell.contentView.backgroundColor = allObjects[indexPath.item]
            default: break
            }
    
            return cell
        }
    
        func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
            return CGSize(width: 150, height: collectionView.frame.size.height)
        }
    }
    
    extension ViewController: UICollectionViewDelegate {
        func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
            collectionView.deselectItem(at: indexPath, animated: false)
    
            switch indexPath.section {
            case 0:
                self.selectedObjects.removeAll()
                collectionView.reloadData()
            case 2:
                if selectedObjects.contains(allObjects[indexPath.item]) {
                    break
                } else {
                    // SOLUTION //
                    UIView.setAnimationsEnabled(false)
                    let selectedCell = collectionView.cellForItem(at: indexPath)
                    let visibleRect = collectionView.convert(collectionView.bounds, to: selectedCell)
    
                    selectedObjects.insert(allObjects[indexPath.item], at: 0)
                    collectionView.performBatchUpdates({
                        let indexPathForNewItem = IndexPath(item: 0, section: 1)
                        collectionView.insertItems(at: [indexPathForNewItem])
                    }) { (finished) in
                        let updatedVisibleRect = collectionView.convert(collectionView.bounds, to: selectedCell)
    
                        var contentOffset = collectionView.contentOffset
                        contentOffset.x = contentOffset.x + (visibleRect.origin.x - updatedVisibleRect.origin.x)
                        collectionView.contentOffset = contentOffset
                    }
                    UIView.setAnimationsEnabled(true)
                    // END OF SOLUTION //
                }
    
            default:
                break
            }
        }
    
        func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
            return UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 0)
        }
    }
    

    EDIT

    I just also tried replacing

    let indexPathForNewItem = IndexPath(item: 0, section: 1)
    collectionView.insertItems(at: [indexPathForNewItem])
    

    with

    collectionView.reloadSections(IndexSet(integer: 1))
    

    and it also works just fine, without any flickering, so it's up to you which is more convenient for you.