iOS Developer in search for meaning 🧘‍♂️

Inserting Dynamic Height cells keeping Scroll position

June 25, 2021

We’ve been working on improvements for our Chat UI at Sphere lately and there was an interesting problem to solve and I could not find any answer online that would work perfectly well for our scenario.

So the problem was that we want to have a smooth scrolling experience when users scroll up in the chat, while we insert new chat items to the top of the collection view from the conversation history.

This is how it looks in the end

There are several assumptions that we used to solve this issue:

  1. We know when items are inserted into the top of the collection view by analyzing diff of the state changes
  2. We save the previous content size before applying batch updates
  3. We save content offset before the update in the invalidateLayout(with:) method
  4. We provide new content offset overriding targetContentOffset(forProposedContentOffset:) method on UICollectionViewLayout

So the pseudo code looks something like this:

final class MessageListLayout: UICollectionViewFlowLayout {
	var isPrepending: Bool = false
	
	private var prevContentHeight: CGFloat?
	private var contentOffsetBeforeUpdate: CGPoint?
	
    override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
      // This method is called before collection view will perform batch updates
      // So it's a perfect place to store information before the update
       
      prevContentHeight = self.collectionViewContentSize.height
      contentOffsetBeforeUpdate = self.collectionView?.contentOffset
      super.invalidateLayout(with: context)
    }
    
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
    // Here we check if we are in prepending stage of batch updates
      if
        let preContentHeight = self.prevContentHeight,
        let prevContentOffset = self.contentOffsetBeforeUpdate, isPrepending
      {
        let newContentHeight = collectionViewContentSize.height
        
        // This is basically a difference on which convent size have changed before the update
        let delta = newContentHeight - preContentHeight
        
        // We add `delta` to the previous offset `y` so for user there is an impression that 
        // content offset hasn't changed since the batch update
        return CGPoint(x: proposedContentOffset.x, y: prevContentOffset.y + delta)
      }
      return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
    }
}

And somewhere in out collection view adapter

private func performUpdate(sections: [Section]) {
      let changes = StagedChangeset(source: self.sections, target: sections)
      let wasEmpty = self.sections.isEmpty
      
      // Here we check what is the nature if the change in the content
      let nature = changeNature(of: changes, wasInitiallyEmpty: wasEmpty)
      
      // If it's a `prependingHistory` chagne we tell it to the layout object.
      self.layout.isPrepending = nature == .prependingHistory
      
      // And call for batch updates where the offset will be handled by the layout object.
      reloadWithSilentCellUpdate(changes)
}

Serg Dort

Written by Serg Dort, who works and lives in London builds useful things. You can follow him on Twitter