Skip to content

Commit fca651b

Browse files
committed
msglist: Split into back-to-back 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. 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 (beyond what's described below), that will be something to investigate and fix. 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. This commit adds a test to confirm that. 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. * When a new message arrives, the previously-latest message gets a fresh element in the tree, and any UI state on it is lost. This happens because it moves between one sliver-list and the other, and the `findChildIndexCallback` mechanism only works within a single sliver-list. This causes a couple of visible glitches, but they seem tolerable. This will also naturally get fixed in the next PR or two toward #82: we'll start adding new messages to the bottom sliver, rather than having them push anything from the bottom to the top sliver. Known symptoms of this are: * When the user has just marked messages as read and a new message arrives while the unread markers are fading, the marker on the previously-latest message will (if it was present) abruptly vanish while the others smoothly continue fading. We have a test for the smooth fading, and it happened to test the latest message, so this commit adjusts the test to avoid triggering this issue. * If the user had a spoiler expanded on the previously-latest message, it will reset to collapsed.
1 parent 271c55e commit fca651b

File tree

2 files changed

+95
-18
lines changed

2 files changed

+95
-18
lines changed

lib/widgets/message_list.dart

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -580,11 +580,20 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
580580
}
581581

582582
Widget _buildListView(BuildContext context) {
583-
final numItems = model!.items.length;
584583
const centerSliverKey = ValueKey('center sliver');
585584
final zulipLocalizations = ZulipLocalizations.of(context);
586585

587-
Widget sliver = SliverStickyHeaderList(
586+
// The list has two slivers: a top sliver growing upward,
587+
// and a bottom sliver growing downward.
588+
// Each sliver has some of the items from `model!.items`.
589+
const maxBottomItems = 1;
590+
final totalItems = model!.items.length;
591+
final bottomItems = totalItems <= maxBottomItems ? totalItems : maxBottomItems;
592+
final topItems = totalItems - bottomItems;
593+
594+
// The top sliver has its child 0 as the item just before the
595+
// sliver boundary, child 1 as the item before that, and so on.
596+
final topSliver = SliverStickyHeaderList(
588597
headerPlacement: HeaderPlacement.scrollingStart,
589598
delegate: SliverChildBuilderDelegate(
590599
// To preserve state across rebuilds for individual [MessageItem]
@@ -606,28 +615,67 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
606615
final messageId = (key as ValueKey<int>).value;
607616
final itemIndex = model!.findItemWithMessageId(messageId);
608617
if (itemIndex == -1) return null;
609-
final childIndex = numItems - 1 - (itemIndex - 3);
618+
final childIndex = totalItems - 1 - (itemIndex + bottomItems);
619+
if (childIndex < 0) return null;
610620
return childIndex;
611621
},
612-
childCount: numItems + 3,
622+
childCount: topItems,
623+
(context, childIndex) {
624+
final itemIndex = totalItems - 1 - (childIndex + bottomItems);
625+
final data = model!.items[itemIndex];
626+
final item = _buildItem(zulipLocalizations, data);
627+
return item;
628+
}));
629+
630+
// The bottom sliver has its child 0 as the item just after the
631+
// sliver boundary (just after child 0 of the top sliver),
632+
// its child 1 as the next item after that, and so on.
633+
Widget bottomSliver = SliverStickyHeaderList(
634+
key: centerSliverKey,
635+
headerPlacement: HeaderPlacement.scrollingStart,
636+
delegate: SliverChildBuilderDelegate(
637+
// To preserve state across rebuilds for individual [MessageItem]
638+
// widgets as the size of [MessageListView.items] changes we need
639+
// to match old widgets by their key to their new position in
640+
// the list.
641+
//
642+
// The keys are of type [ValueKey] with a value of [Message.id]
643+
// and here we use a O(log n) binary search method. This could
644+
// be improved but for now it only triggers for materialized
645+
// widgets. As a simple test, flinging through All Messages in
646+
// CZO on a Pixel 5, this only runs about 10 times per rebuild
647+
// and the timing for each call is <100 microseconds.
648+
//
649+
// Non-message items (e.g., start and end markers) that do not
650+
// have state that needs to be preserved have not been given keys
651+
// and will not trigger this callback.
652+
findChildIndexCallback: (Key key) {
653+
final messageId = (key as ValueKey<int>).value;
654+
final itemIndex = model!.findItemWithMessageId(messageId);
655+
if (itemIndex == -1) return null;
656+
final childIndex = itemIndex - topItems;
657+
if (childIndex < 0) return null;
658+
return childIndex;
659+
},
660+
childCount: bottomItems + 3,
613661
(context, childIndex) {
614662
// To reinforce that the end of the feed has been reached:
615663
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
616-
if (childIndex == 0) return const SizedBox(height: 36);
664+
if (childIndex == bottomItems + 2) return const SizedBox(height: 36);
617665

618-
if (childIndex == 1) return MarkAsReadWidget(narrow: widget.narrow);
666+
if (childIndex == bottomItems + 1) return MarkAsReadWidget(narrow: widget.narrow);
619667

620-
if (childIndex == 2) return TypingStatusWidget(narrow: widget.narrow);
668+
if (childIndex == bottomItems) return TypingStatusWidget(narrow: widget.narrow);
621669

622-
final itemIndex = numItems - 1 - (childIndex - 3);
670+
final itemIndex = topItems + childIndex;
623671
final data = model!.items[itemIndex];
624672
return _buildItem(zulipLocalizations, data);
625673
}));
626674

627675
if (!ComposeBox.hasComposeBox(widget.narrow)) {
628676
// TODO(#311) If we have a bottom nav, it will pad the bottom inset,
629677
// and this can be removed; also remove mention in MessageList dartdoc
630-
sliver = SliverSafeArea(sliver: sliver);
678+
bottomSliver = SliverSafeArea(key: bottomSliver.key, sliver: bottomSliver);
631679
}
632680

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

645693
controller: scrollController,
646-
semanticChildCount: numItems, // TODO(#537): what's the right value for this?
694+
semanticChildCount: totalItems, // TODO(#537): what's the right value for this?
647695
center: centerSliverKey,
648696
paintOrder: SliverPaintOrder.firstIsTop,
649697

650698
slivers: [
651-
sliver,
652-
653-
// This is a trivial placeholder that occupies no space. Its purpose is
654-
// to have the key that's passed to [ScrollView.center], and so to cause
655-
// the above [SliverStickyHeaderList] to run from bottom to top.
656-
const SliverToBoxAdapter(key: centerSliverKey),
699+
topSliver,
700+
bottomSliver,
657701
]);
658702
}
659703

