Bottom offset keeping issues
nepodm opened this issue · comments
Hi @ekazaev and thanks for the great chat layout solution!
I'm facing the issue with keeping collection view content at bottom position. Given restoreContentOffset
in UIView.animate block makes top cells disappear like on the video below.
111111.mp4
I was able to fix this behaviour by trying scrollRectToVisible
, scrollToItem
and setContentOffset
(they give same result). Now top cells are not disappearing but if collection isn't at bottom position when batch updates starts it scrolls to bottom item for its estimated height only:
Screen.Recording.2023-07-13.at.16.07.40.mov
I saw similar issues in this repo but there was no solution for my case. Can you help me to figure it out?
@nepodm hi
i am on vacation atm but ill be back soon. Can you please modify the example app to reproduce the behavior so I could have a proper look when i am back? What i can tell you is that you should use restoreContebtoffset in some specific cases and it is not designated to be used in animation loop as it tweaks the collection view.
I found the cause of the bottom offset scroll problem. I was trying to scroll to bottom item with unknown height (the item was never displayed and its height was not calculated). It can be fixed with scrolling to item with known height (like footer in your example project). I added zero height footer and scrolled to its bottom, this solved my first problem but now i have the second one.
When collection view content offset is updating with no animation it causes cell disappearing like on the videos below:
121212.mov
Untitled.mov
I saw issue #43 but your demo code does not set offset properly in example project.
I overridden bounds of UICollectionView with didSet
and now content offset is being set normally.
final class ChatMessageCollectionView: UICollectionView {
private var contentOffsets: [CGFloat: CGFloat] = [:]
// keeps collection view content
// offset on bounds change
override var bounds: CGRect {
didSet {
let maxContentOffsetY = max(-contentInset.top, contentSize.height - bounds.height + contentInset.bottom)
let previousHeight = oldValue.height
let actualHeight = bounds.height
contentOffsets[actualHeight] = contentOffset.y
guard
let previousContentOffsetY = contentOffsets[previousHeight],
contentSize.height > actualHeight,
previousHeight != actualHeight
else {
return
}
let visibleContentHeight = contentSize.height - previousContentOffsetY - previousHeight
var offsetY = contentSize.height - visibleContentHeight - actualHeight
offsetY = offsetY < 0 ? 0 : offsetY
offsetY = min(maxContentOffsetY, offsetY)
contentOffset = .init(x: 0, y: offsetY)
}
}
}
But when im trying to animate contentOffset setting with setContentOffset(.init(x: 0, y: offsetY), animated: true)
cells start flickering and jumping. Can you test this behaviour in your example project?
111.mov
@nepodm Hi.
I am strugnling to understand what you are trying to achieve. Lets start from the beginning. So do you want that when the keyboard appears chat view scrolls to the last message?
As I wrote above the problem with bottom scrolling is solved.
Now i just want to keep collection view content offset during keyboard height updates without visual glitches. In your example app content offset of collection updates instantly. It causes cell disappearing (videos 121212.mov and Untitled.mov). Im trying to animate it with setContentOffset(animated: true) but bottom cells start flickering (111.mov).
These are two different problems connected with thread theme.
@nepodm Lets talks about Example app as i dont see your code. In my app it sets without animation using restoreOffset as it is considered as an acceptable behavior. Similar code is used in some apps in production and alleged visual glitch doesn’t bother anybody it seems. I even see it in WhatsApp which doesn’t use ChatLayout. But there are always some tradeoffs.
Some problems I assume you are hitting when using setContentOffset animated are described here: https://dasdom.dev/scrolling-a-collection-view-with-custom-duration/ I would suggest to use CADisplayLink based solution to get the consistent result. setContentOffset(animated:)
will also create a CADisplayLink
inside if you set the animated
value to true. But you have no influence on the parameters of the animation.
What I also can add is that all those setContentOffset or scrollToItemWithIndexPath may not work correctly in the situation with the dynamic cell size. It is valid for default layouts as well. When you are setting new content offset - cells that were not visible and become visible are being calculated and the desired content offset may not be visually correct anymore as the content size of the view changed and it compensated the content offset within as well. So CADisplayLink
based solution when you are doing a slight correction on every frame will give you the consistent result. Same approach UIScrollView
uses when it scrolls itself to the top. It allows to avoid glitches described in the article above and to always find the “correct top” even if something changes during the scrolling.
PS: Some valuable information to understand the processes behind can be found here: https://medium.com/picsart-engineering/how-frequent-is-uicollectionview-layoutsubviews-being-called-during-scroll-4799ccba2dc
@ekazaev Thanks a lot for provided links and information. I never noticed this behaviour in WhatsApp app but now i see it. Meanwhile Telegram avoided that kind of cell disappearing problem somehow. My app has bigger header and input heights so it's much easier to observe. I will try described method and come back to this thread.
@nepodm Dont forget that Telegram is using their own custom implementation of the Chat view implemented within UIScrollView
and not relying on the UICollectionView
AFAIK. It is a product made by the team of multiple developers that have polished the user experience through the years to get to the point you can see now. Even though Telegram is kinda a golden standard - do not forget that often "Best is the enemy of Good Enough". Even though I am sure that CADisplayLink
can help you (and I have the sneaky suspicion that it is what they actually use), there are so many underwater stones in bringing your chat experience anywhere close to Telegram that I would even ignore that glitch for now. You mentioned that you have bigger header which makes the glitch more obvious - try to make the UICollectionView
bigger going out of the screen on top and use the inset to compensate that change. Probably it will be quicker solution to make the glitch less noticeable. I did not try it but this is what came to my head.
Otherwise you can try to implement your own frame by frame scrolling using CADisplayLink
. As a reference you can use next code:
func scrollToIndexPath(_ indexPath: IndexPath, animated: Bool, completion: (() -> Void)? = nil) {
guard chatLayout.layoutAttributesForItem(at: indexPath) != nil else {
return
}
guard animated else {
let positionSnapshot = ChatLayoutPositionSnapshot(indexPath: indexPath, kind: .cell, edge: .top)
chatLayout.restoreContentOffset(with: positionSnapshot)
completion?()
return
}
let initialOffset = self.collectionView.contentOffset
collectionView.isUserInteractionEnabled = false
// See: https://dasdom.dev/posts/scrolling-a-collection-view-with-custom-duration/
animator = ManualAnimator()
animator?.animate(duration: TimeInterval(0.25), curve: .easeInOut) { [weak self] percentage in
guard let self = self else { return }
guard let attributesForItem = self.chatLayout.layoutAttributesForItem(at: indexPath) else {
return
}
let itemContentOffset = CGPoint(x: initialOffset.x,
y: attributesForItem.frame.minY - self.collectionView.adjustedContentInset.top - self.chatLayout.settings.additionalInsets.top)
let delta = itemContentOffset.y - initialOffset.y
let contentOffsetAtBottom = CGPoint(x: self.collectionView.contentOffset.x, y: self.chatLayout.collectionViewContentSize.height - self.collectionView.frame.height + self.collectionView.adjustedContentInset.bottom)
self.collectionView.contentOffset = CGPoint(x: self.collectionView.contentOffset.x, y: min(contentOffsetAtBottom.y,initialOffset.y + (delta * percentage)))
if percentage == 1.0 {
self.animator = nil
let context = ChatLayoutInvalidationContext()
context.invalidateLayoutMetrics = false
self.chatLayout.invalidateLayout(with: context)
self.collectionView.setNeedsLayout()
self.collectionView.layoutIfNeeded()
self.currentInterfaceActions.options.remove(.scrollingToBottom)
self.collectionView.isUserInteractionEnabled = true
completion?()
}
}
}
@nepodm Ill close the issue if you dont mind. I feel like I provided enough information and there is nothing to be fixed from the ChatLayout point of view as the interaction happens with the UICollectionView which is a level higher. But feel free to reopen the issue if you have more information to share or if you have further questions related to the topic.