UICollectionView state restoration: customizing scroll position

1.9k Views Asked by At

I am trying to find the best way to handle state restoration for a UICollectionView whose elements may move around. My goal is to make sure that the last viewed item in the collection view is still visible when restarting the app, even if the items have moved around. For example, item A is in cell at index 3 when the app was killed, and when the app restarts if the model says that item A should be displayed at index 4, I want the collection view to initialize offset to the cell at index 4.

I thought that implementing the UIDataSourceModelAssociation protocol in my UICollectionViewDataSource class would take care of this for me, as the documentation states:

[UITableView and UICollectionView] classes use the methods of this protocol to ensure that the same data objects (and not just the same row indexes) are scrolled into view and selected.

However, what I have observed is that implementing this protocol does properly affect the indexPath of the selected cells during restoration (which isn't important to my app), but it does not affect the scroll position. The scroll position (contentOffset of the collection view) is always restored to exactly where it was when the app was killed, and not affected by the UICollectionViewDataSource.

I do have a workaround that looks like this. It's basically the same pattern as the model assocation protocol, but I have to do it manually:

override func encodeRestorableStateWithCoder(coder: NSCoder) {
    let identifier = determineIdOfCurrentlyVisibleCell()
    coder.encodeObject(identifier, forKey: "visibleCellIdentifier")
}

override func decodeRestorableStateWithCoder(coder: NSCoder) {
    if let identifier = coder.decodeObjectForKey("visibleCellIdentifier") as? String {
        if let indexPath = model.indexPathForIdentifier(identifier) {
            collectionView.scrollToItemAtIndexPath(indexPath, atScrollPosition: .CenteredVertically, animated: false)
        }
    }
}

Did I misunderstand the usage of UIDataSourceModelAssociation? Is there a bug? Is there a more elegant or correct way to get this to work?

2

There are 2 best solutions below

5
On BEST ANSWER

As you already pointed out, UIDataSourceModelAssociation doesn't seem to work with restoring a UICollectionView's visible offset, but only for selected items. I tried setting breakpoints on both modelIdentifierForElementAtIndexPath and indexPathForElementWithModelIdentifier and noticed they were only called after I selected a cell. If I cleared my collection view's selected cells before backgrounding my app then modelIdentifierForElementAtIndexPath wouldn't get called, but it would once I set at least one cell as selected. At least I can verify that you aren't the only one seeing this behavior.

I think because of the varying nature of UICollectionView it is probably not straightforward to create behavior that scrolls visible cells to the correct point, but this obviously isn't reflected in Apple's documentation. Manually encoding an identifier to the first visible cell for your layout should be a good alternative. What I'm doing is wrapping the collection view's scroll offset in an NSValue and restoring that:

var collectionView: UICollectionView?

// ...

override func encodeRestorableStateWithCoder(coder: NSCoder) {
    if let view = collectionView, offsetValue = NSValue(CGPoint: view.contentOffset) {
        coder.encodeObject(offsetValue, forKey: CollectionViewContentOffsetKey)
    }

    super.encodeRestorableStateWithCoder(coder)
}

override func decodeRestorableStateWithCoder(coder: NSCoder) {
    if let offsetValue = coder.decodeObjectForKey(CollectionViewContentOffsetKey) as? NSValue {
        collectionView?.setContentOffset(offsetValue.CGPointValue(), animated: false)
    }

    super.decodeRestorableStateWithCoder(coder)
}
2
On

Update based on @stepane's suggestion using CGPoint.

  override func encodeRestorableState(with coder: NSCoder) {
        super.encodeRestorableState(with: coder)
        coder.encode(collectionView.contentOffset, forKey: "CollectionViewContentOffset")
    }

    override func decodeRestorableState(with coder: NSCoder) {
        super.decodeRestorableState(with: coder)
        let offsetValue = coder.decodeObject(forKey: "CollectionViewOffset") as! CGPoint
        collectionView?.setContentOffset(offsetValue, animated: false)
    }