Skip to content

anchors 7/n: Start splitting slivers! #1468

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 1, 2025
86 changes: 66 additions & 20 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
@@ -580,11 +580,20 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
}

Widget _buildListView(BuildContext context) {
final length = model!.items.length;
const centerSliverKey = ValueKey('center sliver');
final zulipLocalizations = ZulipLocalizations.of(context);

Widget sliver = SliverStickyHeaderList(
// The list has two slivers: a top sliver growing upward,
// and a bottom sliver growing downward.
// Each sliver has some of the items from `model!.items`.
const maxBottomItems = 1;
final totalItems = model!.items.length;
final bottomItems = totalItems <= maxBottomItems ? totalItems : maxBottomItems;
final topItems = totalItems - bottomItems;

// The top sliver has its child 0 as the item just before the
// sliver boundary, child 1 as the item before that, and so on.
final topSliver = SliverStickyHeaderList(
headerPlacement: HeaderPlacement.scrollingStart,
delegate: SliverChildBuilderDelegate(
// To preserve state across rebuilds for individual [MessageItem]
@@ -603,29 +612,70 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
// 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<int>;
final index = model!.findItemWithMessageId(valueKey.value);
if (index == -1) return null;
return length - 1 - (index - 3);
final messageId = (key as ValueKey<int>).value;
final itemIndex = model!.findItemWithMessageId(messageId);
if (itemIndex == -1) return null;
final childIndex = totalItems - 1 - (itemIndex + bottomItems);
if (childIndex < 0) return null;
return childIndex;
},
childCount: length + 3,
(context, i) {
childCount: topItems,
(context, childIndex) {
final itemIndex = totalItems - 1 - (childIndex + bottomItems);
final data = model!.items[itemIndex];
final item = _buildItem(zulipLocalizations, data);
return item;
}));

// The bottom sliver has its child 0 as the item just after the
// sliver boundary (just after child 0 of the top sliver),
// its child 1 as the next item after that, and so on.
Widget bottomSliver = SliverStickyHeaderList(
key: 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 messageId = (key as ValueKey<int>).value;
final itemIndex = model!.findItemWithMessageId(messageId);
if (itemIndex == -1) return null;
final childIndex = itemIndex - topItems;
if (childIndex < 0) return null;
return childIndex;
},
childCount: bottomItems + 3,
(context, childIndex) {
// 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 (childIndex == bottomItems + 2) return const SizedBox(height: 36);

if (i == 1) return MarkAsReadWidget(narrow: widget.narrow);
if (childIndex == bottomItems + 1) return MarkAsReadWidget(narrow: widget.narrow);

if (i == 2) return TypingStatusWidget(narrow: widget.narrow);
if (childIndex == bottomItems) return TypingStatusWidget(narrow: widget.narrow);

final data = model!.items[length - 1 - (i - 3)];
final itemIndex = topItems + childIndex;
final data = model!.items[itemIndex];
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);
bottomSliver = SliverSafeArea(key: bottomSliver.key, sliver: bottomSliver);
}

return MessageListScrollView(
@@ -641,17 +691,13 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
},

controller: scrollController,
semanticChildCount: length + 2,
semanticChildCount: totalItems, // TODO(#537): what's the right value for this?
center: centerSliverKey,
paintOrder: SliverPaintOrder.firstIsTop,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

      semanticChildCount: length + 2,

This seems like something that risks getting out-of-date. While we do have tests ("fetch older messages on scroll") for this, it might be helpful to note where the + 2 comes from.

Since nothing breaks in this PR, I assume that the sliver changes do not affect it, but that's otherwise hard to confirm without running the tests.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, good question.

I think in fact this already has gotten out of date. Using git log -L to see history of this method, it looks like 5c70c76 probably should have bumped this to match the change to childCount, making it length + 3.

These got decoupled in 6a8cf5c — previously, with just one sliver, we were using StickyHeaderListView.builder (which is a lot like ListView.builder) and it took just one argument itemCount instead of having semanticChildCount here and separately childCount elsewhere. Before that commit, I think the connection was fairly clear because the itemCount: length + 2 was just a few lines above the two if (i == 0) and if (i == 1) lines.

The original two bumps were in e7fe06c and then 56ab395, corresponding to the mark-as-read button and then the spacer.

OTOH the spacer doesn't seem very semantic, so it probably shouldn't have counted in the first place. And the other two (the mark-as-read button and the typing-status line) are often hidden, so probably shouldn't count either when that's the case.

I'll spend a few minutes trying to work out what the right value should actually be here. Then I'll try to do that, and also leave behind some restructuring and/or comments to help them stay in sync.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we do have tests ("fetch older messages on scroll") for this

Yeah, those tests exercise this (because they query semanticChildCount) but they don't really effectively test it.

That's no fault of those tests, because they weren't meant to test it: as the name says, they're about checking that we fetch older messages on scrolling up, and checking we don't do so when we shouldn't.

One piece of evidence that these tests don't test this line is that 5c70c76 didn't update it.

Another is a thought experiment: if you make a change that causes those checks to fail because of something it does that's specific to semanticChildCount, how would you tell whether that's an intended change and the tests just need updating? It'll probably just look like noise that needs to be updated, because the tests really don't tell a story about this handful of extra children. When they check that e.g. first there are 303 children, then 403 children, the point is that there are 300-plus-a-few and later 400-plus-a-few, indicating that the original 300 messages and later those plus the additional 100 messages are shown.

So for example when 56ab395 did update this line (and probably shouldn't have), it dutifully updated those tests.

I think this makes a good example of how in order to effectively test some logic it's not enough to exercise it: the test needs to tell a clear story about the checks it's making and how they relate to the intended spec. The test's main job is to prevent regressions. When someone drafts a change that would be a regression, the test's first step to prevent it is to get the author's attention by failing; but then the second step, equally essential, is to communicate what the problem is so the author can identify the right fix.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. These count checks reminds me of the self.assert_database_query_count's on the server, but over there the number is used to catch potential performance regressions. Here, the counts are used differently.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll spend a few minutes trying to work out what the right value should actually be here.

OK, just activated TalkBack and played with it for a bit — first refreshing myself on how to use it in general, and then trying it in Zulip.

My main conclusion is that for #535 we'll have some work to do in the message list: particularly when you try scrolling around, there are some glitches that seem to be related to the sticky header. (That'll be an M5b issue, like #535 itself.)

I don't see any direct effect of this semanticChildCount value. In particular it's not getting announced to the user. It probably has an influence on the scale of tones that get played when scrolling around, to indicate how close you are to the beginning vs. the end of the list — but that's pretty subtle.

So I think I'll just simplify this to length, leave a TODO(#537), and call it done for now.


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,
]);
}

7 changes: 5 additions & 2 deletions lib/widgets/scrolling.dart
Original file line number Diff line number Diff line change
@@ -245,10 +245,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;
12 changes: 12 additions & 0 deletions test/flutter_checks.dart
Original file line number Diff line number Diff line change
@@ -142,6 +142,18 @@ extension TextEditingControllerChecks on Subject<TextEditingController> {
Subject<String?> get text => has((t) => t.text, 'text');
}

extension ScrollMetricsChecks on Subject<ScrollMetrics> {
Subject<double> get minScrollExtent => has((x) => x.minScrollExtent, 'minScrollExtent');
Subject<double> get maxScrollExtent => has((x) => x.maxScrollExtent, 'maxScrollExtent');
Subject<double> get pixels => has((x) => x.pixels, 'pixels');
Subject<double> get extentBefore => has((x) => x.extentBefore, 'extentBefore');
Subject<double> get extentAfter => has((x) => x.extentAfter, 'extentAfter');
}

extension ScrollPositionChecks on Subject<ScrollPosition> {
Subject<ScrollActivity?> get activity => has((x) => x.activity, 'activity');
}

extension ScrollActivityChecks on Subject<ScrollActivity> {
Subject<double> get velocity => has((x) => x.velocity, 'velocity');
}
44 changes: 22 additions & 22 deletions test/model/message_list_test.dart
Original file line number Diff line number Diff line change
@@ -1450,28 +1450,28 @@ void main() {
.deepEquals(expected..insertAll(0, [101, 103, 105]));

// … and on MessageEvent.
await store.handleEvent(eg.messageEvent(
eg.streamMessage(id: 301, stream: stream1, topic: 'A')));
await store.addMessage(
eg.streamMessage(id: 301, stream: stream1, topic: 'A'));
checkNotifiedOnce();
check(model.messages.map((m) => m.id)).deepEquals(expected..add(301));

await store.handleEvent(eg.messageEvent(
eg.streamMessage(id: 302, stream: stream1, topic: 'B')));
await store.addMessage(
eg.streamMessage(id: 302, stream: stream1, topic: 'B'));
checkNotNotified();
check(model.messages.map((m) => m.id)).deepEquals(expected);

await store.handleEvent(eg.messageEvent(
eg.streamMessage(id: 303, stream: stream2, topic: 'C')));
await store.addMessage(
eg.streamMessage(id: 303, stream: stream2, topic: 'C'));
checkNotifiedOnce();
check(model.messages.map((m) => m.id)).deepEquals(expected..add(303));

await store.handleEvent(eg.messageEvent(
eg.streamMessage(id: 304, stream: stream2, topic: 'D')));
await store.addMessage(
eg.streamMessage(id: 304, stream: stream2, topic: 'D'));
checkNotNotified();
check(model.messages.map((m) => m.id)).deepEquals(expected);

await store.handleEvent(eg.messageEvent(
eg.dmMessage(id: 305, from: eg.otherUser, to: [eg.selfUser])));
await store.addMessage(
eg.dmMessage(id: 305, from: eg.otherUser, to: [eg.selfUser]));
checkNotifiedOnce();
check(model.messages.map((m) => m.id)).deepEquals(expected..add(305));
});
@@ -1507,18 +1507,18 @@ void main() {
.deepEquals(expected..insertAll(0, [101, 102]));

// … and on MessageEvent.
await store.handleEvent(eg.messageEvent(
eg.streamMessage(id: 301, stream: stream, topic: 'A')));
await store.addMessage(
eg.streamMessage(id: 301, stream: stream, topic: 'A'));
checkNotifiedOnce();
check(model.messages.map((m) => m.id)).deepEquals(expected..add(301));

await store.handleEvent(eg.messageEvent(
eg.streamMessage(id: 302, stream: stream, topic: 'B')));
await store.addMessage(
eg.streamMessage(id: 302, stream: stream, topic: 'B'));
checkNotifiedOnce();
check(model.messages.map((m) => m.id)).deepEquals(expected..add(302));

await store.handleEvent(eg.messageEvent(
eg.streamMessage(id: 303, stream: stream, topic: 'C')));
await store.addMessage(
eg.streamMessage(id: 303, stream: stream, topic: 'C'));
checkNotNotified();
check(model.messages.map((m) => m.id)).deepEquals(expected);
});
@@ -1549,8 +1549,8 @@ void main() {
.deepEquals(expected..insertAll(0, [101]));

// … and on MessageEvent.
await store.handleEvent(eg.messageEvent(
eg.streamMessage(id: 301, stream: stream, topic: 'A')));
await store.addMessage(
eg.streamMessage(id: 301, stream: stream, topic: 'A'));
checkNotifiedOnce();
check(model.messages.map((m) => m.id)).deepEquals(expected..add(301));
});
@@ -1589,7 +1589,7 @@ void main() {
// … and on MessageEvent.
final messages = getMessages(301);
for (var i = 0; i < 3; i += 1) {
await store.handleEvent(eg.messageEvent(messages[i]));
await store.addMessage(messages[i]);
checkNotifiedOnce();
check(model.messages.map((m) => m.id)).deepEquals(expected..add(301 + i));
}
@@ -1627,7 +1627,7 @@ void main() {
// … and on MessageEvent.
final messages = getMessages(301);
for (var i = 0; i < 2; i += 1) {
await store.handleEvent(eg.messageEvent(messages[i]));
await store.addMessage(messages[i]);
checkNotifiedOnce();
check(model.messages.map((m) => m.id)).deepEquals(expected..add(301 + i));
}
@@ -1718,11 +1718,11 @@ void main() {
checkNotified(count: 2);

// Then test MessageEvent, where a new header is needed…
await store.handleEvent(eg.messageEvent(streamMessage(13)));
await store.addMessage(streamMessage(13));
checkNotifiedOnce();

// … and where it's not.
await store.handleEvent(eg.messageEvent(streamMessage(14)));
await store.addMessage(streamMessage(14));
checkNotifiedOnce();

// Then test UpdateMessageEvent edits, where a header is and remains needed…
10 changes: 5 additions & 5 deletions test/model/message_test.dart
Original file line number Diff line number Diff line change
@@ -129,7 +129,7 @@ void main() {
check(store.messages).isEmpty();

final newMessage = eg.streamMessage();
await store.handleEvent(eg.messageEvent(newMessage));
await store.addMessage(newMessage);
check(store.messages).deepEquals({
newMessage.id: newMessage,
});
@@ -148,7 +148,7 @@ void main() {
});

final newMessage = eg.streamMessage();
await store.handleEvent(eg.messageEvent(newMessage));
await store.addMessage(newMessage);
check(store.messages).deepEquals({
for (final m in messages) m.id: m,
newMessage.id: newMessage,
@@ -162,7 +162,7 @@ void main() {
check(store.messages).deepEquals({1: message});

final newMessage = eg.streamMessage(id: 1, content: '<p>bar</p>');
await store.handleEvent(eg.messageEvent(newMessage));
await store.addMessage(newMessage);
check(store.messages).deepEquals({1: newMessage});
});
});
@@ -859,7 +859,7 @@ void main() {
]);

await prepare();
await store.handleEvent(eg.messageEvent(message));
await store.addMessage(message);
}