test/widgets/message_list_test.dart

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,31 @@ void main() {
453453
});
454454
});
455455

456+
group('scroll position', () {
457+
// The scrolling behavior is tested in more detail in the tests of
458+
// [MessageListScrollView], in scrolling_test.dart .
459+
460+
testWidgets('sticks to end upon new message', (tester) async {
461+
await setupMessageListPage(tester,
462+
messages: List.generate(10, (_) => eg.streamMessage(content: '<p>a</p>')));
463+
final controller = findMessageListScrollController(tester)!;
464+
465+
// Starts at end, and with room to scroll up.
466+
check(controller.position)
467+
..extentAfter.equals(0)
468+
..extentBefore.isGreaterThan(0);
469+
final oldPosition = controller.position.pixels;
470+
471+
// On new message, position remains at end…
472+
await store.addMessage(eg.streamMessage(content: '<p>a</p><p>b</p>'));
473+
await tester.pump();
474+
check(controller.position)
475+
..extentAfter.equals(0)
476+
// … even though that means a bigger number now.
477+
..pixels.isGreaterThan(oldPosition);
478+
});
479+
});
480+
456481
group('ScrollToBottomButton interactions', () {
457482
bool isButtonVisible(WidgetTester tester) {
458483
return tester.any(find.descendant(
@@ -1490,8 +1515,15 @@ void main() {
14901515
// as the number of items changes in MessageList. See
14911516
// `findChildIndexCallback` passed into [SliverStickyHeaderList]
14921517
// at [_MessageListState._buildListView].
1518+
1519+
// TODO(#82): Cut paddingMessage. It's there to paper over a glitch:
1520+
// the _UnreadMarker animation *does* get interrupted in the case where
1521+
// the message gets pushed from one sliver to the other. See:
1522+
// https://github.com/zulip/zulip-flutter/pull/1436#issuecomment-2756738779
1523+
// That case will no longer exist when #82 is complete.
14931524
final message = eg.streamMessage(flags: []);
1494-
await setupMessageListPage(tester, messages: [message]);
1525+
final paddingMessage = eg.streamMessage();
1526+
await setupMessageListPage(tester, messages: [message, paddingMessage]);
14951527
check(getAnimation(tester, message.id))
14961528
..value.equals(1.0)
14971529
..status.equals(AnimationStatus.dismissed);
@@ -1515,10 +1547,11 @@ void main() {
15151547
..status.equals(AnimationStatus.forward);
15161548

15171549
// introduce new message
1550+
check(find.byType(MessageItem)).findsExactly(2);
15181551
final newMessage = eg.streamMessage(flags:[MessageFlag.read]);
15191552
await store.addMessage(newMessage);
15201553
await tester.pump(); // process handleEvent
1521-
check(find.byType(MessageItem).evaluate()).length.equals(2);
1554+
check(find.byType(MessageItem)).findsExactly(3);
15221555
check(getAnimation(tester, message.id))
15231556
..value.isGreaterThan(0.0)
15241557
..value.isLessThan(1.0)

0 commit comments

Comments
 (0)