iOS Developer in search for meaning 🧘‍♂️

Inserting Dynamic Height Cells While Preserving Scroll Position

June 25, 2021

While improving the Chat UI at Sphere, we ran into a problem I couldn’t find a good solution for online: maintaining smooth scroll position when prepending items to a collection view.

The scenario: users scroll up through chat history, triggering pagination. New messages load at the top of the collection view, but the scroll position shouldn’t jump—users should stay exactly where they were.

Here’s the result:

The approach

The solution relies on four key pieces:

  1. Detect when items are being prepended by analyzing the diff of state changes
  2. Capture the content size before batch updates
  3. Capture the content offset in invalidateLayout(with:)—called right before batch updates execute
  4. Return the adjusted offset from targetContentOffset(forProposedContentOffset:)

The implementation

Custom layout subclass:

final class MessageListLayout: UICollectionViewFlowLayout {
    var isPrepending: Bool = false

    private var prevContentHeight: CGFloat?
    private var contentOffsetBeforeUpdate: CGPoint?

    override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
        // Called before batch updates—capture state here
        prevContentHeight = collectionViewContentSize.height
        contentOffsetBeforeUpdate = collectionView?.contentOffset
        super.invalidateLayout(with: context)
    }

    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
        guard
            let prevHeight = prevContentHeight,
            let prevOffset = contentOffsetBeforeUpdate,
            isPrepending
        else {
            return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
        }

        // Calculate how much content height changed
        let delta = collectionViewContentSize.height - prevHeight

        // Offset by delta so the visible content stays in place
        return CGPoint(x: proposedContentOffset.x, y: prevOffset.y + delta)
    }
}

Collection view adapter:

private func performUpdate(sections: [Section]) {
    let changes = StagedChangeset(source: self.sections, target: sections)
    let wasEmpty = self.sections.isEmpty

    // Determine the type of change
    let nature = changeNature(of: changes, wasInitiallyEmpty: wasEmpty)

    // Signal to layout that we're prepending
    layout.isPrepending = nature == .prependingHistory

    // Batch updates—layout handles the offset adjustment
    reloadWithSilentCellUpdate(changes)
}

The key insight: invalidateLayout(with:) fires before the batch update applies, giving you a window to snapshot the current state. Then targetContentOffset(forProposedContentOffset:) lets you adjust where the collection view lands after the update. The delta between old and new content height is exactly how much to shift the offset to keep the visible content stationary.