From 5839c85a7fe050e83faa32147b1a0609c9f93ebc Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:16:02 +0900 Subject: [PATCH 1/2] Fix bottom message tail-follow jitter --- lib/ui/home/chat/chat_history_viewport.dart | 4 + lib/ui/home/chat/chat_scroll_coordinator.dart | 92 +++++++ .../clamping_custom_scroll_view.dart | 6 + .../clamping_viewport.dart | 41 +++ .../chat/chat_scroll_coordinator_test.dart | 249 +++++++++++++++--- 5 files changed, 355 insertions(+), 37 deletions(-) diff --git a/lib/ui/home/chat/chat_history_viewport.dart b/lib/ui/home/chat/chat_history_viewport.dart index 3f0e489ea7..562524d84f 100644 --- a/lib/ui/home/chat/chat_history_viewport.dart +++ b/lib/ui/home/chat/chat_history_viewport.dart @@ -182,6 +182,10 @@ class ChatHistoryViewport extends HookConsumerWidget { scrollCacheExtent: const ScrollCacheExtent.viewport( ChatScrollCoordinator.loadedJumpViewportCount, ), + scrollOffsetCorrection: + scrollCoordinator.takeViewportAnchorCorrection, + suppressAutoBottomTracking: + scrollCoordinator.shouldSuppressAutoBottomTracking, slivers: [ SliverList( delegate: SliverChildBuilderDelegate((context, index) { diff --git a/lib/ui/home/chat/chat_scroll_coordinator.dart b/lib/ui/home/chat/chat_scroll_coordinator.dart index 7025044108..44a8c07de1 100644 --- a/lib/ui/home/chat/chat_scroll_coordinator.dart +++ b/lib/ui/home/chat/chat_scroll_coordinator.dart @@ -91,6 +91,8 @@ class ChatScrollCoordinator { final _renderedMessageIds = {}; bool _tailFollowEligible = true; _TailFollowAnchorSnapshot? _tailFollowAnchorSnapshot; + bool _tailFollowMessageWindowChanged = false; + _ViewportAnchorSnapshot? _viewportAnchorSnapshot; bool _animateNextRestore = false; ChatScrollRestoreDirection? _animatedRestoreDirection; bool _restoreScheduled = false; @@ -111,15 +113,25 @@ class ChatScrollCoordinator { Map keysByMessageId, ) { if (!scrollController.hasClients) return; + final messageWindowChanged = !_hasSameMessageIds(messages); _setMessages(messages, keysByMessageId); _tailFollowEligible = _isTailFollowEligible(); _tailFollowAnchorSnapshot = _tailFollowEligible ? _tailFollowAnchorSnapshotFromViewport() : null; + _tailFollowMessageWindowChanged = + _tailFollowEligible && + messageWindowChanged && + _tailFollowAnchorSnapshot != null; + _viewportAnchorSnapshot = _tailFollowEligible + ? null + : _viewportAnchorSnapshotFromViewport(); traceChatScroll( 'capture tailEligible=$_tailFollowEligible ' 'anchor=${shortMessageId(_tailFollowAnchorSnapshot?.messageId)} ' 'anchorBottom=${formatDouble(_tailFollowAnchorSnapshot?.bottom)} ' + 'viewportAnchor=${shortMessageId(_viewportAnchorSnapshot?.messageId)} ' + 'viewportTop=${formatDouble(_viewportAnchorSnapshot?.top)} ' '${_formatScrollState()}', ); } @@ -189,6 +201,9 @@ class ChatScrollCoordinator { _animateNextRestore || animateLatestReset); if (reset) { + _tailFollowAnchorSnapshot = null; + _tailFollowMessageWindowChanged = false; + _viewportAnchorSnapshot = null; _animatedRestoreMessageId = null; _animatedRestoreDirection = null; _animatedRestoreComplete = null; @@ -371,6 +386,38 @@ class ChatScrollCoordinator { return true; } + double? takeViewportAnchorCorrection() { + final snapshot = _viewportAnchorSnapshot; + _viewportAnchorSnapshot = null; + if (snapshot == null || !scrollController.hasClients) return null; + final top = _messageTopInViewport( + snapshot.messageId, + _keysByMessageId, + ); + if (top == null) { + traceChatJump( + 'viewport-anchor missing anchor=${shortMessageId(snapshot.messageId)} ' + '${formatScrollMetrics(scrollController.position)}', + ); + return null; + } + + final delta = top - snapshot.top; + if (delta.abs() <= _jumpToTolerance) return null; + traceChatJump( + 'viewport-anchor correction anchor=${shortMessageId(snapshot.messageId)} ' + 'delta=${formatDouble(delta)} ${formatScrollMetrics(scrollController.position)}', + ); + traceChatScroll( + 'viewport-anchor correction anchor=${shortMessageId(snapshot.messageId)} ' + 'delta=${formatDouble(delta)} ${_formatScrollState()}', + ); + return delta; + } + + bool shouldSuppressAutoBottomTracking() => + _tailFollowMessageWindowChanged && _tailFollowAnchorSnapshot != null; + void dispose() { _disposed = true; scrollController @@ -483,6 +530,7 @@ class ChatScrollCoordinator { final render = _messageRender(messageId, keysByMessageId); final viewport = _viewportRender; if (render == null || viewport == null) return null; + if (!render.hasSize || !viewport.hasSize) return null; final viewportTop = viewport.localToGlobal(Offset.zero).dy; final top = render.localToGlobal(Offset.zero).dy - viewportTop; @@ -499,6 +547,19 @@ class ChatScrollCoordinator { ); } + double? _messageTopInViewport( + String messageId, + Map keysByMessageId, + ) { + final render = _messageRender(messageId, keysByMessageId); + final viewport = _viewportRender; + if (render == null || viewport == null) return null; + if (!render.hasSize || !viewport.hasSize) return null; + + final viewportTop = viewport.localToGlobal(Offset.zero).dy; + return render.localToGlobal(Offset.zero).dy - viewportTop; + } + bool _isTargetInLoadedJumpWindow(double target) { if (!scrollController.hasClients) return false; final position = scrollController.position; @@ -745,9 +806,30 @@ class ChatScrollCoordinator { return snapshot; } + _ViewportAnchorSnapshot? _viewportAnchorSnapshotFromViewport() { + _ViewportAnchorSnapshot? snapshot; + final viewportHeight = scrollController.position.viewportDimension; + for (final messageId in _currentRenderedMessageIds()) { + final geometry = _messageTargetGeometry(messageId, _keysByMessageId); + if (geometry == null || + geometry.bottom <= 0 || + geometry.top >= viewportHeight) { + continue; + } + if (snapshot == null || geometry.top < snapshot.top) { + snapshot = _ViewportAnchorSnapshot( + messageId: messageId, + top: geometry.top, + ); + } + } + return snapshot; + } + void _restoreTailFollowAnimationStart() { final snapshot = _tailFollowAnchorSnapshot; _tailFollowAnchorSnapshot = null; + _tailFollowMessageWindowChanged = false; if (snapshot == null || !scrollController.hasClients) return; final geometry = _messageTargetGeometry( snapshot.messageId, @@ -985,6 +1067,16 @@ class _TailFollowAnchorSnapshot { final double bottom; } +class _ViewportAnchorSnapshot { + const _ViewportAnchorSnapshot({ + required this.messageId, + required this.top, + }); + + final String messageId; + final double top; +} + class _MessageTargetGeometry { const _MessageTargetGeometry({ required this.target, diff --git a/lib/widgets/clamping_custom_scroll_view/clamping_custom_scroll_view.dart b/lib/widgets/clamping_custom_scroll_view/clamping_custom_scroll_view.dart index aebbf402ee..d10f2fdb4c 100644 --- a/lib/widgets/clamping_custom_scroll_view/clamping_custom_scroll_view.dart +++ b/lib/widgets/clamping_custom_scroll_view/clamping_custom_scroll_view.dart @@ -21,6 +21,8 @@ class ClampingCustomScrollView extends CustomScrollView { super.center, double anchor = 0.0, super.scrollCacheExtent, + this.scrollOffsetCorrection, + this.suppressAutoBottomTracking, super.slivers, super.semanticChildCount, super.dragStartBehavior, @@ -29,6 +31,8 @@ class ClampingCustomScrollView extends CustomScrollView { // [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so // we need our own version. final double _anchor; + final ScrollOffsetCorrectionCallback? scrollOffsetCorrection; + final bool Function()? suppressAutoBottomTracking; @override double get anchor => _anchor; @@ -56,6 +60,8 @@ class ClampingCustomScrollView extends CustomScrollView { scrollCacheExtent: scrollCacheExtent, center: center, anchor: anchor, + scrollOffsetCorrection: scrollOffsetCorrection, + suppressAutoBottomTracking: suppressAutoBottomTracking, ); } } diff --git a/lib/widgets/clamping_custom_scroll_view/clamping_viewport.dart b/lib/widgets/clamping_custom_scroll_view/clamping_viewport.dart index acf3911d25..c673484a0c 100644 --- a/lib/widgets/clamping_custom_scroll_view/clamping_viewport.dart +++ b/lib/widgets/clamping_custom_scroll_view/clamping_viewport.dart @@ -4,9 +4,12 @@ import 'dart:math' as math; +import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +typedef ScrollOffsetCorrectionCallback = double? Function(); + /// A render object that is bigger on the inside. /// /// Version of [Viewport] with some modifications to how extents are @@ -22,11 +25,15 @@ class ClampingViewport extends Viewport { super.center, super.scrollCacheExtent, super.slivers, + this.scrollOffsetCorrection, + this.suppressAutoBottomTracking, }) : _anchor = anchor; // [Viewport] enforces constraints on [Viewport.anchor], so we need our own // version. final double _anchor; + final ScrollOffsetCorrectionCallback? scrollOffsetCorrection; + final bool Function()? suppressAutoBottomTracking; @override double get anchor => _anchor; @@ -41,7 +48,22 @@ class ClampingViewport extends Viewport { anchor: anchor, offset: offset, scrollCacheExtent: scrollCacheExtent, + scrollOffsetCorrection: scrollOffsetCorrection, + suppressAutoBottomTracking: suppressAutoBottomTracking, ); + + @override + void updateRenderObject( + BuildContext context, + RenderViewport renderObject, + ) { + super.updateRenderObject(context, renderObject); + if (renderObject is ClampingRenderViewport) { + renderObject + ..scrollOffsetCorrection = scrollOffsetCorrection + ..suppressAutoBottomTracking = suppressAutoBottomTracking; + } + } } /// A render object that is bigger on the inside. @@ -62,6 +84,8 @@ class ClampingRenderViewport extends RenderViewport { super.children, super.center, super.scrollCacheExtent, + this.scrollOffsetCorrection, + this.suppressAutoBottomTracking, }) : _anchor = anchor; static const int _maxLayoutCycles = 10; @@ -72,6 +96,8 @@ class ClampingRenderViewport extends RenderViewport { late double _minScrollExtent; late double _maxScrollExtent; bool _hasVisualOverflow = false; + ScrollOffsetCorrectionCallback? scrollOffsetCorrection; + bool Function()? suppressAutoBottomTracking; /// This value is set during layout based on the [CacheExtentStyle]. /// @@ -216,6 +242,7 @@ class ClampingRenderViewport extends RenderViewport { final suppressBottomTracking = _suppressBottomTracking; try { if (!suppressBottomTracking && + suppressAutoBottomTracking?.call() != true && maxScrollOffsetChanged && _lastPositionIsBottom && correction > 0) { @@ -227,6 +254,20 @@ class ClampingRenderViewport extends RenderViewport { _lastMaxScrollOffset = maxScrollOffset; } + final anchorCorrection = scrollOffsetCorrection?.call(); + if (anchorCorrection != null && + anchorCorrection.abs() > precisionErrorTolerance) { + final correctedOffset = (offset.pixels + anchorCorrection) + .clamp(minScrollOffset, maxScrollOffset) + .toDouble(); + final appliedCorrection = correctedOffset - offset.pixels; + if (appliedCorrection.abs() > precisionErrorTolerance) { + offset.correctBy(appliedCorrection); + count += 1; + continue; + } + } + if (offset.applyContentDimensions( minScrollOffset.toDouble(), maxScrollOffset.toDouble(), diff --git a/test/ui/home/chat/chat_scroll_coordinator_test.dart b/test/ui/home/chat/chat_scroll_coordinator_test.dart index 3cb88eccb2..c9513919b0 100644 --- a/test/ui/home/chat/chat_scroll_coordinator_test.dart +++ b/test/ui/home/chat/chat_scroll_coordinator_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_app/db/mixin_database.dart' hide Offset; import 'package:flutter_app/ui/home/chat/chat_scroll_coordinator.dart'; +import 'package:flutter_app/widgets/clamping_custom_scroll_view/clamping_custom_scroll_view.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; @@ -507,6 +508,66 @@ void main() { expect(coordinator.trackingScrollController.jumpCount, 0); }); + testWidgets( + 'scheduleRestore preserves visible message position during non-reset paging', + (tester) async { + final coordinator = TrackingChatScrollCoordinator(); + final centerKey = GlobalKey(); + final messages = List.generate(30, testMessage); + final prependedMessages = List.generate( + 3, + (index) => testMessage(-3 + index), + ); + final nextMessages = [...prependedMessages, ...messages]; + final keysByMessageId = { + for (final message in nextMessages) message.messageId: GlobalKey(), + }; + final anchorMessageId = messages[10].messageId; + + addTearDown(coordinator.dispose); + await pumpAnchorCorrectingMessages( + tester, + coordinator, + messages, + keysByMessageId, + centerKey: centerKey, + ); + coordinator.scrollController.jumpTo(800); + await tester.pump(); + + final beforeTop = messageTop( + coordinator, + keysByMessageId[anchorMessageId]!, + ); + coordinator.captureViewportState(messages, keysByMessageId); + + await pumpAnchorCorrectingMessages( + tester, + coordinator, + nextMessages, + keysByMessageId, + centerKey: centerKey, + ); + expect( + messageTop(coordinator, keysByMessageId[anchorMessageId]!), + moreOrLessEquals(beforeTop, epsilon: 0.1), + ); + + coordinator.scheduleRestore( + messages: nextMessages, + keysByMessageId: keysByMessageId, + reset: false, + isLatest: false, + ); + await tester.pump(); + + expect( + messageTop(coordinator, keysByMessageId[anchorMessageId]!), + moreOrLessEquals(beforeTop, epsilon: 0.1), + ); + }, + ); + testWidgets('scheduleRestore animates tail-follow message appends', ( tester, ) async { @@ -666,6 +727,57 @@ void main() { }, ); + testWidgets('tail-follow append keeps old bottom until restore animates', ( + tester, + ) async { + final coordinator = TrackingChatScrollCoordinator(); + final centerKey = GlobalKey(); + final top = List.generate(8, testMessage); + final bottom = List.generate(8, (index) => testMessage(index + 100)); + final nextBottom = [...bottom, testMessage(200)]; + final messages = [...top, ...bottom]; + final nextMessages = [...top, ...nextBottom]; + final keysByMessageId = { + for (final message in nextMessages) message.messageId: GlobalKey(), + }; + + addTearDown(coordinator.dispose); + await pumpCenteredScrollableMessages( + tester, + coordinator, + centerKey: centerKey, + top: top, + bottom: bottom, + keysByMessageId: keysByMessageId, + useClampingViewport: true, + suppressAutoBottomTracking: coordinator.shouldSuppressAutoBottomTracking, + ); + coordinator.scrollController.jumpTo( + coordinator.scrollController.position.maxScrollExtent, + ); + await tester.pump(); + final oldBottomOffset = coordinator.scrollController.offset; + coordinator.captureViewportState(messages, keysByMessageId); + + await pumpCenteredScrollableMessages( + tester, + coordinator, + centerKey: centerKey, + top: top, + bottom: nextBottom, + keysByMessageId: keysByMessageId, + useClampingViewport: true, + suppressAutoBottomTracking: coordinator.shouldSuppressAutoBottomTracking, + ); + await tester.pump(); + + expect(coordinator.scrollController.offset, oldBottomOffset); + expect( + coordinator.scrollController.position.maxScrollExtent, + greaterThan(oldBottomOffset), + ); + }); + testWidgets('scheduleRestore animates requested latest resets', ( tester, ) async { @@ -1329,6 +1441,14 @@ class CountingKeyMap extends MapBase { GlobalKey? remove(Object? key) => _delegate.remove(key); } +double messageTop(ChatScrollCoordinator coordinator, GlobalKey key) { + final render = key.currentContext!.findRenderObject()! as RenderBox; + final viewport = + coordinator.viewportKey.currentContext!.findRenderObject()! as RenderBox; + return render.localToGlobal(Offset.zero).dy - + viewport.localToGlobal(Offset.zero).dy; +} + Future pumpScrollableMessages( WidgetTester tester, ChatScrollCoordinator coordinator, @@ -1372,6 +1492,49 @@ Future pumpScrollableMessages( ); } +Future pumpAnchorCorrectingMessages( + WidgetTester tester, + ChatScrollCoordinator coordinator, + List messages, + Map keysByMessageId, { + required GlobalKey centerKey, + double itemHeight = 80, + double viewportHeight = 600, +}) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 320, + height: viewportHeight, + child: ClampingCustomScrollView( + key: coordinator.viewportKey, + center: centerKey, + controller: coordinator.scrollController, + scrollOffsetCorrection: coordinator.takeViewportAnchorCorrection, + slivers: [ + SliverToBoxAdapter(key: centerKey, child: const SizedBox()), + SliverList.builder( + itemCount: messages.length, + itemBuilder: (context, index) { + final messageId = messages[index].messageId; + return ChatRenderedMessage( + coordinator: coordinator, + messageId: messageId, + child: SizedBox( + key: keysByMessageId[messageId], + height: itemHeight, + ), + ); + }, + ), + ], + ), + ), + ), + ); +} + Future pumpFullyBuiltScrollableMessages( WidgetTester tester, ChatScrollCoordinator coordinator, @@ -1414,52 +1577,64 @@ Future pumpCenteredScrollableMessages( required List top, required List bottom, required Map keysByMessageId, + bool useClampingViewport = false, + bool Function()? suppressAutoBottomTracking, double itemHeight = 80, double viewportHeight = 240, }) async { + final slivers = [ + SliverList.builder( + itemCount: top.length, + itemBuilder: (context, index) { + final message = top[top.length - index - 1]; + return ChatRenderedMessage( + coordinator: coordinator, + messageId: message.messageId, + child: SizedBox( + key: keysByMessageId[message.messageId], + height: itemHeight, + ), + ); + }, + ), + SliverToBoxAdapter(key: centerKey, child: const SizedBox()), + SliverList.builder( + itemCount: bottom.length, + itemBuilder: (context, index) { + final message = bottom[index]; + return ChatRenderedMessage( + coordinator: coordinator, + messageId: message.messageId, + child: SizedBox( + key: keysByMessageId[message.messageId], + height: itemHeight, + ), + ); + }, + ), + ]; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: SizedBox( width: 320, height: viewportHeight, - child: CustomScrollView( - key: coordinator.viewportKey, - center: centerKey, - controller: coordinator.scrollController, - anchor: ChatScrollCoordinator.messageFocusAnchor, - slivers: [ - SliverList.builder( - itemCount: top.length, - itemBuilder: (context, index) { - final message = top[top.length - index - 1]; - return ChatRenderedMessage( - coordinator: coordinator, - messageId: message.messageId, - child: SizedBox( - key: keysByMessageId[message.messageId], - height: itemHeight, - ), - ); - }, - ), - SliverToBoxAdapter(key: centerKey, child: const SizedBox()), - SliverList.builder( - itemCount: bottom.length, - itemBuilder: (context, index) { - final message = bottom[index]; - return ChatRenderedMessage( - coordinator: coordinator, - messageId: message.messageId, - child: SizedBox( - key: keysByMessageId[message.messageId], - height: itemHeight, - ), - ); - }, - ), - ], - ), + child: useClampingViewport + ? ClampingCustomScrollView( + key: coordinator.viewportKey, + center: centerKey, + controller: coordinator.scrollController, + anchor: ChatScrollCoordinator.messageFocusAnchor, + suppressAutoBottomTracking: suppressAutoBottomTracking, + slivers: slivers, + ) + : CustomScrollView( + key: coordinator.viewportKey, + center: centerKey, + controller: coordinator.scrollController, + anchor: ChatScrollCoordinator.messageFocusAnchor, + slivers: slivers, + ), ), ), ); From 5577927bd87c44bfcc312fd0c6541cfe980d637e Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:53:53 +0900 Subject: [PATCH 2/2] Guard chat scroll geometry during layout --- lib/ui/home/chat/chat_scroll_coordinator.dart | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/lib/ui/home/chat/chat_scroll_coordinator.dart b/lib/ui/home/chat/chat_scroll_coordinator.dart index 44a8c07de1..01d323655d 100644 --- a/lib/ui/home/chat/chat_scroll_coordinator.dart +++ b/lib/ui/home/chat/chat_scroll_coordinator.dart @@ -532,8 +532,12 @@ class ChatScrollCoordinator { if (render == null || viewport == null) return null; if (!render.hasSize || !viewport.hasSize) return null; - final viewportTop = viewport.localToGlobal(Offset.zero).dy; - final top = render.localToGlobal(Offset.zero).dy - viewportTop; + final viewportOrigin = _globalOrigin(viewport); + final renderOrigin = _globalOrigin(render); + if (viewportOrigin == null || renderOrigin == null) return null; + + final viewportTop = viewportOrigin.dy; + final top = renderOrigin.dy - viewportTop; final height = render.size.height; final target = scrollController.offset + @@ -556,8 +560,23 @@ class ChatScrollCoordinator { if (render == null || viewport == null) return null; if (!render.hasSize || !viewport.hasSize) return null; - final viewportTop = viewport.localToGlobal(Offset.zero).dy; - return render.localToGlobal(Offset.zero).dy - viewportTop; + final viewportOrigin = _globalOrigin(viewport); + final renderOrigin = _globalOrigin(render); + if (viewportOrigin == null || renderOrigin == null) return null; + + return renderOrigin.dy - viewportOrigin.dy; + } + + Offset? _globalOrigin(RenderBox render) { + try { + return render.localToGlobal(Offset.zero); + } catch (error) { + if (error is StateError && + error.message.contains('RenderBox was not laid out')) { + return null; + } + rethrow; + } } bool _isTargetInLoadedJumpWindow(double target) {