diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 1e95a1be49..8c058ad7cb 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 @@ -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,29 +616,63 @@ 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)]; - return _buildItem(zulipLocalizations, data, i); + 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 CustomPaintOrderScrollView( + return MessageListScrollView( // TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or // similar) if that is ever offered: // https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849 @@ -645,21 +686,16 @@ class _MessageListState extends State with PerAccountStoreAwareStat controller: scrollController, semanticChildCount: length + 2, - anchor: 1.0, center: centerSliverKey, 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, ]); } - Widget _buildItem(ZulipLocalizations zulipLocalizations, MessageListItem data, int i) { + Widget _buildItem(ZulipLocalizations zulipLocalizations, MessageListItem data) { switch (data) { case MessageListHistoryStartItem(): return Center( @@ -685,7 +721,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); } } diff --git a/lib/widgets/scrolling.dart b/lib/widgets/scrolling.dart index 7ceff8b745..7e00c6a694 100644 --- a/lib/widgets/scrolling.dart +++ b/lib/widgets/scrolling.dart @@ -1,4 +1,8 @@ +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]. @@ -244,3 +248,450 @@ 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, + }); + + /// 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) { + // 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); + } + + bool _nearEqual(double a, double b) => + nearEqual(a, b, Tolerance.defaultTolerance.distance); + + 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 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 = clampDouble(0.75 * viewportDimension, + minScrollExtent, maxScrollExtent); + if (!hasPixels || pixels != target) { + 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. + // 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. + // + // 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; + } + + 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. +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 +/// 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.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, + clipBehavior: clipBehavior, + paintOrder_: paintOrder_, + ); + } +} + +/// The version of [Viewport] that underlies [MessageListScrollView]. +class MessageListViewport extends CustomPaintOrderViewport { + MessageListViewport({ + super.key, + super.axisDirection, + super.crossAxisDirection, + 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), + offset: offset, + cacheExtent: cacheExtent, + cacheExtentStyle: cacheExtentStyle, + clipBehavior: clipBehavior, + paintOrder_: paintOrder_, + ); + } +} + +/// 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, + required super.crossAxisDirection, + required super.offset, + super.children, + super.center, + super.cacheExtent, + super.cacheExtentStyle, + super.clipBehavior, + required super.paintOrder_, + }); + + @override + double get anchor => 1.0; + + 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 { + // 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; + } + } + 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). + assert(anchor == 1.0); + 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; + } + } + +} diff --git a/test/widgets/scrolling_test.dart b/test/widgets/scrolling_test.dart index bfb010ccf0..71c3006f63 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,184 @@ void main() { .deepEquals(sliverIds(result.path)); }); }); + + group('MessageListScrollView', () { + Future prepare(WidgetTester tester, { + MessageListScrollController? controller, + required double topHeight, + required double bottomHeight, + }) async { + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: MessageListScrollView( + controller: 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 -> 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); + + // 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); + + // 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 -> scrolls to ends and no farther', (tester) async { + // Starts out scrolled to top (to show top of the bottom sliver). + await prepare(tester, topHeight: 100, bottomHeight: 800); + check(tester.getRect(findTop)).top.equals(0); + check(tester.getRect(findBottom)).bottom.equals(900); + + // 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(900); + + // 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(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 { + 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 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(findTop).top); + await tester.pump(Duration(milliseconds: 15)); + } + check(ys).deepEquals(List.generate(10, (_) => 0.0)); + }); + + testWidgets('starts at desired start, even when bottom underestimated at first', (tester) async { + const numItems = 10; + const itemHeight = 20.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, + // 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: 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 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 { + 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); + }); + }); } class TestCustomPainter extends CustomPainter {