Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/ui/home/chat/chat_history_viewport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
115 changes: 113 additions & 2 deletions lib/ui/home/chat/chat_scroll_coordinator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ class ChatScrollCoordinator {
final _renderedMessageIds = <String>{};
bool _tailFollowEligible = true;
_TailFollowAnchorSnapshot? _tailFollowAnchorSnapshot;
bool _tailFollowMessageWindowChanged = false;
_ViewportAnchorSnapshot? _viewportAnchorSnapshot;
bool _animateNextRestore = false;
ChatScrollRestoreDirection? _animatedRestoreDirection;
bool _restoreScheduled = false;
Expand All @@ -111,15 +113,25 @@ class ChatScrollCoordinator {
Map<String, GlobalKey> 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()}',
);
}
Expand Down Expand Up @@ -189,6 +201,9 @@ class ChatScrollCoordinator {
_animateNextRestore ||
animateLatestReset);
if (reset) {
_tailFollowAnchorSnapshot = null;
_tailFollowMessageWindowChanged = false;
_viewportAnchorSnapshot = null;
_animatedRestoreMessageId = null;
_animatedRestoreDirection = null;
_animatedRestoreComplete = null;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -483,9 +530,14 @@ 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 viewportOrigin = _globalOrigin(viewport);
final renderOrigin = _globalOrigin(render);
if (viewportOrigin == null || renderOrigin == null) return null;

final viewportTop = viewport.localToGlobal(Offset.zero).dy;
final top = render.localToGlobal(Offset.zero).dy - viewportTop;
final viewportTop = viewportOrigin.dy;
final top = renderOrigin.dy - viewportTop;
final height = render.size.height;
final target =
scrollController.offset +
Expand All @@ -499,6 +551,34 @@ class ChatScrollCoordinator {
);
}

double? _messageTopInViewport(
String messageId,
Map<String, GlobalKey> keysByMessageId,
) {
final render = _messageRender(messageId, keysByMessageId);
final viewport = _viewportRender;
if (render == null || viewport == null) return null;
if (!render.hasSize || !viewport.hasSize) return null;

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) {
if (!scrollController.hasClients) return false;
final position = scrollController.position;
Expand Down Expand Up @@ -745,9 +825,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,
Expand Down Expand Up @@ -985,6 +1086,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -56,6 +60,8 @@ class ClampingCustomScrollView extends CustomScrollView {
scrollCacheExtent: scrollCacheExtent,
center: center,
anchor: anchor,
scrollOffsetCorrection: scrollOffsetCorrection,
suppressAutoBottomTracking: suppressAutoBottomTracking,
);
}
}
41 changes: 41 additions & 0 deletions lib/widgets/clamping_custom_scroll_view/clamping_viewport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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.
Expand All @@ -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;
Expand All @@ -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].
///
Expand Down Expand Up @@ -216,6 +242,7 @@ class ClampingRenderViewport extends RenderViewport {
final suppressBottomTracking = _suppressBottomTracking;
try {
if (!suppressBottomTracking &&
suppressAutoBottomTracking?.call() != true &&
maxScrollOffsetChanged &&
_lastPositionIsBottom &&
correction > 0) {
Expand All @@ -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(),
Expand Down
Loading
Loading