Фикс: исправлен скролл реплай-сообщений под композер при отправке

This commit is contained in:
2026-03-31 00:16:29 +05:00
parent a2de309a0f
commit 3fc15c14ff

View File

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