From 09d7a57600b5fc1b05209b46c5524ef94f28a791 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 25 Mar 2025 14:15:14 -0700 Subject: [PATCH 01/11] msglist [nfc]: Cut already-broken logic for slightly less padding at bottom This `i == 1` condition was never true, because in that situation the caller would build a MarkAsReadWidget instead of calling this method. This 8px vs. 11px distinction dates back to the prototype: 731b1990c made it 8px instead of 0px, and the distinction itself goes back to the commit 9916194ea msglist: Start on rendering messages in the prototype's first hours. The logic that drove it, though, became fragile with e7fe06cbc which changed it from "i == 0" (the end of the list, OK that's fairly canonical as a special value) to "i == 1" (more arbitrary). So then it naturally got broken a little later, in 56ab39576 in 2023-11, and it's been broken ever since: we just always show 11px of padding here. We might further change the layout in the future, but if we do we'll fix it forward starting from the behavior the app has already had for over a year. --- lib/widgets/message_list.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 1e95a1be49..71730f951b 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -622,7 +622,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat if (i == 2) return TypingStatusWidget(narrow: widget.narrow); final data = model!.items[length - 1 - (i - 3)]; - return _buildItem(zulipLocalizations, data, i); + return _buildItem(zulipLocalizations, data); })); if (!ComposeBox.hasComposeBox(widget.narrow)) { @@ -659,7 +659,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat ]); } - Widget _buildItem(ZulipLocalizations zulipLocalizations, MessageListItem data, int i) { + Widget _buildItem(ZulipLocalizations zulipLocalizations, MessageListItem data) { switch (data) { case MessageListHistoryStartItem(): return Center( @@ -685,7 +685,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat return MessageItem( key: ValueKey(data.message.id), header: header, - trailingWhitespace: i == 1 ? 8 : 11, + trailingWhitespace: 11, item: data); } } From 50b0a75b9f2e7aaf8eded48e24e47d0d0114e468 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 22 Mar 2025 23:29:24 -0700 Subject: [PATCH 02/11] msglist [nfc]: Introduce MessageListScrollView, not yet doing anything different --- lib/widgets/message_list.dart | 2 +- lib/widgets/scrolling.dart | 92 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 71730f951b..8b9730d566 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -631,7 +631,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat sliver = SliverSafeArea(sliver: sliver); } - return CustomPaintOrderScrollView( + return MessageListScrollView( // TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or // similar) if that is ever offered: // https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849 diff --git a/lib/widgets/scrolling.dart b/lib/widgets/scrolling.dart index 7ceff8b745..ec345dbbdd 100644 --- a/lib/widgets/scrolling.dart +++ b/lib/widgets/scrolling.dart @@ -244,3 +244,95 @@ class RenderCustomPaintOrderViewport extends RenderViewport { }; } } + +/// A version of [CustomScrollView] adapted for the Zulip message list. +/// +/// This lets us customize behavior in ways that aren't currently supported +/// by the fields of [CustomScrollView] itself. +class MessageListScrollView extends CustomPaintOrderScrollView { + const MessageListScrollView({ + super.key, + super.scrollDirection, + super.reverse, + super.controller, + super.primary, + super.physics, + super.scrollBehavior, + // super.shrinkWrap, // omitted, always false + super.center, + super.anchor, + super.cacheExtent, + super.slivers, + super.semanticChildCount, + super.dragStartBehavior, + super.keyboardDismissBehavior, + super.restorationId, + super.clipBehavior, + super.hitTestBehavior, + super.paintOrder, + }); + + @override + Widget buildViewport(BuildContext context, ViewportOffset offset, + AxisDirection axisDirection, List slivers) { + return MessageListViewport( + axisDirection: axisDirection, + offset: offset, + slivers: slivers, + cacheExtent: cacheExtent, + center: center, + anchor: anchor, + clipBehavior: clipBehavior, + paintOrder_: paintOrder_, + ); + } +} + +/// The version of [Viewport] that underlies [MessageListScrollView]. +class MessageListViewport extends CustomPaintOrderViewport { + MessageListViewport({ + super.key, + super.axisDirection, + super.crossAxisDirection, + super.anchor, + required super.offset, + super.center, + super.cacheExtent, + super.cacheExtentStyle, + super.slivers, + super.clipBehavior, + required super.paintOrder_, + }); + + @override + RenderViewport createRenderObject(BuildContext context) { + return RenderMessageListViewport( + axisDirection: axisDirection, + crossAxisDirection: crossAxisDirection + ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection), + anchor: anchor, + offset: offset, + cacheExtent: cacheExtent, + cacheExtentStyle: cacheExtentStyle, + clipBehavior: clipBehavior, + paintOrder_: paintOrder_, + ); + } +} + +/// The version of [RenderViewport] that underlies [MessageListViewport] +/// and [MessageListScrollView]. +class RenderMessageListViewport extends RenderCustomPaintOrderViewport { + RenderMessageListViewport({ + super.axisDirection, + required super.crossAxisDirection, + required super.offset, + super.anchor, + super.children, + super.center, + super.cacheExtent, + super.cacheExtentStyle, + super.clipBehavior, + required super.paintOrder_, + }); +} From b49b3a317548e14d5c26184f5e250de8e7839bf7 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 22 Mar 2025 23:36:01 -0700 Subject: [PATCH 03/11] scroll [nfc]: Copy RenderViewport.performLayout and friends from upstream Some of the behavior we'd like to customize isn't currently cleanly exposed to subclasses any more than it is to parent widgets passing constructor arguments. In particular, we'll want to change a few bits of logic in [RenderViewport.performLayout], replacing the handling of the `anchor` field with something more flexible. In order to do that, we'll start from a copy of that method, so that we can edit the copy. Then the base class's `performLayout` refers to a private helper method `_attemptLayout`, so we need a copy of that too; and they each refer to a number of private fields, so we need copies of those too; and to make those work correctly, we need copies of all the other members that refer to those fields, so that they're all referring correctly to the same version of those fields (namely the one on the subclass) rather than to a mix of the versions on the base class and those on the subclass. Fortunately, flood-filling that graph of members which refer to private members, which are referred to by other members, etc., terminates with a connected component which is... not small, but a lot smaller and less unwieldy than if we had to copy the whole upstream file these are defined in. --- lib/widgets/scrolling.dart | 212 +++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) diff --git a/lib/widgets/scrolling.dart b/lib/widgets/scrolling.dart index ec345dbbdd..cb140b8b8a 100644 --- a/lib/widgets/scrolling.dart +++ b/lib/widgets/scrolling.dart @@ -1,3 +1,6 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -322,6 +325,8 @@ class MessageListViewport extends CustomPaintOrderViewport { /// The version of [RenderViewport] that underlies [MessageListViewport] /// and [MessageListScrollView]. +// TODO(upstream): Devise upstream APIs to obviate the duplicated code here; +// use `git log -L` to see what edits we've made locally. class RenderMessageListViewport extends RenderCustomPaintOrderViewport { RenderMessageListViewport({ super.axisDirection, @@ -335,4 +340,211 @@ class RenderMessageListViewport extends RenderCustomPaintOrderViewport { super.clipBehavior, required super.paintOrder_, }); + + double? _calculatedCacheExtent; + + @override + Rect describeSemanticsClip(RenderSliver? child) { + if (_calculatedCacheExtent == null) { + return semanticBounds; + } + + switch (axis) { + case Axis.vertical: + return Rect.fromLTRB( + semanticBounds.left, + semanticBounds.top - _calculatedCacheExtent!, + semanticBounds.right, + semanticBounds.bottom + _calculatedCacheExtent!, + ); + case Axis.horizontal: + return Rect.fromLTRB( + semanticBounds.left - _calculatedCacheExtent!, + semanticBounds.top, + semanticBounds.right + _calculatedCacheExtent!, + semanticBounds.bottom, + ); + } + } + + static const int _maxLayoutCyclesPerChild = 10; + + // Out-of-band data computed during layout. + late double _minScrollExtent; + late double _maxScrollExtent; + bool _hasVisualOverflow = false; + + @override + void performLayout() { + // Ignore the return value of applyViewportDimension because we are + // doing a layout regardless. + switch (axis) { + case Axis.vertical: + offset.applyViewportDimension(size.height); + case Axis.horizontal: + offset.applyViewportDimension(size.width); + } + + if (center == null) { + assert(firstChild == null); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + offset.applyContentDimensions(0.0, 0.0); + return; + } + assert(center!.parent == this); + + final (double mainAxisExtent, double crossAxisExtent) = switch (axis) { + Axis.vertical => (size.height, size.width), + Axis.horizontal => (size.width, size.height), + }; + + final double centerOffsetAdjustment = center!.centerOffsetAdjustment; + final int maxLayoutCycles = _maxLayoutCyclesPerChild * childCount; + + double correction; + int count = 0; + do { + correction = _attemptLayout( + mainAxisExtent, + crossAxisExtent, + offset.pixels + centerOffsetAdjustment, + ); + if (correction != 0.0) { + offset.correctBy(correction); + } else { + if (offset.applyContentDimensions( + math.min(0.0, _minScrollExtent + mainAxisExtent * anchor), + math.max(0.0, _maxScrollExtent - mainAxisExtent * (1.0 - anchor)), + )) { + break; + } + } + count += 1; + } while (count < maxLayoutCycles); + assert(() { + if (count >= maxLayoutCycles) { + assert(count != 1); + throw FlutterError( + 'A RenderViewport exceeded its maximum number of layout cycles.\n' + 'RenderViewport render objects, during layout, can retry if either their ' + 'slivers or their ViewportOffset decide that the offset should be corrected ' + 'to take into account information collected during that layout.\n' + 'In the case of this RenderViewport object, however, this happened $count ' + 'times and still there was no consensus on the scroll offset. This usually ' + 'indicates a bug. Specifically, it means that one of the following three ' + 'problems is being experienced by the RenderViewport object:\n' + ' * One of the RenderSliver children or the ViewportOffset have a bug such' + ' that they always think that they need to correct the offset regardless.\n' + ' * Some combination of the RenderSliver children and the ViewportOffset' + ' have a bad interaction such that one applies a correction then another' + ' applies a reverse correction, leading to an infinite loop of corrections.\n' + ' * There is a pathological case that would eventually resolve, but it is' + ' so complicated that it cannot be resolved in any reasonable number of' + ' layout passes.', + ); + } + return true; + }()); + } + + double _attemptLayout(double mainAxisExtent, double crossAxisExtent, double correctedOffset) { + assert(!mainAxisExtent.isNaN); + assert(mainAxisExtent >= 0.0); + assert(crossAxisExtent.isFinite); + assert(crossAxisExtent >= 0.0); + assert(correctedOffset.isFinite); + _minScrollExtent = 0.0; + _maxScrollExtent = 0.0; + _hasVisualOverflow = false; + + // centerOffset is the offset from the leading edge of the RenderViewport + // to the zero scroll offset (the line between the forward slivers and the + // reverse slivers). + final double centerOffset = mainAxisExtent * anchor - correctedOffset; + final double reverseDirectionRemainingPaintExtent = clampDouble( + centerOffset, + 0.0, + mainAxisExtent, + ); + final double forwardDirectionRemainingPaintExtent = clampDouble( + mainAxisExtent - centerOffset, + 0.0, + mainAxisExtent, + ); + + _calculatedCacheExtent = switch (cacheExtentStyle) { + CacheExtentStyle.pixel => cacheExtent, + CacheExtentStyle.viewport => mainAxisExtent * cacheExtent!, + }; + + final double fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!; + final double centerCacheOffset = centerOffset + _calculatedCacheExtent!; + final double reverseDirectionRemainingCacheExtent = clampDouble( + centerCacheOffset, + 0.0, + fullCacheExtent, + ); + final double forwardDirectionRemainingCacheExtent = clampDouble( + fullCacheExtent - centerCacheOffset, + 0.0, + fullCacheExtent, + ); + + final RenderSliver? leadingNegativeChild = childBefore(center!); + + if (leadingNegativeChild != null) { + // negative scroll offsets + final double result = layoutChildSequence( + child: leadingNegativeChild, + scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent, + overlap: 0.0, + layoutOffset: forwardDirectionRemainingPaintExtent, + remainingPaintExtent: reverseDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.reverse, + advance: childBefore, + remainingCacheExtent: reverseDirectionRemainingCacheExtent, + cacheOrigin: clampDouble(mainAxisExtent - centerOffset, -_calculatedCacheExtent!, 0.0), + ); + if (result != 0.0) { + return -result; + } + } + + // positive scroll offsets + return layoutChildSequence( + child: center, + scrollOffset: math.max(0.0, -centerOffset), + overlap: leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0, + layoutOffset: + centerOffset >= mainAxisExtent ? centerOffset : reverseDirectionRemainingPaintExtent, + remainingPaintExtent: forwardDirectionRemainingPaintExtent, + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent, + growthDirection: GrowthDirection.forward, + advance: childAfter, + remainingCacheExtent: forwardDirectionRemainingCacheExtent, + cacheOrigin: clampDouble(centerOffset, -_calculatedCacheExtent!, 0.0), + ); + } + + @override + bool get hasVisualOverflow => _hasVisualOverflow; + + @override + void updateOutOfBandData(GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) { + switch (growthDirection) { + case GrowthDirection.forward: + _maxScrollExtent += childLayoutGeometry.scrollExtent; + case GrowthDirection.reverse: + _minScrollExtent -= childLayoutGeometry.scrollExtent; + } + if (childLayoutGeometry.hasVisualOverflow) { + _hasVisualOverflow = true; + } + } + } From 9eefc2b34c5ce4ed735acbc47f0da07656713e7f Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sun, 23 Mar 2025 00:57:41 -0700 Subject: [PATCH 04/11] msglist [nfc]: Introduce MessageListScrollPosition --- lib/widgets/message_list.dart | 2 +- lib/widgets/scrolling.dart | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 8b9730d566..12982f5308 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -466,7 +466,7 @@ class MessageList extends StatefulWidget { class _MessageListState extends State with PerAccountStoreAwareStateMixin { MessageListView? model; - final ScrollController scrollController = ScrollController(); + final ScrollController scrollController = MessageListScrollController(); final ValueNotifier _scrollToBottomVisibleValue = ValueNotifier(false); @override diff --git a/lib/widgets/scrolling.dart b/lib/widgets/scrolling.dart index cb140b8b8a..984f0c7825 100644 --- a/lib/widgets/scrolling.dart +++ b/lib/widgets/scrolling.dart @@ -248,6 +248,43 @@ class RenderCustomPaintOrderViewport extends RenderViewport { } } +/// A version of [ScrollPosition] adapted for the Zulip message list, +/// used by [MessageListScrollController]. +class MessageListScrollPosition extends ScrollPositionWithSingleContext { + MessageListScrollPosition({ + required super.physics, + required super.context, + super.initialPixels, + super.keepScrollOffset, + super.oldPosition, + super.debugLabel, + }); +} + +/// A version of [ScrollController] adapted for the Zulip message list. +class MessageListScrollController extends ScrollController { + MessageListScrollController({ + super.initialScrollOffset, + super.keepScrollOffset, + super.debugLabel, + super.onAttach, + super.onDetach, + }); + + @override + ScrollPosition createScrollPosition(ScrollPhysics physics, + ScrollContext context, ScrollPosition? oldPosition) { + return MessageListScrollPosition( + physics: physics, + context: context, + initialPixels: initialScrollOffset, + keepScrollOffset: keepScrollOffset, + oldPosition: oldPosition, + debugLabel: debugLabel, + ); + } +} + /// A version of [CustomScrollView] adapted for the Zulip message list. /// /// This lets us customize behavior in ways that aren't currently supported From 4efbb12a6f8fd5f2bc8e34567c084424a21a53dd Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 24 Mar 2025 16:41:37 -0700 Subject: [PATCH 05/11] msglist [nfc]: Force anchor to 1.0 We'll rely on this assumption to simplify some upcoming customizations. --- lib/widgets/message_list.dart | 1 - lib/widgets/scrolling.dart | 9 ++++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 12982f5308..d9e410dc06 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -645,7 +645,6 @@ class _MessageListState extends State with PerAccountStoreAwareStat controller: scrollController, semanticChildCount: length + 2, - anchor: 1.0, center: centerSliverKey, paintOrder: SliverPaintOrder.firstIsTop, diff --git a/lib/widgets/scrolling.dart b/lib/widgets/scrolling.dart index 984f0c7825..ea0201e975 100644 --- a/lib/widgets/scrolling.dart +++ b/lib/widgets/scrolling.dart @@ -300,7 +300,6 @@ class MessageListScrollView extends CustomPaintOrderScrollView { super.scrollBehavior, // super.shrinkWrap, // omitted, always false super.center, - super.anchor, super.cacheExtent, super.slivers, super.semanticChildCount, @@ -321,7 +320,6 @@ class MessageListScrollView extends CustomPaintOrderScrollView { slivers: slivers, cacheExtent: cacheExtent, center: center, - anchor: anchor, clipBehavior: clipBehavior, paintOrder_: paintOrder_, ); @@ -334,7 +332,6 @@ class MessageListViewport extends CustomPaintOrderViewport { super.key, super.axisDirection, super.crossAxisDirection, - super.anchor, required super.offset, super.center, super.cacheExtent, @@ -350,7 +347,6 @@ class MessageListViewport extends CustomPaintOrderViewport { axisDirection: axisDirection, crossAxisDirection: crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection), - anchor: anchor, offset: offset, cacheExtent: cacheExtent, cacheExtentStyle: cacheExtentStyle, @@ -369,7 +365,6 @@ class RenderMessageListViewport extends RenderCustomPaintOrderViewport { super.axisDirection, required super.crossAxisDirection, required super.offset, - super.anchor, super.children, super.center, super.cacheExtent, @@ -378,6 +373,9 @@ class RenderMessageListViewport extends RenderCustomPaintOrderViewport { required super.paintOrder_, }); + @override + double get anchor => 1.0; + double? _calculatedCacheExtent; @override @@ -499,6 +497,7 @@ class RenderMessageListViewport extends RenderCustomPaintOrderViewport { // centerOffset is the offset from the leading edge of the RenderViewport // to the zero scroll offset (the line between the forward slivers and the // reverse slivers). + assert(anchor == 1.0); final double centerOffset = mainAxisExtent * anchor - correctedOffset; final double reverseDirectionRemainingPaintExtent = clampDouble( centerOffset, From 2541a10f0c64eb9f6adffe1a8797dbbfb474efe3 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 24 Mar 2025 16:41:50 -0700 Subject: [PATCH 06/11] scroll [nfc]: Introduce applyContentDimensionsRaw on scroll position --- lib/widgets/scrolling.dart | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/widgets/scrolling.dart b/lib/widgets/scrolling.dart index ea0201e975..d5ac60073e 100644 --- a/lib/widgets/scrolling.dart +++ b/lib/widgets/scrolling.dart @@ -259,6 +259,25 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext { super.oldPosition, super.debugLabel, }); + + /// Like [applyContentDimensions], but called without adjusting + /// the arguments to subtract the viewport dimension. + /// + /// For instance, if there is 100.0 pixels of scrollable content + /// of which 40.0 pixels is in the reverse-growing slivers and + /// 60.0 pixels in the forward-growing slivers, then the arguments + /// will be -40.0 and 60.0, regardless of the viewport dimension. + /// + /// By contrast in a call to [applyContentDimensions], in this example and + /// if the viewport dimension is 80.0, then the arguments might be + /// 0.0 and 60.0, or -10.0 and 10.0, or -40.0 and 0.0, or other values, + /// depending on the value of [Viewport.anchor]. + bool applyContentDimensionsRaw(double wholeMinScrollExtent, double wholeMaxScrollExtent) { + // This makes the simplifying assumption that `anchor` is 1.0. + final effectiveMin = math.min(0.0, wholeMinScrollExtent + viewportDimension); + final effectiveMax = wholeMaxScrollExtent; + return applyContentDimensions(effectiveMin, effectiveMax); + } } /// A version of [ScrollController] adapted for the Zulip message list. @@ -449,10 +468,12 @@ class RenderMessageListViewport extends RenderCustomPaintOrderViewport { if (correction != 0.0) { offset.correctBy(correction); } else { - if (offset.applyContentDimensions( - math.min(0.0, _minScrollExtent + mainAxisExtent * anchor), - math.max(0.0, _maxScrollExtent - mainAxisExtent * (1.0 - anchor)), - )) { + // TODO(upstream): Move applyContentDimensionsRaw to ViewportOffset + // (possibly with an API change to tell it [anchor]?); + // give it a default implementation calling applyContentDimensions; + // have RenderViewport.performLayout call it. + if ((offset as MessageListScrollPosition) + .applyContentDimensionsRaw(_minScrollExtent, _maxScrollExtent)) { break; } } From 1667c6e30481652c0bb4917bb88ae41a9b4e00ff Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 3 Apr 2025 16:55:25 -0700 Subject: [PATCH 07/11] scroll: Start out scrolled to bottom of list This is NFC as to the real message list, because so far the bottom sliver there always has height 0, so that maxScrollExtent is always 0. This is a step toward letting us move part of the message list into the bottom sliver, because it means that doing so would preserve the list's current behavior of starting out scrolled to the end. --- lib/widgets/scrolling.dart | 30 +++++++++++++ test/widgets/scrolling_test.dart | 74 ++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/lib/widgets/scrolling.dart b/lib/widgets/scrolling.dart index d5ac60073e..5752278297 100644 --- a/lib/widgets/scrolling.dart +++ b/lib/widgets/scrolling.dart @@ -278,6 +278,36 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext { final effectiveMax = wholeMaxScrollExtent; return applyContentDimensions(effectiveMin, effectiveMax); } + + bool _hasEverCompletedLayout = false; + + @override + bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { + // Inspired by _TabBarScrollPosition.applyContentDimensions upstream. + bool changed = false; + + if (!_hasEverCompletedLayout) { + // The list is being laid out for the first time (its first performLayout). + // Start out scrolled to the end. + final target = maxScrollExtent; + if (!hasPixels || pixels != target) { + correctPixels(target); + changed = true; + } + } + + if (!super.applyContentDimensions(minScrollExtent, maxScrollExtent)) { + changed = true; + } + + if (!changed) { + // Because this method is about to return true, + // this will be the last round of this layout. + _hasEverCompletedLayout = true; + } + + return !changed; + } } /// A version of [ScrollController] adapted for the Zulip message list. diff --git a/test/widgets/scrolling_test.dart b/test/widgets/scrolling_test.dart index bfb010ccf0..9854ef47ed 100644 --- a/test/widgets/scrolling_test.dart +++ b/test/widgets/scrolling_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/widgets.dart' hide SliverPaintOrder; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/widgets/scrolling.dart'; +import '../flutter_checks.dart'; + void main() { group('CustomPaintOrderScrollView paint order', () { final paintLog = []; @@ -127,6 +129,78 @@ void main() { .deepEquals(sliverIds(result.path)); }); }); + + group('MessageListScrollView', () { + Future prepare(WidgetTester tester, + {required double topHeight, required double bottomHeight}) async { + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: MessageListScrollView( + controller: MessageListScrollController(), + center: const ValueKey('center'), + slivers: [ + SliverToBoxAdapter( + child: SizedBox(height: topHeight, child: Text('top'))), + SliverToBoxAdapter(key: const ValueKey('center'), + child: SizedBox(height: bottomHeight, child: Text('bottom'))), + ]))); + await tester.pump(); + } + + // The `skipOffstage: false` produces more informative output + // when a test fails because one of the slivers is just offscreen. + final findTop = find.text('top', skipOffstage: false); + final findBottom = find.text('bottom', skipOffstage: false); + + testWidgets('short/short -> starts scrolled to bottom', (tester) async { + // Starts out with items at bottom of viewport. + await prepare(tester, topHeight: 100, bottomHeight: 100); + check(tester.getRect(findBottom)).bottom.equals(600); + + // Try scrolling down (by dragging up); doesn't move. + await tester.drag(findTop, Offset(0, -100)); + await tester.pump(); + check(tester.getRect(findBottom)).bottom.equals(600); + }); + + testWidgets('short/long -> starts scrolled to bottom', (tester) async { + // Starts out scrolled to bottom. + await prepare(tester, topHeight: 100, bottomHeight: 800); + check(tester.getRect(findBottom)).bottom.equals(600); + + // Try scrolling down (by dragging up); doesn't move. + await tester.drag(findBottom, Offset(0, -100)); + await tester.pump(); + check(tester.getRect(findBottom)).bottom.equals(600); + }); + + testWidgets('starts at bottom, even when bottom underestimated at first', (tester) async { + const numItems = 10; + const itemHeight = 300.0; + + // A list where the bottom sliver takes several rounds of layout + // to see how long it really is. + final controller = MessageListScrollController(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: MessageListScrollView( + controller: controller, + center: const ValueKey('center'), + slivers: [ + SliverToBoxAdapter( + child: SizedBox(height: 100, child: Text('top'))), + SliverList.list(key: const ValueKey('center'), + children: List.generate(numItems, (i) => + SizedBox(height: (i+1) * itemHeight, child: Text('item $i')))), + ]))); + await tester.pump(); + + // Starts out scrolled all the way to the bottom, + // even though it must have taken several rounds of layout to find that. + check(controller.position.pixels) + .equals(itemHeight * numItems * (numItems + 1)/2); + check(tester.getRect(find.text('item ${numItems-1}', skipOffstage: false))) + .bottom.equals(600); + }); + }); } class TestCustomPainter extends CustomPainter { From 07a3905137141faa5a35a3037acc8d5cd301657b Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 24 Mar 2025 15:19:04 -0700 Subject: [PATCH 08/11] scroll: Keep short list pinned at bottom of viewport, not past bottom This is NFC as to the real message list, because so far the bottom sliver there always has height 0. Before this change, the user could always scroll up (moving the content down) so that the bottom sliver was entirely off the bottom of the viewport, even if that exposed blank space at the top of the viewport because the top sliver was shorter than the viewport. After this change, it's never in bounds to have part of the viewport be blank for lack of content while there's content scrolled out of the viewport at the other end. This is a step toward letting us move part of the message list into the bottom sliver, because it fixes a bug that would otherwise create in the case where the top sliver fits entirely on the screen. --- lib/widgets/scrolling.dart | 45 ++++++++++++++++++++++++++++++-- test/widgets/scrolling_test.dart | 37 ++++++++++++++++++++++++-- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/lib/widgets/scrolling.dart b/lib/widgets/scrolling.dart index 5752278297..12a0d97918 100644 --- a/lib/widgets/scrolling.dart +++ b/lib/widgets/scrolling.dart @@ -273,9 +273,42 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext { /// 0.0 and 60.0, or -10.0 and 10.0, or -40.0 and 0.0, or other values, /// depending on the value of [Viewport.anchor]. bool applyContentDimensionsRaw(double wholeMinScrollExtent, double wholeMaxScrollExtent) { - // This makes the simplifying assumption that `anchor` is 1.0. - final effectiveMin = math.min(0.0, wholeMinScrollExtent + viewportDimension); + // The origin point of these scroll coordinates, scroll extent 0.0, + // is that the boundary between slivers is the bottom edge of the viewport. + // (That's expressed by setting `anchor` to 1.0, consulted in + // `_attemptLayout` below.) + + // The farthest the list can scroll down (moving the content up) + // is to the point where the bottom end of the list + // touches the bottom edge of the viewport. final effectiveMax = wholeMaxScrollExtent; + + // The farthest the list can scroll up (moving the content down) + // is either: + // * the same as the farthest it can scroll down, + // * or the point where the top end of the list + // touches the top edge of the viewport, + // whichever is farther up. + final effectiveMin = math.min(effectiveMax, + wholeMinScrollExtent + viewportDimension); + + // The first point comes into effect when the list is short, + // so the whole thing fits into the viewport. In that case, + // the only scroll position allowed is with the bottom end of the list + // at the bottom edge of the viewport. + + // The upstream answer (with no `applyContentDimensionsRaw`) would + // effectively say: + // final effectiveMin = math.min(0.0, + // wholeMinScrollExtent + viewportDimension); + // + // In other words, the farthest the list can scroll up might be farther up + // than the answer here: it could always scroll up to 0.0, meaning that the + // boundary between slivers is at the bottom edge of the viewport. + // Whenever the top sliver is shorter than the viewport (and the bottom + // sliver isn't empty), this would mean one can scroll up past + // the top of the list, even though that scrolls other content offscreen. + return applyContentDimensions(effectiveMin, effectiveMax); } @@ -289,6 +322,8 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext { if (!_hasEverCompletedLayout) { // The list is being laid out for the first time (its first performLayout). // Start out scrolled to the end. + // This also brings [pixels] within bounds, which + // the initial value of 0.0 might not have been. final target = maxScrollExtent; if (!hasPixels || pixels != target) { correctPixels(target); @@ -296,6 +331,12 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext { } } + // This step must come after the first-time correction above. + // Otherwise, if the initial [pixels] value of 0.0 was out of bounds + // (which happens if the top slivers are shorter than the viewport), + // then the base implementation of [applyContentDimensions] would + // bring it in bounds via a scrolling animation, which isn't right when + // starting from the meaningless initial 0.0 value. if (!super.applyContentDimensions(minScrollExtent, maxScrollExtent)) { changed = true; } diff --git a/test/widgets/scrolling_test.dart b/test/widgets/scrolling_test.dart index 9854ef47ed..47fd2dbedc 100644 --- a/test/widgets/scrolling_test.dart +++ b/test/widgets/scrolling_test.dart @@ -151,7 +151,7 @@ void main() { final findTop = find.text('top', skipOffstage: false); final findBottom = find.text('bottom', skipOffstage: false); - testWidgets('short/short -> starts scrolled to bottom', (tester) async { + testWidgets('short/short -> pinned at bottom', (tester) async { // Starts out with items at bottom of viewport. await prepare(tester, topHeight: 100, bottomHeight: 100); check(tester.getRect(findBottom)).bottom.equals(600); @@ -160,9 +160,14 @@ void main() { await tester.drag(findTop, Offset(0, -100)); await tester.pump(); check(tester.getRect(findBottom)).bottom.equals(600); + + // Try scrolling up (by dragging down); doesn't move. + await tester.drag(findTop, Offset(0, 100)); + await tester.pump(); + check(tester.getRect(findBottom)).bottom.equals(600); }); - testWidgets('short/long -> starts scrolled to bottom', (tester) async { + testWidgets('short/long -> scrolls to ends and no farther', (tester) async { // Starts out scrolled to bottom. await prepare(tester, topHeight: 100, bottomHeight: 800); check(tester.getRect(findBottom)).bottom.equals(600); @@ -171,6 +176,34 @@ void main() { await tester.drag(findBottom, Offset(0, -100)); await tester.pump(); check(tester.getRect(findBottom)).bottom.equals(600); + + // Try scrolling up (by dragging down); moves only as far as top of list. + await tester.drag(findBottom, Offset(0, 400)); + await tester.pump(); + check(tester.getRect(findBottom)).bottom.equals(900); + check(tester.getRect(findTop)).top.equals(0); + }); + + testWidgets('short/short -> starts at bottom, immediately without animation', (tester) async { + await prepare(tester, topHeight: 100, bottomHeight: 100); + + final ys = []; + for (int i = 0; i < 10; i++) { + ys.add(tester.getRect(findBottom).bottom - 600); + await tester.pump(Duration(milliseconds: 15)); + } + check(ys).deepEquals(List.generate(10, (_) => 0.0)); + }); + + testWidgets('short/long -> starts at bottom, immediately without animation', (tester) async { + await prepare(tester, topHeight: 100, bottomHeight: 800); + + final ys = []; + for (int i = 0; i < 10; i++) { + ys.add(tester.getRect(findBottom).bottom - 600); + await tester.pump(Duration(milliseconds: 15)); + } + check(ys).deepEquals(List.generate(10, (_) => 0.0)); }); testWidgets('starts at bottom, even when bottom underestimated at first', (tester) async { From 0f7bc7a55b1e0a52efd7e5f79fcdaa05ad0dd721 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 24 Mar 2025 16:45:38 -0700 Subject: [PATCH 09/11] scroll: Stay at end once there This is NFC as to the real message list, because so far the bottom sliver there always has height 0, so that both maxScrollExtent and this.maxScrollExtent are always 0. This is a step toward letting us move part of the message list into the bottom sliver, because it means that doing so would preserve the list's current behavior of remaining scrolled to the end once there as e.g. new messages arrive. --- lib/widgets/scrolling.dart | 16 ++++++++++++++ test/widgets/scrolling_test.dart | 38 +++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/lib/widgets/scrolling.dart b/lib/widgets/scrolling.dart index 12a0d97918..b8ea5c77af 100644 --- a/lib/widgets/scrolling.dart +++ b/lib/widgets/scrolling.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter/rendering.dart'; /// A [SingleChildScrollView] that always shows a Material [Scrollbar]. @@ -312,6 +313,9 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext { return applyContentDimensions(effectiveMin, effectiveMax); } + bool _nearEqual(double a, double b) => + nearEqual(a, b, Tolerance.defaultTolerance.distance); + bool _hasEverCompletedLayout = false; @override @@ -329,6 +333,13 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext { correctPixels(target); changed = true; } + } else if (_nearEqual(pixels, this.maxScrollExtent) + && !_nearEqual(pixels, maxScrollExtent)) { + // The list was scrolled to the end before this layout round. + // Make sure it stays at the end. + // (For example, show the new message that just arrived.) + correctPixels(maxScrollExtent); + changed = true; } // This step must come after the first-time correction above. @@ -337,6 +348,11 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext { // then the base implementation of [applyContentDimensions] would // bring it in bounds via a scrolling animation, which isn't right when // starting from the meaningless initial 0.0 value. + // + // For the "stays at the end" correction, it's not clear if the order + // matters in practice. But the doc on [applyNewDimensions], called by + // the base [applyContentDimensions], says it should come after any + // calls to [correctPixels]; so OK, do this after the [correctPixels]. if (!super.applyContentDimensions(minScrollExtent, maxScrollExtent)) { changed = true; } diff --git a/test/widgets/scrolling_test.dart b/test/widgets/scrolling_test.dart index 47fd2dbedc..2b74bd7fb5 100644 --- a/test/widgets/scrolling_test.dart +++ b/test/widgets/scrolling_test.dart @@ -131,11 +131,14 @@ void main() { }); group('MessageListScrollView', () { - Future prepare(WidgetTester tester, - {required double topHeight, required double bottomHeight}) async { + Future prepare(WidgetTester tester, { + MessageListScrollController? controller, + required double topHeight, + required double bottomHeight, + }) async { await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, child: MessageListScrollView( - controller: MessageListScrollController(), + controller: controller ?? MessageListScrollController(), center: const ValueKey('center'), slivers: [ SliverToBoxAdapter( @@ -233,6 +236,35 @@ void main() { check(tester.getRect(find.text('item ${numItems-1}', skipOffstage: false))) .bottom.equals(600); }); + + testWidgets('stick to end of list when it grows', (tester) async { + final controller = MessageListScrollController(); + await prepare(tester, controller: controller, + topHeight: 400, bottomHeight: 400); + check(tester.getRect(findBottom))..top.equals(200)..bottom.equals(600); + + // Bottom sliver grows; remain scrolled to (new) bottom. + await prepare(tester, controller: controller, + topHeight: 400, bottomHeight: 500); + check(tester.getRect(findBottom))..top.equals(100)..bottom.equals(600); + }); + + testWidgets('when not at end, let it grow without following', (tester) async { + final controller = MessageListScrollController(); + await prepare(tester, controller: controller, + topHeight: 400, bottomHeight: 400); + check(tester.getRect(findBottom))..top.equals(200)..bottom.equals(600); + + // Scroll up (by dragging down) to detach from end of list. + await tester.drag(findBottom, Offset(0, 100)); + await tester.pump(); + check(tester.getRect(findBottom))..top.equals(300)..bottom.equals(700); + + // Bottom sliver grows; remain at existing position, now farther from end. + await prepare(tester, controller: controller, + topHeight: 400, bottomHeight: 500); + check(tester.getRect(findBottom))..top.equals(300)..bottom.equals(800); + }); }); } From 26d2d3a5a82ce1bd0f605cf6c824602a20fdc266 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 18 Jan 2024 10:53:16 -0500 Subject: [PATCH 10/11] WIP msglist: Split into back-to-back slivers; TODO test?; TODO scroll-to-end show, act; TODO weaken test on crossing slivers Thanks to all the preparatory changes we've made in this commit series and those that came before it, this change should have only subtle effects on user-visible behavior. In particular: * The sticky headers should behave exactly as they did before, even when the sliver boundary lies under the sticky header, thanks to several previous commit series. * On first loading a given message list, it should start out scrolled to the end, just as before. * When already scrolled to the end, the message list should stay there when a new message arrives, or a message is edited, etc., even if the (new) latest message is taller than it was. Subtle differences include: * When scrolled up from the bottom and a new message comes in, the behavior is slightly different from before. The current exact behavior is something we probably want to change anyway, and I think the new behavior isn't particularly better or worse. * The behavior upon overscroll might be slightly different; I'm not sure. This therefore serves as a testing ground for all the ways that the message list's scrolling behavior can become more complicated when two (nontrivial) slivers are involved. If we find a situation where the behavior does change noticeably, that will be something to investigate and fix. --- lib/widgets/message_list.dart | 71 ++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index d9e410dc06..8c058ad7cb 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -583,11 +583,18 @@ class _MessageListState extends State with PerAccountStoreAwareStat } Widget _buildListView(BuildContext context) { + const bottomSize = 1; final length = model!.items.length; + final bottomLength = length <= bottomSize ? length : bottomSize; + final topLength = length - bottomLength; const centerSliverKey = ValueKey('center sliver'); final zulipLocalizations = ZulipLocalizations.of(context); - Widget sliver = SliverStickyHeaderList( + // TODO(#311) If we have a bottom nav, it will pad the bottom inset, + // and this can be removed; also remove mention in MessageList dartdoc + final needSafeArea = !ComposeBox.hasComposeBox(widget.narrow); + + final topSliver = SliverStickyHeaderList( headerPlacement: HeaderPlacement.scrollingStart, delegate: SliverChildBuilderDelegate( // To preserve state across rebuilds for individual [MessageItem] @@ -609,26 +616,60 @@ class _MessageListState extends State with PerAccountStoreAwareStat final valueKey = key as ValueKey; final index = model!.findItemWithMessageId(valueKey.value); if (index == -1) return null; - return length - 1 - (index - 3); + final i = length - 1 - (index + bottomLength); + if (i < 0) return null; + return i; + }, + childCount: topLength, + (context, i) { + final data = model!.items[length - 1 - (i + bottomLength)]; + final item = _buildItem(zulipLocalizations, data); + return item; + })); + + Widget bottomSliver = SliverStickyHeaderList( + key: needSafeArea ? null : centerSliverKey, + headerPlacement: HeaderPlacement.scrollingStart, + delegate: SliverChildBuilderDelegate( + // To preserve state across rebuilds for individual [MessageItem] + // widgets as the size of [MessageListView.items] changes we need + // to match old widgets by their key to their new position in + // the list. + // + // The keys are of type [ValueKey] with a value of [Message.id] + // and here we use a O(log n) binary search method. This could + // be improved but for now it only triggers for materialized + // widgets. As a simple test, flinging through All Messages in + // CZO on a Pixel 5, this only runs about 10 times per rebuild + // and the timing for each call is <100 microseconds. + // + // Non-message items (e.g., start and end markers) that do not + // have state that needs to be preserved have not been given keys + // and will not trigger this callback. + findChildIndexCallback: (Key key) { + final valueKey = key as ValueKey; + final index = model!.findItemWithMessageId(valueKey.value); + if (index == -1) return null; + final i = index - topLength; + if (i < 0) return null; + return i; }, - childCount: length + 3, + childCount: bottomLength + 3, (context, i) { // To reinforce that the end of the feed has been reached: // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603 - if (i == 0) return const SizedBox(height: 36); + if (i == bottomLength + 2) return const SizedBox(height: 36); - if (i == 1) return MarkAsReadWidget(narrow: widget.narrow); + if (i == bottomLength + 1) return MarkAsReadWidget(narrow: widget.narrow); - if (i == 2) return TypingStatusWidget(narrow: widget.narrow); + if (i == bottomLength) return TypingStatusWidget(narrow: widget.narrow); - final data = model!.items[length - 1 - (i - 3)]; + final data = model!.items[topLength + i]; return _buildItem(zulipLocalizations, data); })); - if (!ComposeBox.hasComposeBox(widget.narrow)) { - // TODO(#311) If we have a bottom nav, it will pad the bottom inset, - // and this can be removed; also remove mention in MessageList dartdoc - sliver = SliverSafeArea(sliver: sliver); + if (needSafeArea) { + bottomSliver = SliverSafeArea(key: centerSliverKey, sliver: bottomSliver); } return MessageListScrollView( @@ -649,12 +690,8 @@ class _MessageListState extends State with PerAccountStoreAwareStat paintOrder: SliverPaintOrder.firstIsTop, slivers: [ - sliver, - - // This is a trivial placeholder that occupies no space. Its purpose is - // to have the key that's passed to [ScrollView.center], and so to cause - // the above [SliverStickyHeaderList] to run from bottom to top. - const SliverToBoxAdapter(key: centerSliverKey), + topSliver, + bottomSliver, ]); } From 6e03b9ee303780664008b6d1f2cc197a6f7293d0 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Tue, 25 Mar 2025 13:56:55 -0700 Subject: [PATCH 11/11] scroll: Show start of latest message if long, instead of end This makes our first payoff in actual UX from having the message list split into two back-to-back slivers! With this change, if you open a message list and the latest message is very tall, the list starts out scrolled so that you can see the top of that latest message -- plus a bit of context above it (25% of the viewport's height). Previously the list would always start out scrolled to the end, so you'd have to scroll up in order to read even the one latest message from the beginning. In addition to a small UX improvement now, this makes a preview of behavior we'll want to have when the bottom sliver starts at the first unread message, and may have many messages after that. This new behavior is nice already with one message, if the message happens to be very tall; but it'll become critical when the bottom sliver is routinely many screenfuls tall. --- lib/widgets/scrolling.dart | 7 ++- test/widgets/scrolling_test.dart | 79 ++++++++++++++++++++++++-------- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/lib/widgets/scrolling.dart b/lib/widgets/scrolling.dart index b8ea5c77af..7e00c6a694 100644 --- a/lib/widgets/scrolling.dart +++ b/lib/widgets/scrolling.dart @@ -325,10 +325,13 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext { if (!_hasEverCompletedLayout) { // The list is being laid out for the first time (its first performLayout). - // Start out scrolled to the end. + // Start out scrolled down so the bottom sliver (the new messages) + // occupies 75% of the viewport, + // or at the in-range scroll position closest to that. // This also brings [pixels] within bounds, which // the initial value of 0.0 might not have been. - final target = maxScrollExtent; + final target = clampDouble(0.75 * viewportDimension, + minScrollExtent, maxScrollExtent); if (!hasPixels || pixels != target) { correctPixels(target); changed = true; diff --git a/test/widgets/scrolling_test.dart b/test/widgets/scrolling_test.dart index 2b74bd7fb5..71c3006f63 100644 --- a/test/widgets/scrolling_test.dart +++ b/test/widgets/scrolling_test.dart @@ -171,20 +171,58 @@ void main() { }); testWidgets('short/long -> scrolls to ends and no farther', (tester) async { - // Starts out scrolled to bottom. + // Starts out scrolled to top (to show top of the bottom sliver). await prepare(tester, topHeight: 100, bottomHeight: 800); - check(tester.getRect(findBottom)).bottom.equals(600); + check(tester.getRect(findTop)).top.equals(0); + check(tester.getRect(findBottom)).bottom.equals(900); - // Try scrolling down (by dragging up); doesn't move. - await tester.drag(findBottom, Offset(0, -100)); + // Try scrolling up (by dragging down); doesn't move. + await tester.drag(findBottom, Offset(0, 100)); await tester.pump(); - check(tester.getRect(findBottom)).bottom.equals(600); + check(tester.getRect(findBottom)).bottom.equals(900); - // Try scrolling up (by dragging down); moves only as far as top of list. - await tester.drag(findBottom, Offset(0, 400)); + // Try scrolling down (by dragging up); moves only as far as bottom of list. + await tester.drag(findBottom, Offset(0, -400)); await tester.pump(); - check(tester.getRect(findBottom)).bottom.equals(900); + check(tester.getRect(findBottom)).bottom.equals(600); + }); + + testWidgets('starts by showing top of bottom sliver, long/long', (tester) async { + // Both slivers are long; the bottom sliver gets 75% of the viewport. + await prepare(tester, topHeight: 1000, bottomHeight: 3000); + check(tester.getRect(findBottom)).top.equals(150); + }); + + testWidgets('starts by showing top of bottom sliver, short/long', (tester) async { + // The top sliver is shorter than 25% of the viewport. + // It's shown in full, and the bottom sliver gets the rest (so >75%). + await prepare(tester, topHeight: 50, bottomHeight: 3000); check(tester.getRect(findTop)).top.equals(0); + check(tester.getRect(findBottom)).top.equals(50); + }); + + testWidgets('starts by showing top of bottom sliver, short/medium', (tester) async { + // The whole list fits in the viewport. It's pinned to the bottom, + // even when that gives the bottom sliver more than 75%. + await prepare(tester, topHeight: 50, bottomHeight: 500); + check(tester.getRect(findTop))..top.equals(50)..bottom.equals(100); + check(tester.getRect(findBottom)).bottom.equals(600); + }); + + testWidgets('starts by showing top of bottom sliver, medium/short', (tester) async { + // The whole list fits in the viewport. It's pinned to the bottom, + // even when that gives the top sliver more than 25%. + await prepare(tester, topHeight: 300, bottomHeight: 100); + check(tester.getRect(findTop))..top.equals(200)..bottom.equals(500); + check(tester.getRect(findBottom)).bottom.equals(600); + }); + + testWidgets('starts by showing top of bottom sliver, long/short', (tester) async { + // The bottom sliver is shorter than 75% of the viewport. + // It's shown in full, and the top sliver gets the rest (so >25%). + await prepare(tester, topHeight: 1000, bottomHeight: 300); + check(tester.getRect(findTop)).bottom.equals(300); + check(tester.getRect(findBottom)).bottom.equals(600); }); testWidgets('short/short -> starts at bottom, immediately without animation', (tester) async { @@ -198,20 +236,20 @@ void main() { check(ys).deepEquals(List.generate(10, (_) => 0.0)); }); - testWidgets('short/long -> starts at bottom, immediately without animation', (tester) async { + testWidgets('short/long -> starts at desired start, immediately without animation', (tester) async { await prepare(tester, topHeight: 100, bottomHeight: 800); final ys = []; for (int i = 0; i < 10; i++) { - ys.add(tester.getRect(findBottom).bottom - 600); + ys.add(tester.getRect(findTop).top); await tester.pump(Duration(milliseconds: 15)); } check(ys).deepEquals(List.generate(10, (_) => 0.0)); }); - testWidgets('starts at bottom, even when bottom underestimated at first', (tester) async { + testWidgets('starts at desired start, even when bottom underestimated at first', (tester) async { const numItems = 10; - const itemHeight = 300.0; + const itemHeight = 20.0; // A list where the bottom sliver takes several rounds of layout // to see how long it really is. @@ -219,22 +257,25 @@ void main() { await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, child: MessageListScrollView( controller: controller, + // The tiny cacheExtent causes each layout round to only reach + // the first item it expects will go beyond the viewport. + cacheExtent: 1.0, // in (logical) pixels! center: const ValueKey('center'), slivers: [ SliverToBoxAdapter( - child: SizedBox(height: 100, child: Text('top'))), + child: SizedBox(height: 300, child: Text('top'))), SliverList.list(key: const ValueKey('center'), children: List.generate(numItems, (i) => SizedBox(height: (i+1) * itemHeight, child: Text('item $i')))), ]))); await tester.pump(); - // Starts out scrolled all the way to the bottom, - // even though it must have taken several rounds of layout to find that. - check(controller.position.pixels) - .equals(itemHeight * numItems * (numItems + 1)/2); - check(tester.getRect(find.text('item ${numItems-1}', skipOffstage: false))) - .bottom.equals(600); + // Starts out with the bottom sliver occupying 75% of the viewport… + check(controller.position.pixels).equals(450); + // … even though it has more height than that. + check(tester.getRect(find.text('item 6'))).bottom.isGreaterThan(600); + // (And even though on the first round of layout, it would have looked + // much shorter so that the view would have tried to scroll to its end.) }); testWidgets('stick to end of list when it grows', (tester) async {