mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-10-20 11:34:48 +00:00
Keep chat scrollbar thumb stable and inset it from the composer (#734)
* Keep chat scrollbar thumb stable and inset it from the composer Estimate unbuilt row extents from heights recorded while rows were mounted instead of the built-row average, so a long response no longer makes the thumb jump. Give the platform scrollbar toolbar and composer insets while rows keep the real window padding. * Address review: median fallback, extent invalidation, safe-area padding Estimate never-measured rows from the median known height so one tall response cannot move the estimate as short rows mount. Drop remembered extents when the viewport width or text scale changes. Keep horizontal safe-area padding on the scrollbar track. * Forget row extents when row content or builder changes * Forget row extents when horizontal padding changes * Estimate unmeasured chat rows from their text length Right after a chat opens only the rows near the bottom are measured, so a long latest response made the median guess huge for every older row and the thumb jumped as short rows mounted. The chat page now supplies a per-row estimate from message text length, which depends on nothing but the row itself. * Scale row extent estimates by the text scale factor * Test the row estimate against a row that is never built * Remember measured row heights and estimate by row kind Measured row heights now go into a process-wide LRU keyed by message id, content length, viewport width, and text scale, so a chat scrolled before reopens with exact extents. The text-length estimate gains flat terms for user bubbles, attachments, images, and follow-ups, mirroring paseo's per-kind estimates. * Key remembered row extents on a full layout signature Content hash plus follow-up, attachment, file, code-execution, source, and version counts, so a same-length edit or new follow-ups never reuse a stale measured height. * Include streaming, status, and archived state in the extent key
This commit is contained in:
parent
c552008a3c
commit
446975413b
3 changed files with 521 additions and 10 deletions
|
|
@ -2945,11 +2945,69 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||
positionSettled: _pinToTopPositionSettled,
|
||||
);
|
||||
|
||||
final viewportWidth = MediaQuery.sizeOf(context).width;
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1);
|
||||
final extentMemory = ChatRowExtentMemory.instance;
|
||||
ChatMessage? historyMessageAtRenderIndex(int renderIndex) {
|
||||
final sourceIndex = timeline.sourceIndexAtRenderIndex(renderIndex);
|
||||
if (sourceIndex == null ||
|
||||
sourceIndex < 0 ||
|
||||
sourceIndex >= timeline.historyMessages.length) {
|
||||
return null;
|
||||
}
|
||||
return timeline.historyMessages[sourceIndex];
|
||||
}
|
||||
|
||||
bool? layoutRowIsArchived(String messageId) {
|
||||
final row = layoutMetadata.indexByMessageId[messageId];
|
||||
return row == null ? null : layoutMetadata.rows[row].isArchivedVariant;
|
||||
}
|
||||
|
||||
String extentKeyFor(ChatMessage message) => ChatRowExtentMemory.keyFor(
|
||||
messageId: message.id,
|
||||
layoutSignature: Object.hash(
|
||||
message.content,
|
||||
message.isStreaming,
|
||||
message.statusHistory.length,
|
||||
message.followUps.length,
|
||||
message.attachmentIds?.length,
|
||||
message.files?.length,
|
||||
message.codeExecutions.length,
|
||||
message.sources.length,
|
||||
message.versions.length,
|
||||
layoutRowIsArchived(message.id),
|
||||
),
|
||||
viewportWidth: viewportWidth,
|
||||
textScale: textScale,
|
||||
);
|
||||
double? estimateRowExtent(int renderIndex) {
|
||||
final message = historyMessageAtRenderIndex(renderIndex);
|
||||
if (message == null) return null;
|
||||
return extentMemory.lookup(extentKeyFor(message)) ??
|
||||
estimateChatRowExtent(
|
||||
text: message.content,
|
||||
viewportWidth: viewportWidth,
|
||||
textScale: textScale,
|
||||
isUser: message.role == 'user',
|
||||
attachmentCount: message.attachmentIds?.length ?? 0,
|
||||
imageCount: message.files?.length ?? 0,
|
||||
followUpCount: message.followUps.length,
|
||||
);
|
||||
}
|
||||
|
||||
void rememberRowExtent(int renderIndex, double extent) {
|
||||
final message = historyMessageAtRenderIndex(renderIndex);
|
||||
if (message == null || message.isStreaming) return;
|
||||
extentMemory.record(extentKeyFor(message), extent);
|
||||
}
|
||||
|
||||
return ChatTimelineViewport(
|
||||
controller: _timelineViewportController,
|
||||
ownerGeneration: _conversationOwnerGeneration,
|
||||
messageIds: messageIds,
|
||||
rowRebuildKeys: _rowRebuildKeysMemo,
|
||||
estimateRowExtent: estimateRowExtent,
|
||||
onRowExtentMeasured: rememberRowExtent,
|
||||
initialAnchor: _initialScrollAnchor,
|
||||
pinnedUserMessageId: _wantsPinToTop ? _pinnedUserMessageId : null,
|
||||
liveFooter: timeline.runningFooterHost == null
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
|
||||
|
|
@ -283,6 +284,8 @@ class ChatTimelineViewport extends StatefulWidget {
|
|||
this.trailingContent,
|
||||
this.hideUntilSettled = false,
|
||||
this.rowRebuildKeys = const <Object?>[],
|
||||
this.estimateRowExtent,
|
||||
this.onRowExtentMeasured,
|
||||
super.key,
|
||||
}) : assert(
|
||||
rowRebuildKeys.length == 0 ||
|
||||
|
|
@ -298,6 +301,15 @@ class ChatTimelineViewport extends StatefulWidget {
|
|||
final int ownerGeneration;
|
||||
final List<String> messageIds;
|
||||
final List<Object?> rowRebuildKeys;
|
||||
|
||||
/// Height guess for a row at a [messageIds] index that has never been laid
|
||||
/// out. It must depend only on the row's own content so the scroll extent
|
||||
/// stays stable while other rows mount and unmount.
|
||||
final double? Function(int sourceIndex)? estimateRowExtent;
|
||||
|
||||
/// Reports a measured row height for a [messageIds] index whenever it
|
||||
/// changes, so callers can remember it beyond this viewport's lifetime.
|
||||
final void Function(int sourceIndex, double extent)? onRowExtentMeasured;
|
||||
final ChatScrollAnchor? initialAnchor;
|
||||
final ChatTimelineRowBuilder rowBuilder;
|
||||
final String? pinnedUserMessageId;
|
||||
|
|
@ -362,6 +374,8 @@ class _ChatTimelineViewportState extends State<ChatTimelineViewport>
|
|||
final GlobalKey _endSentinelKey = GlobalKey();
|
||||
final ValueNotifier<double> _pinSupportSpace = ValueNotifier<double>(0);
|
||||
final Map<String, GlobalKey> _rowKeys = <String, GlobalKey>{};
|
||||
final Map<String, double> _rowExtents = <String, double>{};
|
||||
(double, TextScaler)? _rowExtentLayoutInputs;
|
||||
final Map<String, int> _mountedRowCounts = <String, int>{};
|
||||
final Map<String, ({int sourceIndex, Object? rebuildKey, Widget widget})>
|
||||
_rowWidgetCache = {};
|
||||
|
|
@ -433,6 +447,21 @@ class _ChatTimelineViewportState extends State<ChatTimelineViewport>
|
|||
_scheduleInitialPositionCallback(_restoreInitialPosition);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Row heights depend on the viewport width and text scale; remembered
|
||||
// extents from another layout would misestimate unbuilt rows.
|
||||
final layoutInputs = (
|
||||
MediaQuery.sizeOf(context).width,
|
||||
MediaQuery.textScalerOf(context),
|
||||
);
|
||||
if (_rowExtentLayoutInputs != layoutInputs) {
|
||||
_rowExtentLayoutInputs = layoutInputs;
|
||||
_rowExtents.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ChatTimelineViewport oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
|
@ -450,6 +479,10 @@ class _ChatTimelineViewportState extends State<ChatTimelineViewport>
|
|||
!identical(oldWidget.rowRebuildKeys, widget.rowRebuildKeys) ||
|
||||
rowBuilderChanged;
|
||||
if (rowBuilderChanged) _rowWidgetCache.clear();
|
||||
if (rowBuilderChanged ||
|
||||
oldWidget.horizontalPadding != widget.horizontalPadding) {
|
||||
_rowExtents.clear();
|
||||
}
|
||||
if ((oldWidget.maintainVisibleAnchor || widget.maintainVisibleAnchor) &&
|
||||
(_freeAnchor == null || _rowRect(_freeAnchor!.messageId) == null)) {
|
||||
// Capture before the new child configuration is laid out. This is the
|
||||
|
|
@ -511,6 +544,7 @@ class _ChatTimelineViewportState extends State<ChatTimelineViewport>
|
|||
_metricsSnapshot = null;
|
||||
_lastReportedMetrics = null;
|
||||
_rowWidgetCache.clear();
|
||||
_rowExtents.clear();
|
||||
_anchorCorrectionAttempts = 0;
|
||||
_initialPositionResolved = false;
|
||||
_initialEmptyFallbackVisible = false;
|
||||
|
|
@ -645,13 +679,16 @@ class _ChatTimelineViewportState extends State<ChatTimelineViewport>
|
|||
for (var index = 0; index < widget.messageIds.length; index += 1) {
|
||||
final id = widget.messageIds[index];
|
||||
if (seen.add(id)) {
|
||||
entries.add((
|
||||
id: id,
|
||||
sourceIndex: index,
|
||||
rebuildKey: widget.rowRebuildKeys.isEmpty
|
||||
? widget.rowBuilder
|
||||
: widget.rowRebuildKeys[index],
|
||||
));
|
||||
final rebuildKey = widget.rowRebuildKeys.isEmpty
|
||||
? widget.rowBuilder
|
||||
: widget.rowRebuildKeys[index];
|
||||
// A changed row may lay out at a different height; forget the
|
||||
// remembered extent so the estimate does not treat it as exact.
|
||||
final cached = _rowWidgetCache[id];
|
||||
if (cached != null && cached.rebuildKey != rebuildKey) {
|
||||
_rowExtents.remove(id);
|
||||
}
|
||||
entries.add((id: id, sourceIndex: index, rebuildKey: rebuildKey));
|
||||
}
|
||||
}
|
||||
_timelineEntries = List.unmodifiable(entries);
|
||||
|
|
@ -666,6 +703,7 @@ class _ChatTimelineViewportState extends State<ChatTimelineViewport>
|
|||
|
||||
void _syncRowKeys() {
|
||||
_rowKeys.removeWhere((id, _) => !_messageIdSet.contains(id));
|
||||
_rowExtents.removeWhere((id, _) => !_messageIdSet.contains(id));
|
||||
for (final id in _messageIds) {
|
||||
_rowKeys.putIfAbsent(id, GlobalKey.new);
|
||||
}
|
||||
|
|
@ -762,6 +800,17 @@ class _ChatTimelineViewportState extends State<ChatTimelineViewport>
|
|||
..multiply(box.getTransformTo(viewport));
|
||||
final rect = MatrixUtils.transformRect(transform, Offset.zero & box.size);
|
||||
if (rect.isFinite) rects[id] = rect;
|
||||
final extent = box.size.height;
|
||||
if (_rowExtents[id] != extent) {
|
||||
_rowExtents[id] = extent;
|
||||
final index = _messageIndexById[id];
|
||||
if (index != null) {
|
||||
widget.onRowExtentMeasured?.call(
|
||||
_timelineEntries[index].sourceIndex,
|
||||
extent,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Map<String, Rect>.unmodifiable(rects);
|
||||
}
|
||||
|
|
@ -1954,6 +2003,9 @@ class _ChatTimelineViewportState extends State<ChatTimelineViewport>
|
|||
rowBuilder: widget.rowBuilder,
|
||||
entries: _timelineEntries,
|
||||
centerIndex: centerIndex,
|
||||
reverse: true,
|
||||
rowExtents: _rowExtents,
|
||||
estimateRowExtent: widget.estimateRowExtent,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -1971,6 +2023,9 @@ class _ChatTimelineViewportState extends State<ChatTimelineViewport>
|
|||
rowBuilder: widget.rowBuilder,
|
||||
entries: _timelineEntries,
|
||||
centerIndex: centerIndex,
|
||||
reverse: false,
|
||||
rowExtents: _rowExtents,
|
||||
estimateRowExtent: widget.estimateRowExtent,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -2023,9 +2078,26 @@ class _ChatTimelineViewportState extends State<ChatTimelineViewport>
|
|||
),
|
||||
);
|
||||
|
||||
final scrollableTranscript = PlatformInfo.isIOS
|
||||
? CupertinoScrollbar(controller: _scrollController, child: transcript)
|
||||
: Scrollbar(controller: _scrollController, child: transcript);
|
||||
// Platform scrollbars take their track insets from MediaQuery padding.
|
||||
// The toolbar and composer float over the transcript, so give the
|
||||
// scrollbar those insets while the rows keep the real window padding.
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final insetTranscript = MediaQuery(data: mediaQuery, child: transcript);
|
||||
final scrollbarMediaQuery = mediaQuery.copyWith(
|
||||
padding: mediaQuery.padding.copyWith(
|
||||
top: math.max(0, widget.topContentInset),
|
||||
bottom: math.max(0, widget.bottomPadding),
|
||||
),
|
||||
);
|
||||
final scrollableTranscript = MediaQuery(
|
||||
data: scrollbarMediaQuery,
|
||||
child: PlatformInfo.isIOS
|
||||
? CupertinoScrollbar(
|
||||
controller: _scrollController,
|
||||
child: insetTranscript,
|
||||
)
|
||||
: Scrollbar(controller: _scrollController, child: insetTranscript),
|
||||
);
|
||||
|
||||
return Stack(
|
||||
key: _viewportKey,
|
||||
|
|
@ -2063,11 +2135,93 @@ class _ChatTimelineViewportState extends State<ChatTimelineViewport>
|
|||
}
|
||||
}
|
||||
|
||||
/// Rough height of a chat row in a [viewportWidth]-wide transcript. Only the
|
||||
/// row's own content feeds in, so the value is O(1) and never changes with
|
||||
/// scroll position; it stands in until the row is measured.
|
||||
double estimateChatRowExtent({
|
||||
required String text,
|
||||
required double viewportWidth,
|
||||
double textScale = 1,
|
||||
bool isUser = false,
|
||||
int attachmentCount = 0,
|
||||
int imageCount = 0,
|
||||
int followUpCount = 0,
|
||||
}) {
|
||||
// ponytail: flat per-kind terms plus body-text lines, as paseo does; code
|
||||
// blocks, tables, and markdown structure are not modelled. Refine only if
|
||||
// the thumb still drifts.
|
||||
final chrome = isUser ? 56.0 : 88.0;
|
||||
final lineHeight = 22.0 * textScale;
|
||||
final glyphWidth = 8.0 * textScale;
|
||||
const minCharsPerLine = 20.0;
|
||||
final charsPerLine = math.max(
|
||||
minCharsPerLine,
|
||||
(viewportWidth - 96) / glyphWidth,
|
||||
);
|
||||
final lines = text.isEmpty
|
||||
? 0
|
||||
: math.max(1, (text.length / charsPerLine).ceil());
|
||||
return chrome +
|
||||
lines * lineHeight +
|
||||
attachmentCount * 72 +
|
||||
imageCount * 220 +
|
||||
followUpCount * 44 * textScale;
|
||||
}
|
||||
|
||||
/// Process-wide memory of measured chat row heights, so a chat that was
|
||||
/// scrolled before reopens with exact extents instead of estimates.
|
||||
///
|
||||
/// Keys carry the message id, a signature of everything in the message that
|
||||
/// affects its height, the viewport width, and the text scale; any of those
|
||||
/// changing yields a fresh key, so an entry can never describe a row laid
|
||||
/// out under different inputs.
|
||||
class ChatRowExtentMemory {
|
||||
ChatRowExtentMemory._();
|
||||
|
||||
static final ChatRowExtentMemory instance = ChatRowExtentMemory._();
|
||||
|
||||
static const int _capacity = 2000;
|
||||
|
||||
final LinkedHashMap<String, double> _extents =
|
||||
LinkedHashMap<String, double>();
|
||||
|
||||
static String keyFor({
|
||||
required String messageId,
|
||||
required int layoutSignature,
|
||||
required double viewportWidth,
|
||||
required double textScale,
|
||||
}) => '$messageId|$layoutSignature|$viewportWidth|$textScale';
|
||||
|
||||
double? lookup(String key) {
|
||||
final extent = _extents.remove(key);
|
||||
if (extent != null) _extents[key] = extent;
|
||||
return extent;
|
||||
}
|
||||
|
||||
void record(String key, double extent) {
|
||||
_extents.remove(key);
|
||||
_extents[key] = extent;
|
||||
if (_extents.length > _capacity) _extents.remove(_extents.keys.first);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
int get debugLength => _extents.length;
|
||||
|
||||
@visibleForTesting
|
||||
void debugClear() => _extents.clear();
|
||||
}
|
||||
|
||||
/// SliverChildBuilderDelegate whose `shouldRebuild` defaults to true, which
|
||||
/// made every shell rebuild (drag-start setState, keyboard insets, pin
|
||||
/// transitions) rebuild every mounted row. Row content is derived entirely
|
||||
/// from [rowBuilder] and [entries]; per-row Consumers pick up message-level
|
||||
/// changes on their own, so identical inputs mean no rebuild is needed.
|
||||
///
|
||||
/// It also estimates the extent of unbuilt rows from heights the viewport
|
||||
/// recorded in [rowExtents] while those rows were mounted. The default
|
||||
/// extrapolation averages only
|
||||
/// the currently built rows, so one long response makes the scrollbar thumb
|
||||
/// jump as rows enter and leave the build window.
|
||||
class _TimelineRowDelegate extends SliverChildBuilderDelegate {
|
||||
const _TimelineRowDelegate(
|
||||
super.builder, {
|
||||
|
|
@ -2076,11 +2230,53 @@ class _TimelineRowDelegate extends SliverChildBuilderDelegate {
|
|||
required this.rowBuilder,
|
||||
required this.entries,
|
||||
required this.centerIndex,
|
||||
required this.reverse,
|
||||
required this.rowExtents,
|
||||
required this.estimateRowExtent,
|
||||
}) : super(addSemanticIndexes: false);
|
||||
|
||||
final ChatTimelineRowBuilder rowBuilder;
|
||||
final List<({String id, int sourceIndex, Object? rebuildKey})> entries;
|
||||
final int centerIndex;
|
||||
final bool reverse;
|
||||
final Map<String, double> rowExtents;
|
||||
final double? Function(int sourceIndex)? estimateRowExtent;
|
||||
|
||||
({String id, int sourceIndex, Object? rebuildKey}) _entryAt(int index) =>
|
||||
entries[reverse ? centerIndex - 1 - index : centerIndex + index];
|
||||
|
||||
@override
|
||||
double? estimateMaxScrollOffset(
|
||||
int firstIndex,
|
||||
int lastIndex,
|
||||
double leadingScrollOffset,
|
||||
double trailingScrollOffset,
|
||||
) {
|
||||
final count = childCount;
|
||||
if (count == null) return null;
|
||||
var total = trailingScrollOffset;
|
||||
var unknown = 0;
|
||||
for (var index = lastIndex + 1; index < count; index += 1) {
|
||||
final entry = _entryAt(index);
|
||||
final extent =
|
||||
rowExtents[entry.id] ?? estimateRowExtent?.call(entry.sourceIndex);
|
||||
if (extent == null) {
|
||||
unknown += 1;
|
||||
} else {
|
||||
total += extent;
|
||||
}
|
||||
}
|
||||
if (unknown == 0) return total;
|
||||
// Rows never laid out fall back to the median known row height. Unlike
|
||||
// the built-row mean, one giant response cannot drag it around as short
|
||||
// rows mount, which is what keeps the thumb still.
|
||||
final known = rowExtents.values.toList()..sort();
|
||||
final typical = known.isEmpty
|
||||
? (trailingScrollOffset - leadingScrollOffset) /
|
||||
(lastIndex - firstIndex + 1)
|
||||
: known[known.length ~/ 2];
|
||||
return total + unknown * typical;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRebuild(covariant _TimelineRowDelegate oldDelegate) {
|
||||
|
|
|
|||
|
|
@ -214,6 +214,259 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
_viewportTest('scrollbar track is inset by the toolbar and composer', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = _controller(tester);
|
||||
final ids = List<String>.generate(30, (index) => 'message-$index');
|
||||
addTearDown(PlatformUiCapabilities.resetDebugOverrides);
|
||||
PlatformUiCapabilities.debugPlatformOverride = TargetPlatform.android;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_viewportHost(_viewport(controller: controller, ids: ids)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final scrollbarPadding = MediaQuery.paddingOf(
|
||||
tester.element(find.byType(Scrollbar)),
|
||||
);
|
||||
check(scrollbarPadding.top).equals(_topContentInset);
|
||||
check(scrollbarPadding.bottom).equals(80);
|
||||
final rowPadding = MediaQuery.paddingOf(
|
||||
tester.element(find.byType(CustomScrollView)),
|
||||
);
|
||||
check(rowPadding.bottom).equals(0);
|
||||
});
|
||||
|
||||
_viewportTest('row extent estimates make the content extent exact before '
|
||||
'any row has been laid out', (tester) async {
|
||||
final controller = _controller(tester);
|
||||
final ids = List<String>.generate(60, (index) => 'message-$index');
|
||||
// The tall row sits far above both the initial centre and the latest
|
||||
// window, so only the estimate can account for it before it is built.
|
||||
double heightOf(String id) => id == 'message-30' ? 3000 : 200;
|
||||
// Everything above the anchored latest row lives in the reverse sliver,
|
||||
// whose extent is the negative minimum scroll offset. The top clearance
|
||||
// cancels against the viewport anchor, so the rows alone remain.
|
||||
final olderRowsExtent = ids
|
||||
.take(ids.length - 1)
|
||||
.fold<double>(0, (sum, id) => sum + heightOf(id));
|
||||
final built = <String>{};
|
||||
|
||||
await tester.pumpWidget(
|
||||
_viewportHost(
|
||||
_viewport(
|
||||
controller: controller,
|
||||
ids: ids,
|
||||
followLatest: false,
|
||||
initialAnchor: const ChatScrollAnchor(
|
||||
messageId: 'message-59',
|
||||
offsetWithinMessage: 0,
|
||||
loadedCount: 60,
|
||||
),
|
||||
estimateRowExtent: (index) => heightOf(ids[index]),
|
||||
rowBuilder: (context, index) {
|
||||
final id = ids[index];
|
||||
built.add(id);
|
||||
return SizedBox(
|
||||
height: heightOf(id),
|
||||
child: Text(id, key: ValueKey<String>('label-$id')),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final position = tester
|
||||
.state<ScrollableState>(find.byType(Scrollable))
|
||||
.position;
|
||||
check(built).not((it) => it.contains('message-30'));
|
||||
check(-position.minScrollExtent).isCloseTo(olderRowsExtent, 1);
|
||||
});
|
||||
|
||||
test('row extent estimate grows with text, scale, and attachments', () {
|
||||
double estimate(String text, double width, {double scale = 1}) =>
|
||||
estimateChatRowExtent(
|
||||
text: text,
|
||||
viewportWidth: width,
|
||||
textScale: scale,
|
||||
);
|
||||
final short = estimate('hi', 400);
|
||||
final long = estimate('x' * 4000, 400);
|
||||
check(long).isGreaterThan(short);
|
||||
check(long).isGreaterThan(estimate('x' * 4000, 1200));
|
||||
check(estimate('x' * 4000, 400, scale: 2)).isGreaterThan(long);
|
||||
check(estimateChatRowExtent(text: '', viewportWidth: 400, imageCount: 1))
|
||||
.isGreaterThan(estimateChatRowExtent(text: '', viewportWidth: 400));
|
||||
check(estimateChatRowExtent(text: 'hi', viewportWidth: 400, isUser: true))
|
||||
.isLessThan(estimateChatRowExtent(text: 'hi', viewportWidth: 400));
|
||||
});
|
||||
|
||||
test('row extent memory returns recorded heights and evicts oldest', () {
|
||||
final memory = ChatRowExtentMemory.instance;
|
||||
addTearDown(memory.debugClear);
|
||||
memory.debugClear();
|
||||
String key(int i) => ChatRowExtentMemory.keyFor(
|
||||
messageId: 'm$i',
|
||||
layoutSignature: 10,
|
||||
viewportWidth: 400,
|
||||
textScale: 1,
|
||||
);
|
||||
memory.record(key(0), 120);
|
||||
check(memory.lookup(key(0))).equals(120);
|
||||
check(
|
||||
memory.lookup(
|
||||
ChatRowExtentMemory.keyFor(
|
||||
messageId: 'm0',
|
||||
layoutSignature: 11,
|
||||
viewportWidth: 400,
|
||||
textScale: 1,
|
||||
),
|
||||
),
|
||||
).isNull();
|
||||
for (var i = 1; i <= 2000; i += 1) {
|
||||
memory.record(key(i), i.toDouble());
|
||||
}
|
||||
check(memory.debugLength).equals(2000);
|
||||
check(memory.lookup(key(0))).isNull();
|
||||
check(memory.lookup(key(2000))).equals(2000);
|
||||
});
|
||||
|
||||
_viewportTest('measured row heights are reported by source index', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = _controller(tester);
|
||||
final ids = List<String>.generate(30, (index) => 'message-$index');
|
||||
final reported = <int, double>{};
|
||||
await tester.pumpWidget(
|
||||
_viewportHost(
|
||||
_viewport(
|
||||
controller: controller,
|
||||
ids: ids,
|
||||
rowHeight: (id) => id == 'message-29' ? 90 : 52,
|
||||
onRowExtentMeasured: (index, extent) => reported[index] = extent,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
check(reported[29]).equals(90);
|
||||
check(reported[28]).equals(52);
|
||||
});
|
||||
|
||||
_viewportTest('content extent stays exact once rows have been laid out', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = _controller(tester);
|
||||
final ids = List<String>.generate(40, (index) => 'message-$index');
|
||||
double heightOf(String id) => id == 'message-20' ? 3000 : 40;
|
||||
final exactExtent =
|
||||
_topContentInset +
|
||||
ids.fold<double>(0, (sum, id) => sum + heightOf(id)) +
|
||||
80;
|
||||
// Same tree shape for both scales so the viewport state survives and the
|
||||
// remembered extents are cleared by the layout-input change, not by a
|
||||
// fresh state.
|
||||
Widget host(double scale) => _viewportHost(
|
||||
Builder(
|
||||
builder: (context) => MediaQuery(
|
||||
data: MediaQuery.of(context)
|
||||
.copyWith(textScaler: TextScaler.linear(scale)),
|
||||
child: _viewport(
|
||||
controller: controller,
|
||||
ids: ids,
|
||||
followLatest: false,
|
||||
rowHeight: (id) => heightOf(id) * scale,
|
||||
rowRebuildKeys: List<Object?>.filled(ids.length, scale),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(host(1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final position = tester
|
||||
.state<ScrollableState>(find.byType(Scrollable))
|
||||
.position;
|
||||
double totalExtent() =>
|
||||
position.maxScrollExtent -
|
||||
position.minScrollExtent +
|
||||
position.viewportDimension;
|
||||
|
||||
// Walk every row through the build window once.
|
||||
for (
|
||||
var offset = position.maxScrollExtent;
|
||||
offset > position.minScrollExtent;
|
||||
offset -= 400
|
||||
) {
|
||||
position.jumpTo(offset);
|
||||
await tester.pump();
|
||||
}
|
||||
position.jumpTo(position.minScrollExtent);
|
||||
await tester.pump();
|
||||
|
||||
// Park the build window inside the tall row, where the built-row average
|
||||
// used to inflate the estimate for every unbuilt row.
|
||||
position.jumpTo(
|
||||
position.minScrollExtent + _topContentInset + 20 * 40 + 1200,
|
||||
);
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('label-message-20')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const ValueKey<String>('label-message-0')), findsNothing);
|
||||
check(totalExtent()).isCloseTo(exactExtent, 1);
|
||||
|
||||
// A text scale change rewraps rows, so remembered extents are dropped and
|
||||
// unbuilt rows are estimated from rows measured under the new scale.
|
||||
await tester.pumpWidget(host(2));
|
||||
await tester.pump();
|
||||
// Walk down from the top through the tall row so rows 0-20 are measured
|
||||
// at the new scale, then park inside the tall row. The trailing rows
|
||||
// were never rebuilt, so only the median of remeasured rows gives the
|
||||
// exact extent; stale scale-1 heights would fall short.
|
||||
position.jumpTo(position.minScrollExtent);
|
||||
await tester.pump();
|
||||
final parkInsideTallRow =
|
||||
position.minScrollExtent + _topContentInset + 20 * 80 + 1200;
|
||||
for (
|
||||
var offset = position.pixels;
|
||||
offset < parkInsideTallRow;
|
||||
offset += 400
|
||||
) {
|
||||
position.jumpTo(offset);
|
||||
await tester.pump();
|
||||
}
|
||||
position.jumpTo(parkInsideTallRow);
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('label-message-20')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey<String>('label-message-21')),
|
||||
findsNothing,
|
||||
);
|
||||
// Measure from the tall row's real position: the sliver keeps stale
|
||||
// offsets for leading rows it never rebuilt, which is Flutter's own
|
||||
// imprecision and not what this estimate covers.
|
||||
final tallRowTop =
|
||||
tester
|
||||
.getTopLeft(find.byKey(const ValueKey<String>('label-message-20')))
|
||||
.dy -
|
||||
tester.getTopLeft(find.byType(CustomScrollView)).dy;
|
||||
final expected =
|
||||
position.pixels -
|
||||
position.minScrollExtent +
|
||||
tallRowTop +
|
||||
6000 +
|
||||
19 * 80 +
|
||||
80;
|
||||
check(totalExtent()).isCloseTo(expected, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'viewport rejects simultaneous anchor maintenance and latest follow',
|
||||
() {
|
||||
|
|
@ -2577,6 +2830,8 @@ Widget _viewport({
|
|||
Widget? trailingContent,
|
||||
bool hideUntilSettled = false,
|
||||
double Function(String id)? rowHeight,
|
||||
double? Function(int index)? estimateRowExtent,
|
||||
void Function(int index, double extent)? onRowExtentMeasured,
|
||||
ChatTimelineRowBuilder? rowBuilder,
|
||||
List<Object?> rowRebuildKeys = const <Object?>[],
|
||||
ValueChanged<ChatTimelineViewportMetrics>? onMetricsChanged,
|
||||
|
|
@ -2593,6 +2848,8 @@ Widget _viewport({
|
|||
ownerGeneration: ownerGeneration,
|
||||
messageIds: ids,
|
||||
rowRebuildKeys: rowRebuildKeys,
|
||||
estimateRowExtent: estimateRowExtent,
|
||||
onRowExtentMeasured: onRowExtentMeasured,
|
||||
initialAnchor: initialAnchor,
|
||||
pinnedUserMessageId: pinnedUserMessageId,
|
||||
liveFooter: liveFooter,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue