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:
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)
}
Written by Serg Dort, who works and lives in London builds useful things. You can follow him on Twitter