Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

like image 841
Hash88 Avatar asked Nov 19 '19 09:11

Hash88


2 Answers

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.

like image 70
bevoy Avatar answered Nov 08 '22 18:11

bevoy


IMPORTANT!

If you want to preserve UICollectionView scroll position when you fetch new items, it is better to insert the new items instead of reloading the entire section (or maybe the entire list!)..

But the trick here is to set the size of each item (or cell) to avoid jumping behavior (or flickers):

  1. Inherit your ViewController from UICollectionViewDelegateFlowLayout and implement the sizeForItemAt

    class myViewController: UIViewController, UICollectionViewDataSource, 
        UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
    
        // Your core functions goes here
    
        func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
            return CGSize(width: ITEM_WIDTH, height: ITEM_HEIGHT)
        }
    
    }
    
  2. Insert the items:

    for i in dictionary {
    
        items.append(i)
    
        collectionView.performBatchUpdates({
    
            collectionView.insertItems(at: [IndexPath(item: items.count - 1, section: 0)])
    
            }, completion: { [] (_) in
    
        })
    }
    
like image 2
Osama Remlawi Avatar answered Nov 08 '22 18:11

Osama Remlawi