test('smoke', () async {
@@ -930,7 +930,7 @@ void main() {
),
]);
await prepare();
await store.handleEvent(eg.messageEvent(message));
await store.addMessage(message);
check(store.messages[message.id]).isNotNull().poll.isNull();
});
});
71 changes: 55 additions & 16 deletions test/widgets/message_list_test.dart
Original file line number Diff line number Diff line change
@@ -375,7 +375,7 @@ void main() {
testWidgets('basic', (tester) async {
await setupMessageListPage(tester, foundOldest: false,
messages: List.generate(300, (i) => eg.streamMessage(id: 950 + i, sender: eg.selfUser)));
check(itemCount(tester)).equals(303);
check(itemCount(tester)).equals(301);

// Fling-scroll upward...
await tester.fling(find.byType(MessageListPage), const Offset(0, 300), 8000);
@@ -388,7 +388,7 @@ void main() {
await tester.pump(Duration.zero); // Allow a frame for the response to arrive.

// Now we have more messages.
check(itemCount(tester)).equals(403);
check(itemCount(tester)).equals(401);
});

testWidgets('observe double-fetch glitch', (tester) async {
@@ -429,7 +429,7 @@ void main() {
...List.generate(100, (i) => eg.streamMessage(id: 1302 + i)),
]);
final lastRequest = connection.lastRequest;
check(itemCount(tester)).equals(404);
check(itemCount(tester)).equals(402);

// Fling-scroll upward...
await tester.fling(find.byType(MessageListPage), const Offset(0, 300), 8000);
@@ -453,6 +453,31 @@ void main() {
});
});

