ekazaev / ChatLayout

ChatLayout is an alternative solution to MessageKit. It uses custom UICollectionViewLayout to provide you full control over the presentation as well as all the tools available in UICollectionView. It supports dynamic cells and supplementary view sizes.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Bottom offset keeping issues

nepodm opened this issue · comments

commented

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.

commented

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?

commented

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.
image

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

commented

@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.