Skip to content

Commit 6424c6e

Browse files
authored
Fix chat tail-follow jitter and layout-safe geometry (#2002)
* Fix bottom message tail-follow jitter * Guard chat scroll geometry during layout
1 parent fa7ca33 commit 6424c6e

5 files changed

Lines changed: 376 additions & 39 deletions

File tree

lib/ui/home/chat/chat_history_viewport.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ class ChatHistoryViewport extends HookConsumerWidget {
182182
scrollCacheExtent: const ScrollCacheExtent.viewport(
183183
ChatScrollCoordinator.loadedJumpViewportCount,
184184
),
185+
scrollOffsetCorrection:
186+
scrollCoordinator.takeViewportAnchorCorrection,
187+
suppressAutoBottomTracking:
188+
scrollCoordinator.shouldSuppressAutoBottomTracking,
185189
slivers: [
186190
SliverList(
187191
delegate: SliverChildBuilderDelegate((context, index) {

lib/ui/home/chat/chat_scroll_coordinator.dart

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ class ChatScrollCoordinator {
9191
final _renderedMessageIds = <String>{};
9292
bool _tailFollowEligible = true;
9393
_TailFollowAnchorSnapshot? _tailFollowAnchorSnapshot;
94+
bool _tailFollowMessageWindowChanged = false;
95+
_ViewportAnchorSnapshot? _viewportAnchorSnapshot;
9496
bool _animateNextRestore = false;
9597
ChatScrollRestoreDirection? _animatedRestoreDirection;
9698
bool _restoreScheduled = false;
@@ -111,15 +113,25 @@ class ChatScrollCoordinator {
111113
Map<String, GlobalKey> keysByMessageId,
112114
) {
113115
if (!scrollController.hasClients) return;
116+
final messageWindowChanged = !_hasSameMessageIds(messages);
114117
_setMessages(messages, keysByMessageId);
115118
_tailFollowEligible = _isTailFollowEligible();
116119
_tailFollowAnchorSnapshot = _tailFollowEligible
117120
? _tailFollowAnchorSnapshotFromViewport()
118121
: null;
122+
_tailFollowMessageWindowChanged =
123+
_tailFollowEligible &&
124+
messageWindowChanged &&
125+
_tailFollowAnchorSnapshot != null;
126+
_viewportAnchorSnapshot = _tailFollowEligible
127+
? null
128+
: _viewportAnchorSnapshotFromViewport();
119129
traceChatScroll(
120130
'capture tailEligible=$_tailFollowEligible '
121131
'anchor=${shortMessageId(_tailFollowAnchorSnapshot?.messageId)} '
122132
'anchorBottom=${formatDouble(_tailFollowAnchorSnapshot?.bottom)} '
133+
'viewportAnchor=${shortMessageId(_viewportAnchorSnapshot?.messageId)} '
134+
'viewportTop=${formatDouble(_viewportAnchorSnapshot?.top)} '
123135
'${_formatScrollState()}',
124136
);
125137
}
@@ -189,6 +201,9 @@ class ChatScrollCoordinator {
189201
_animateNextRestore ||
190202
animateLatestReset);
191203
if (reset) {
204+
_tailFollowAnchorSnapshot = null;
205+
_tailFollowMessageWindowChanged = false;
206+
_viewportAnchorSnapshot = null;
192207
_animatedRestoreMessageId = null;
193208
_animatedRestoreDirection = null;
194209
_animatedRestoreComplete = null;
@@ -371,6 +386,38 @@ class ChatScrollCoordinator {
371386
return true;
372387
}
373388

389+
double? takeViewportAnchorCorrection() {
390+
final snapshot = _viewportAnchorSnapshot;
391+
_viewportAnchorSnapshot = null;
392+
if (snapshot == null || !scrollController.hasClients) return null;
393+
final top = _messageTopInViewport(
394+
snapshot.messageId,
395+
_keysByMessageId,
396+
);
397+
if (top == null) {
398+
traceChatJump(
399+
'viewport-anchor missing anchor=${shortMessageId(snapshot.messageId)} '
400+
'${formatScrollMetrics(scrollController.position)}',
401+
);
402+
return null;
403+
}
404+
405+
final delta = top - snapshot.top;
406+
if (delta.abs() <= _jumpToTolerance) return null;
407+
traceChatJump(
408+
'viewport-anchor correction anchor=${shortMessageId(snapshot.messageId)} '
409+
'delta=${formatDouble(delta)} ${formatScrollMetrics(scrollController.position)}',
410+
);
411+
traceChatScroll(
412+
'viewport-anchor correction anchor=${shortMessageId(snapshot.messageId)} '
413+
'delta=${formatDouble(delta)} ${_formatScrollState()}',
414+
);
415+
return delta;
416+
}
417+
418+
bool shouldSuppressAutoBottomTracking() =>
419+
_tailFollowMessageWindowChanged && _tailFollowAnchorSnapshot != null;
420+
374421
void dispose() {
375422
_disposed = true;
376423
scrollController
@@ -483,9 +530,14 @@ class ChatScrollCoordinator {
483530
final render = _messageRender(messageId, keysByMessageId);
484531
final viewport = _viewportRender;
485532
if (render == null || viewport == null) return null;
533+
if (!render.hasSize || !viewport.hasSize) return null;
534+
535+
final viewportOrigin = _globalOrigin(viewport);
536+
final renderOrigin = _globalOrigin(render);
537+
if (viewportOrigin == null || renderOrigin == null) return null;
486538

487-
final viewportTop = viewport.localToGlobal(Offset.zero).dy;
488-
final top = render.localToGlobal(Offset.zero).dy - viewportTop;
539+
final viewportTop = viewportOrigin.dy;
540+
final top = renderOrigin.dy - viewportTop;
489541
final height = render.size.height;
490542
final target =
491543
scrollController.offset +
@@ -499,6 +551,34 @@ class ChatScrollCoordinator {
499551
);
500552
}
501553

554+
double? _messageTopInViewport(
555+
String messageId,
556+
Map<String, GlobalKey> keysByMessageId,
557+
) {
558+
final render = _messageRender(messageId, keysByMessageId);
559+
final viewport = _viewportRender;
560+
if (render == null || viewport == null) return null;
561+
if (!render.hasSize || !viewport.hasSize) return null;
562+
563+
final viewportOrigin = _globalOrigin(viewport);
564+
final renderOrigin = _globalOrigin(render);
565+
if (viewportOrigin == null || renderOrigin == null) return null;
566+
567+
return renderOrigin.dy - viewportOrigin.dy;
568+
}
569+
570+
Offset? _globalOrigin(RenderBox render) {
571+
try {
572+
return render.localToGlobal(Offset.zero);
573+
} catch (error) {
574+
if (error is StateError &&
575+
error.message.contains('RenderBox was not laid out')) {
576+
return null;
577+
}
578+
rethrow;
579+
}
580+
}
581+
502582
bool _isTargetInLoadedJumpWindow(double target) {
503583
if (!scrollController.hasClients) return false;
504584
final position = scrollController.position;
@@ -745,9 +825,30 @@ class ChatScrollCoordinator {
745825
return snapshot;
746826
}
747827

828+
_ViewportAnchorSnapshot? _viewportAnchorSnapshotFromViewport() {
829+
_ViewportAnchorSnapshot? snapshot;
830+
final viewportHeight = scrollController.position.viewportDimension;
831+
for (final messageId in _currentRenderedMessageIds()) {
832+
final geometry = _messageTargetGeometry(messageId, _keysByMessageId);
833+
if (geometry == null ||
834+
geometry.bottom <= 0 ||
835+
geometry.top >= viewportHeight) {
836+
continue;
837+
}
838+
if (snapshot == null || geometry.top < snapshot.top) {
839+
snapshot = _ViewportAnchorSnapshot(
840+
messageId: messageId,
841+
top: geometry.top,
842+
);
843+
}
844+
}
845+
return snapshot;
846+
}
847+
748848
void _restoreTailFollowAnimationStart() {
749849
final snapshot = _tailFollowAnchorSnapshot;
750850
_tailFollowAnchorSnapshot = null;
851+
_tailFollowMessageWindowChanged = false;
751852
if (snapshot == null || !scrollController.hasClients) return;
752853
final geometry = _messageTargetGeometry(
753854
snapshot.messageId,
@@ -985,6 +1086,16 @@ class _TailFollowAnchorSnapshot {
9851086
final double bottom;
9861087
}
9871088

1089+
class _ViewportAnchorSnapshot {
1090+
const _ViewportAnchorSnapshot({
1091+
required this.messageId,
1092+
required this.top,
1093+
});
1094+
1095+
final String messageId;
1096+
final double top;
1097+
}
1098+
9881099
class _MessageTargetGeometry {
9891100
const _MessageTargetGeometry({
9901101
required this.target,

lib/widgets/clamping_custom_scroll_view/clamping_custom_scroll_view.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class ClampingCustomScrollView extends CustomScrollView {
2121
super.center,
2222
double anchor = 0.0,
2323
super.scrollCacheExtent,
24+
this.scrollOffsetCorrection,
25+
this.suppressAutoBottomTracking,
2426
super.slivers,
2527
super.semanticChildCount,
2628
super.dragStartBehavior,
@@ -29,6 +31,8 @@ class ClampingCustomScrollView extends CustomScrollView {
2931
// [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so
3032
// we need our own version.
3133
final double _anchor;
34+
final ScrollOffsetCorrectionCallback? scrollOffsetCorrection;
35+
final bool Function()? suppressAutoBottomTracking;
3236

3337
@override
3438
double get anchor => _anchor;
@@ -56,6 +60,8 @@ class ClampingCustomScrollView extends CustomScrollView {
5660
scrollCacheExtent: scrollCacheExtent,
5761
center: center,
5862
anchor: anchor,
63+
scrollOffsetCorrection: scrollOffsetCorrection,
64+
suppressAutoBottomTracking: suppressAutoBottomTracking,
5965
);
6066
}
6167
}

lib/widgets/clamping_custom_scroll_view/clamping_viewport.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
import 'dart:math' as math;
66

7+
import 'package:flutter/foundation.dart';
78
import 'package:flutter/rendering.dart';
89
import 'package:flutter/widgets.dart';
910

11+
typedef ScrollOffsetCorrectionCallback = double? Function();
12+
1013
/// A render object that is bigger on the inside.
1114
///
1215
/// Version of [Viewport] with some modifications to how extents are
@@ -22,11 +25,15 @@ class ClampingViewport extends Viewport {
2225
super.center,
2326
super.scrollCacheExtent,
2427
super.slivers,
28+
this.scrollOffsetCorrection,
29+
this.suppressAutoBottomTracking,
2530
}) : _anchor = anchor;
2631

2732
// [Viewport] enforces constraints on [Viewport.anchor], so we need our own
2833
// version.
2934
final double _anchor;
35+
final ScrollOffsetCorrectionCallback? scrollOffsetCorrection;
36+
final bool Function()? suppressAutoBottomTracking;
3037

3138
@override
3239
double get anchor => _anchor;
@@ -41,7 +48,22 @@ class ClampingViewport extends Viewport {
4148
anchor: anchor,
4249
offset: offset,
4350
scrollCacheExtent: scrollCacheExtent,
51+
scrollOffsetCorrection: scrollOffsetCorrection,
52+
suppressAutoBottomTracking: suppressAutoBottomTracking,
4453
);
54+
55+
@override
56+
void updateRenderObject(
57+
BuildContext context,
58+
RenderViewport renderObject,
59+
) {
60+
super.updateRenderObject(context, renderObject);
61+
if (renderObject is ClampingRenderViewport) {
62+
renderObject
63+
..scrollOffsetCorrection = scrollOffsetCorrection
64+
..suppressAutoBottomTracking = suppressAutoBottomTracking;
65+
}
66+
}
4567
}
4668

4769
/// A render object that is bigger on the inside.
@@ -62,6 +84,8 @@ class ClampingRenderViewport extends RenderViewport {
6284
super.children,
6385
super.center,
6486
super.scrollCacheExtent,
87+
this.scrollOffsetCorrection,
88+
this.suppressAutoBottomTracking,
6589
}) : _anchor = anchor;
6690

6791
static const int _maxLayoutCycles = 10;
@@ -72,6 +96,8 @@ class ClampingRenderViewport extends RenderViewport {
7296
late double _minScrollExtent;
7397
late double _maxScrollExtent;
7498
bool _hasVisualOverflow = false;
99+
ScrollOffsetCorrectionCallback? scrollOffsetCorrection;
100+
bool Function()? suppressAutoBottomTracking;
75101

76102
/// This value is set during layout based on the [CacheExtentStyle].
77103
///
@@ -216,6 +242,7 @@ class ClampingRenderViewport extends RenderViewport {
216242
final suppressBottomTracking = _suppressBottomTracking;
217243
try {
218244
if (!suppressBottomTracking &&
245+
suppressAutoBottomTracking?.call() != true &&
219246
maxScrollOffsetChanged &&
220247
_lastPositionIsBottom &&
221248
correction > 0) {
@@ -227,6 +254,20 @@ class ClampingRenderViewport extends RenderViewport {
227254
_lastMaxScrollOffset = maxScrollOffset;
228255
}
229256

257+
final anchorCorrection = scrollOffsetCorrection?.call();
258+
if (anchorCorrection != null &&
259+
anchorCorrection.abs() > precisionErrorTolerance) {
260+
final correctedOffset = (offset.pixels + anchorCorrection)
261+
.clamp(minScrollOffset, maxScrollOffset)
262+
.toDouble();
263+
final appliedCorrection = correctedOffset - offset.pixels;
264+
if (appliedCorrection.abs() > precisionErrorTolerance) {
265+
offset.correctBy(appliedCorrection);
266+
count += 1;
267+
continue;
268+
}
269+
}
270+
230271
if (offset.applyContentDimensions(
231272
minScrollOffset.toDouble(),
232273
maxScrollOffset.toDouble(),

0 commit comments

Comments
 (0)