group('scroll position', () {
// The scrolling behavior is tested in more detail in the tests of
// [MessageListScrollView], in scrolling_test.dart .

testWidgets('sticks to end upon new message', (tester) async {
await setupMessageListPage(tester,
messages: List.generate(10, (_) => eg.streamMessage(content: '<p>a</p>')));
final controller = findMessageListScrollController(tester)!;

// Starts at end, and with room to scroll up.
check(controller.position)
..extentAfter.equals(0)
..extentBefore.isGreaterThan(0);
final oldPosition = controller.position.pixels;

// On new message, position remains at end…
await store.addMessage(eg.streamMessage(content: '<p>a</p><p>b</p>'));
await tester.pump();
check(controller.position)
..extentAfter.equals(0)
// … even though that means a bigger number now.
..pixels.isGreaterThan(oldPosition);
});
});

group('ScrollToBottomButton interactions', () {
bool isButtonVisible(WidgetTester tester) {
return tester.any(find.descendant(
@@ -462,24 +487,30 @@ void main() {

testWidgets('scrolling changes visibility', (tester) async {
await setupMessageListPage(tester, messageCount: 10);
final scrollController = findMessageListScrollController(tester)!;
// Scroll position starts at the end, so button hidden.
final controller = findMessageListScrollController(tester)!;
check(controller.position).extentAfter.equals(0);
check(isButtonVisible(tester)).equals(false);

scrollController.jumpTo(-600);
// Scrolling up, button becomes visible.
controller.jumpTo(-600);
await tester.pump();
check(controller.position).extentAfter.isGreaterThan(0);
check(isButtonVisible(tester)).equals(true);

scrollController.jumpTo(0);
// Scrolling back down to end, button becomes hidden again.
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pump();
check(controller.position).extentAfter.equals(0);
check(isButtonVisible(tester)).equals(false);
});

testWidgets('dimension updates changes visibility', (tester) async {
await setupMessageListPage(tester, messageCount: 100);

// Scroll up, to hide the button.
final scrollController = findMessageListScrollController(tester)!;
scrollController.jumpTo(-600);
final controller = findMessageListScrollController(tester)!;
controller.jumpTo(-600);
await tester.pump();
check(isButtonVisible(tester)).equals(true);

@@ -497,16 +528,16 @@ void main() {

testWidgets('button works', (tester) async {
await setupMessageListPage(tester, messageCount: 10);
final scrollController = findMessageListScrollController(tester)!;
scrollController.jumpTo(-600);
final controller = findMessageListScrollController(tester)!;
controller.jumpTo(-600);
await tester.pump();
check(scrollController.position.pixels).equals(-600);
check(controller.position).extentAfter.isGreaterOrEqual(600);

// Tap button.
await tester.tap(find.byType(ScrollToBottomButton));
// The list scrolls to the end…
await tester.pumpAndSettle();
check(scrollController.position.pixels).equals(0);
check(controller.position).extentAfter.equals(0);
// … and for good measure confirm the button disappeared.
check(isButtonVisible(tester)).equals(false);
});
@@ -520,7 +551,7 @@ void main() {
// Scroll a long distance up, many screenfuls.
controller.jumpTo(-distance);
await tester.pump();
check(controller.position.pixels).equals(-distance);
check(controller.position).pixels.equals(-distance);

// Tap button.
await tester.tap(find.byType(ScrollToBottomButton));
@@ -1484,8 +1515,15 @@ void main() {
// as the number of items changes in MessageList. See
// `findChildIndexCallback` passed into [SliverStickyHeaderList]
// at [_MessageListState._buildListView].

// TODO(#82): Cut paddingMessage. It's there to paper over a glitch:
// the _UnreadMarker animation *does* get interrupted in the case where
// the message gets pushed from one sliver to the other. See:
// https://github.com/zulip/zulip-flutter/pull/1436#issuecomment-2756738779
// That case will no longer exist when #82 is complete.
final message = eg.streamMessage(flags: []);
await setupMessageListPage(tester, messages: [message]);
final paddingMessage = eg.streamMessage();
await setupMessageListPage(tester, messages: [message, paddingMessage]);
check(getAnimation(tester, message.id))
..value.equals(1.0)
..status.equals(AnimationStatus.dismissed);
@@ -1509,10 +1547,11 @@ void main() {
..status.equals(AnimationStatus.forward);

// introduce new message
check(find.byType(MessageItem)).findsExactly(2);
final newMessage = eg.streamMessage(flags:[MessageFlag.read]);
await store.handleEvent(eg.messageEvent(newMessage));
await store.addMessage(newMessage);
await tester.pump(); // process handleEvent
check(find.byType(MessageItem).evaluate()).length.equals(2);
check(find.byType(MessageItem)).findsExactly(3);
check(getAnimation(tester, message.id))
..value.isGreaterThan(0.0)
..value.isLessThan(1.0)
135 changes: 88 additions & 47 deletions test/widgets/scrolling_test.dart
Original file line number Diff line number Diff line change
@@ -64,20 +64,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 {
@@ -91,43 +129,46 @@ 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 = <double>[];
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.
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: 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 {
@@ -198,30 +239,30 @@ void main() {
await prepare(tester, topHeight: 300, bottomHeight: 600);
await tester.drag(findBottom, Offset(0, 300));
await tester.pump();
check(position.extentAfter).equals(300);
check(position).extentAfter.equals(300);

// Start scrolling to end, from just a short distance up.
position.scrollToEnd();
await tester.pump();
check(position.extentAfter).equals(300);
check(position.activity).isA<ScrollToEndActivity>();
check(position).extentAfter.equals(300);
check(position).activity.isA<ScrollToEndActivity>();

// The scrolling moves at a stately pace; …
await tester.pump(Duration(milliseconds: 100));
check(position.extentAfter).equals(200);
check(position).extentAfter.equals(200);

await tester.pump(Duration(milliseconds: 100));
check(position.extentAfter).equals(100);
check(position).extentAfter.equals(100);

// … then upon reaching the end, …
await tester.pump(Duration(milliseconds: 100));
check(position.extentAfter).equals(0);
check(position).extentAfter.equals(0);

// … goes idle on the next frame, …
await tester.pump(Duration(milliseconds: 1));
check(position.activity).isA<IdleScrollActivity>();
check(position).activity.isA<IdleScrollActivity>();
// … without moving any farther.
check(position.extentAfter).equals(0);
check(position).extentAfter.equals(0);
});

testWidgets('long -> bounded speed', (tester) async {
@@ -231,12 +272,12 @@ void main() {
await prepare(tester, topHeight: distance + 1000, bottomHeight: 300);
await tester.drag(findBottom, Offset(0, distance));
await tester.pump();
check(position.extentAfter).equals(distance);
check(position).extentAfter.equals(distance);

// Start scrolling to end.
position.scrollToEnd();
await tester.pump();
check(position.activity).isA<ScrollToEndActivity>();
check(position).activity.isA<ScrollToEndActivity>();

// Let it scroll, plotting the trajectory.
final log = <double>[];
@@ -249,12 +290,12 @@ void main() {
(i) => distance - referenceSpeed * i));

// Having reached the end, …
check(position.extentAfter).equals(0);
check(position).extentAfter.equals(0);
// … it goes idle on the next frame, …
await tester.pump(Duration(milliseconds: 1));
check(position.activity).isA<IdleScrollActivity>();
check(position).activity.isA<IdleScrollActivity>();
// … without moving any farther.
check(position.extentAfter).equals(0);
check(position).extentAfter.equals(0);
});

testWidgets('starting from overscroll, just drift', (tester) async {
@@ -266,33 +307,33 @@ void main() {
await tester.pump();
final offset1 = position.pixels - position.maxScrollExtent;
check(offset1).isGreaterThan(100 / 2);
check(position.activity).isA<BallisticScrollActivity>();
check(position).activity.isA<BallisticScrollActivity>();

// Start drifting back into range.
await tester.pump(Duration(milliseconds: 10));
final offset2 = position.pixels - position.maxScrollExtent;
check(offset2)..isGreaterThan(0.0)..isLessThan(offset1);
check(position.activity).isA<BallisticScrollActivity>()
check(position).activity.isA<BallisticScrollActivity>()
.velocity.isLessThan(0);

// Invoke `scrollToEnd`. The motion should stop…
position.scrollToEnd();
await tester.pump();
check(position.pixels - position.maxScrollExtent).equals(offset2);
check(position.activity).isA<BallisticScrollActivity>()
check(position).activity.isA<BallisticScrollActivity>()
.velocity.equals(0);

// … and resume drifting from there…
await tester.pump(Duration(milliseconds: 10));
final offset3 = position.pixels - position.maxScrollExtent;
check(offset3)..isGreaterThan(0.0)..isLessThan(offset2);
check(position.activity).isA<BallisticScrollActivity>()
check(position).activity.isA<BallisticScrollActivity>()
.velocity.isLessThan(0);

// … to eventually return to being in range.
await tester.pump(Duration(seconds: 1));
check(position.pixels - position.maxScrollExtent).equals(0);
check(position.activity).isA<IdleScrollActivity>();
check(position).activity.isA<IdleScrollActivity>();

debugDefaultTargetPlatformOverride = null;
});
@@ -305,17 +346,17 @@ void main() {

position.jumpTo(398);
await tester.pump();
check(position.extentAfter).equals(2);
check(position).extentAfter.equals(2);

position.scrollToEnd();
await tester.pump();
check(position.extentAfter).equals(2);
check(position).extentAfter.equals(2);

// Reach the end in just 150ms, not 300ms.
await tester.pump(Duration(milliseconds: 75));
check(position.extentAfter).equals(1);
check(position).extentAfter.equals(1);
await tester.pump(Duration(milliseconds: 75));
check(position.extentAfter).equals(0);
check(position).extentAfter.equals(0);
});

testWidgets('on overscroll, stop', (tester) async {
@@ -325,7 +366,7 @@ void main() {
// Scroll up…
position.jumpTo(400);
await tester.pump();
check(position.extentAfter).equals(600);
check(position).extentAfter.equals(600);

// … then invoke `scrollToEnd`…
position.scrollToEnd();
@@ -334,7 +375,7 @@ void main() {
// … but have the bottom sliver turn out to be shorter than it was.
await prepare(tester, topHeight: 400, bottomHeight: 600,
reuseController: true);
check(position.extentAfter).equals(200);
check(position).extentAfter.equals(200);

// Let the scrolling animation proceed until it hits the end.
int steps = 0;
@@ -348,10 +389,10 @@ void main() {
check(position.pixels - position.maxScrollExtent).equals(0);

// … and the animation is done. Nothing further happens.
check(position.activity).isA<IdleScrollActivity>();
check(position).activity.isA<IdleScrollActivity>();
await tester.pump(Duration(milliseconds: 11));
check(position.pixels - position.maxScrollExtent).equals(0);
check(position.activity).isA<IdleScrollActivity>();
check(position).activity.isA<IdleScrollActivity>();

debugDefaultTargetPlatformOverride = null;
});
@@ -362,7 +403,7 @@ void main() {
// Scroll up…
position.jumpTo(0);
await tester.pump();
check(position.extentAfter).equals(3000);
check(position).extentAfter.equals(3000);

// … then invoke `scrollToEnd`…
position.scrollToEnd();
@@ -371,7 +412,7 @@ void main() {
// … but have the bottom sliver turn out to be longer than it was.
await prepare(tester, topHeight: 1000, bottomHeight: 6000,
reuseController: true);
check(position.extentAfter).equals(6000);
check(position).extentAfter.equals(6000);

// Let the scrolling animation go until it stops.
int steps = 0;