Фикс: исправлен скролл реплай-сообщений под композер при отправке
This commit is contained in:
@@ -86,6 +86,13 @@ final class NativeMessageListController: UIViewController {
|
|||||||
/// Cleared only when isInputFocused becomes true (user tap or reply action).
|
/// Cleared only when isInputFocused becomes true (user tap or reply action).
|
||||||
fileprivate(set) var keyboardBlockedUntilUserTap = false
|
fileprivate(set) var keyboardBlockedUntilUserTap = false
|
||||||
private var currentKeyboardHeight: CGFloat = 0
|
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
|
// MARK: - Scroll-to-Bottom Button
|
||||||
private var scrollToBottomButton: UIButton?
|
private var scrollToBottomButton: UIButton?
|
||||||
@@ -762,23 +769,35 @@ final class NativeMessageListController: UIViewController {
|
|||||||
// Capture offset BEFORE setting insets — UIKit may auto-clamp after.
|
// Capture offset BEFORE setting insets — UIKit may auto-clamp after.
|
||||||
let oldOffset = collectionView.contentOffset.y
|
let oldOffset = collectionView.contentOffset.y
|
||||||
|
|
||||||
|
let recentScrollToBottom = CFAbsoluteTimeGetCurrent() - scrollToBottomTimestamp < 1.0
|
||||||
|
|
||||||
collectionView.contentInset = UIEdgeInsets(top: newInsetTop, left: 0, bottom: topInset, right: 0)
|
collectionView.contentInset = UIEdgeInsets(top: newInsetTop, left: 0, bottom: topInset, right: 0)
|
||||||
collectionView.scrollIndicatorInsets = collectionView.contentInset
|
collectionView.scrollIndicatorInsets = collectionView.contentInset
|
||||||
|
|
||||||
// Always compensate symmetrically: open AND close.
|
if recentScrollToBottom && abs(delta) > 0.5 {
|
||||||
// Setting exact `oldOffset - delta` overrides any UIKit auto-clamping.
|
// 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
|
let shouldCompensate = abs(delta) > 0.5
|
||||||
&& !collectionView.isDragging
|
&& !collectionView.isDragging
|
||||||
&& !collectionView.isDecelerating
|
&& !collectionView.isDecelerating
|
||||||
if shouldCompensate {
|
if shouldCompensate {
|
||||||
collectionView.contentOffset.y = oldOffset - delta
|
collectionView.contentOffset.y = oldOffset - delta
|
||||||
}
|
}
|
||||||
|
}
|
||||||
updateScrollToBottomBadge()
|
updateScrollToBottomBadge()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scroll to the newest message (visual bottom = offset 0 in inverted scroll).
|
/// Scroll to the newest message (visual bottom = offset 0 in inverted scroll).
|
||||||
func scrollToBottom(animated: Bool) {
|
func scrollToBottom(animated: Bool) {
|
||||||
guard !messages.isEmpty else { return }
|
guard !messages.isEmpty else { return }
|
||||||
|
if animated { scrollToBottomTimestamp = CFAbsoluteTimeGetCurrent() }
|
||||||
collectionView.setContentOffset(
|
collectionView.setContentOffset(
|
||||||
CGPoint(x: 0, y: -collectionView.contentInset.top),
|
CGPoint(x: 0, y: -collectionView.contentInset.top),
|
||||||
animated: animated
|
animated: animated
|
||||||
@@ -874,7 +893,10 @@ final class NativeMessageListController: UIViewController {
|
|||||||
// Capture offset BEFORE animation — UIKit may auto-clamp after inset change.
|
// Capture offset BEFORE animation — UIKit may auto-clamp after inset change.
|
||||||
// Skip compensation during swipe-back — let content slide down naturally.
|
// Skip compensation during swipe-back — let content slide down naturally.
|
||||||
let oldOffset = collectionView.contentOffset.y
|
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
|
&& abs(delta) > 0.5
|
||||||
&& !collectionView.isDragging
|
&& !collectionView.isDragging
|
||||||
&& !collectionView.isDecelerating
|
&& !collectionView.isDecelerating
|
||||||
@@ -886,8 +908,10 @@ final class NativeMessageListController: UIViewController {
|
|||||||
self.collectionView.contentInset = UIEdgeInsets(top: newInsetTop, left: 0, bottom: topInset, right: 0)
|
self.collectionView.contentInset = UIEdgeInsets(top: newInsetTop, left: 0, bottom: topInset, right: 0)
|
||||||
self.collectionView.scrollIndicatorInsets = self.collectionView.contentInset
|
self.collectionView.scrollIndicatorInsets = self.collectionView.contentInset
|
||||||
|
|
||||||
// Enforce exact offset — overrides any UIKit auto-clamping.
|
if snapToBottom {
|
||||||
if shouldCompensate {
|
// Recent scroll-to-bottom — maintain bottom target.
|
||||||
|
self.collectionView.contentOffset.y = -newInsetTop
|
||||||
|
} else if shouldCompensate {
|
||||||
self.collectionView.contentOffset.y = oldOffset - delta
|
self.collectionView.contentOffset.y = oldOffset - delta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,6 +975,7 @@ extension NativeMessageListController: UICollectionViewDelegate {
|
|||||||
onPaginationTrigger?()
|
onPaginationTrigger?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ComposerViewDelegate
|
// MARK: - ComposerViewDelegate
|
||||||
@@ -1146,6 +1171,11 @@ struct NativeMessageListView: UIViewControllerRepresentable {
|
|||||||
// Sync composer state (iOS < 26)
|
// Sync composer state (iOS < 26)
|
||||||
if useUIKitComposer {
|
if useUIKitComposer {
|
||||||
syncComposerState(controller)
|
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
|
// Update messages
|
||||||
@@ -1223,11 +1253,6 @@ struct NativeMessageListView: UIViewControllerRepresentable {
|
|||||||
private func syncComposerState(_ controller: NativeMessageListController) {
|
private func syncComposerState(_ controller: NativeMessageListController) {
|
||||||
guard let composer = controller.composerView else { return }
|
guard let composer = controller.composerView else { return }
|
||||||
composer.setText(messageText)
|
composer.setText(messageText)
|
||||||
#if DEBUG
|
|
||||||
if replySenderName != nil {
|
|
||||||
print("📋 syncComposer: sender=\(replySenderName ?? "nil") preview=\(replyPreviewText ?? "nil")")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
composer.setReply(senderName: replySenderName, previewText: replyPreviewText)
|
composer.setReply(senderName: replySenderName, previewText: replyPreviewText)
|
||||||
|
|
||||||
// After swipe-back dismisses keyboard, block programmatic re-focus
|
// After swipe-back dismisses keyboard, block programmatic re-focus
|
||||||
|
|||||||
Reference in New Issue
Block a user