diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 8d4efe7..e363297 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -86,6 +86,13 @@ final class NativeMessageListController: UIViewController { /// Cleared only when isInputFocused becomes true (user tap or reply action). fileprivate(set) var keyboardBlockedUntilUserTap = false private var currentKeyboardHeight: CGFloat = 0 + /// Timestamp of the last animated scrollToBottom call. + /// When recent (< 1s), applyInsets/keyboardWillChangeFrame snap to + /// -newInsetTop instead of compensating from the intermediate offset. + /// A timestamp is used instead of a boolean flag because UIKit cancels + /// the scroll animation when contentInset changes, firing + /// scrollViewDidEndScrollingAnimation prematurely. + private var scrollToBottomTimestamp: CFAbsoluteTime = 0 // MARK: - Scroll-to-Bottom Button private var scrollToBottomButton: UIButton? @@ -762,16 +769,27 @@ final class NativeMessageListController: UIViewController { // Capture offset BEFORE setting insets — UIKit may auto-clamp after. let oldOffset = collectionView.contentOffset.y + let recentScrollToBottom = CFAbsoluteTimeGetCurrent() - scrollToBottomTimestamp < 1.0 + collectionView.contentInset = UIEdgeInsets(top: newInsetTop, left: 0, bottom: topInset, right: 0) collectionView.scrollIndicatorInsets = collectionView.contentInset - // Always compensate symmetrically: open AND close. - // Setting exact `oldOffset - delta` overrides any UIKit auto-clamping. - let shouldCompensate = abs(delta) > 0.5 - && !collectionView.isDragging - && !collectionView.isDecelerating - if shouldCompensate { - collectionView.contentOffset.y = oldOffset - delta + if recentScrollToBottom && abs(delta) > 0.5 { + // An animated scroll-to-bottom was recently started. + // Reading contentOffset during the animation returns an intermediate + // value — compensating from it produces a wrong final position. + // Snap to the new bottom instead. + collectionView.setContentOffset( + CGPoint(x: 0, y: -newInsetTop), animated: false + ) + } else { + // Normal compensation: preserve reading position. + let shouldCompensate = abs(delta) > 0.5 + && !collectionView.isDragging + && !collectionView.isDecelerating + if shouldCompensate { + collectionView.contentOffset.y = oldOffset - delta + } } updateScrollToBottomBadge() } @@ -779,6 +797,7 @@ final class NativeMessageListController: UIViewController { /// Scroll to the newest message (visual bottom = offset 0 in inverted scroll). func scrollToBottom(animated: Bool) { guard !messages.isEmpty else { return } + if animated { scrollToBottomTimestamp = CFAbsoluteTimeGetCurrent() } collectionView.setContentOffset( CGPoint(x: 0, y: -collectionView.contentInset.top), animated: animated @@ -874,7 +893,10 @@ final class NativeMessageListController: UIViewController { // Capture offset BEFORE animation — UIKit may auto-clamp after inset change. // Skip compensation during swipe-back — let content slide down naturally. let oldOffset = collectionView.contentOffset.y - let shouldCompensate = !isDisappearing + let recentScroll = CFAbsoluteTimeGetCurrent() - scrollToBottomTimestamp < 1.0 + let snapToBottom = recentScroll && abs(delta) > 0.5 + let shouldCompensate = !snapToBottom + && !isDisappearing && abs(delta) > 0.5 && !collectionView.isDragging && !collectionView.isDecelerating @@ -886,8 +908,10 @@ final class NativeMessageListController: UIViewController { self.collectionView.contentInset = UIEdgeInsets(top: newInsetTop, left: 0, bottom: topInset, right: 0) self.collectionView.scrollIndicatorInsets = self.collectionView.contentInset - // Enforce exact offset — overrides any UIKit auto-clamping. - if shouldCompensate { + if snapToBottom { + // Recent scroll-to-bottom — maintain bottom target. + self.collectionView.contentOffset.y = -newInsetTop + } else if shouldCompensate { self.collectionView.contentOffset.y = oldOffset - delta } @@ -951,6 +975,7 @@ extension NativeMessageListController: UICollectionViewDelegate { onPaginationTrigger?() } } + } // MARK: - ComposerViewDelegate @@ -1146,6 +1171,11 @@ struct NativeMessageListView: UIViewControllerRepresentable { // Sync composer state (iOS < 26) if useUIKitComposer { syncComposerState(controller) + // Force immediate layout to process pending height change from reply + // bar show/hide. Without this, height report is deferred to + // DispatchQueue.main.async, and scrollToBottom may calculate offset + // before contentInset reflects the new composer height. + controller.composerView?.layoutIfNeeded() } // Update messages @@ -1223,11 +1253,6 @@ struct NativeMessageListView: UIViewControllerRepresentable { private func syncComposerState(_ controller: NativeMessageListController) { guard let composer = controller.composerView else { return } composer.setText(messageText) - #if DEBUG - if replySenderName != nil { - print("📋 syncComposer: sender=\(replySenderName ?? "nil") preview=\(replyPreviewText ?? "nil")") - } - #endif composer.setReply(senderName: replySenderName, previewText: replyPreviewText) // After swipe-back dismisses keyboard, block programmatic re-focus