Skip to content

Commit 9e6624b

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 70495f2 commit 9e6624b

File tree

2 files changed

+87
-19
lines changed

2 files changed

+87
-19
lines changed

lib/widgets/message_list.dart

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -581,11 +581,18 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
581581
}
582582

583583
Widget _buildListView(BuildContext context) {
584+
const bottomSize = 1;
584585
final length = model!.items.length;
586+
final bottomLength = length <= bottomSize ? length : bottomSize;
587+
final topLength = length - bottomLength;
585588
const centerSliverKey = ValueKey('center sliver');
586589
final zulipLocalizations = ZulipLocalizations.of(context);
587590

588-
Widget sliver = SliverStickyHeaderList(
591+
// TODO(#311) If we have a bottom nav, it will pad the bottom inset,
592+
// and this can be removed; also remove mention in MessageList dartdoc
593+
final needSafeArea = !ComposeBox.hasComposeBox(widget.narrow);
594+
595+
final topSliver = SliverStickyHeaderList(
589596
headerPlacement: HeaderPlacement.scrollingStart,
590597
delegate: SliverChildBuilderDelegate(
591598
// To preserve state across rebuilds for individual [MessageItem]
@@ -607,26 +614,60 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
607614
final valueKey = key as ValueKey<int>;
608615
final index = model!.findItemWithMessageId(valueKey.value);
609616
if (index == -1) return null;
610-
return length - 1 - (index - 3);
617+
final i = length - 1 - (index + bottomLength);
618+
if (i < 0) return null;
619+
return i;
620+
},
621+
childCount: topLength,
622+
(context, i) {
623+
final data = model!.items[length - 1 - (i + bottomLength)];
624+
final item = _buildItem(zulipLocalizations, data);
625+
return item;
626+
}));
627+
628+
Widget bottomSliver = SliverStickyHeaderList(
629+
key: needSafeArea ? null : centerSliverKey,
630+
headerPlacement: HeaderPlacement.scrollingStart,
631+
delegate: SliverChildBuilderDelegate(
632+
// To preserve state across rebuilds for individual [MessageItem]
633+
// widgets as the size of [MessageListView.items] changes we need
634+
// to match old widgets by their key to their new position in
635+
// the list.
636+
//
637+
// The keys are of type [ValueKey] with a value of [Message.id]
638+
// and here we use a O(log n) binary search method. This could
639+
// be improved but for now it only triggers for materialized
640+
// widgets. As a simple test, flinging through All Messages in
641+
// CZO on a Pixel 5, this only runs about 10 times per rebuild
642+
// and the timing for each call is <100 microseconds.
643+
//
644+
// Non-message items (e.g., start and end markers) that do not
645+
// have state that needs to be preserved have not been given keys
646+
// and will not trigger this callback.
647+
findChildIndexCallback: (Key key) {
648+
final valueKey = key as ValueKey<int>;
649+
final index = model!.findItemWithMessageId(valueKey.value);
650+
if (index == -1) return null;
651+
final i = index - topLength;
652+
if (i < 0) return null;
653+
return i;
611654
},
612-
childCount: length + 3,
655+
childCount: bottomLength + 3,
613656
(context, i) {
614657
// To reinforce that the end of the feed has been reached:
615658
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
616-
if (i == 0) return const SizedBox(height: 36);
659+
if (i == bottomLength + 2) return const SizedBox(height: 36);
617660

618-
if (i == 1) return MarkAsReadWidget(narrow: widget.narrow);
661+
if (i == bottomLength + 1) return MarkAsReadWidget(narrow: widget.narrow);
619662

620-
if (i == 2) return TypingStatusWidget(narrow: widget.narrow);
663+
if (i == bottomLength) return TypingStatusWidget(narrow: widget.narrow);
621664

622-
final data = model!.items[length - 1 - (i - 3)];
665+
final data = model!.items[topLength + i];
623666
return _buildItem(zulipLocalizations, data);
624667
}));
625668

626-
if (!ComposeBox.hasComposeBox(widget.narrow)) {
627-
// TODO(#311) If we have a bottom nav, it will pad the bottom inset,
628-
// and this can be removed; also remove mention in MessageList dartdoc
629-
sliver = SliverSafeArea(sliver: sliver);
669+
if (needSafeArea) {
670+
bottomSliver = SliverSafeArea(key: centerSliverKey, sliver: bottomSliver);
630671
}
631672

632673
return MessageListScrollView(
@@ -647,12 +688,8 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
647688
paintOrder: SliverPaintOrder.firstIsTop,
648689

649690
slivers: [
650-
sliver,
651-
652-
// This is a trivial placeholder that occupies no space. Its purpose is
653-
// to have the key that's passed to [ScrollView.center], and so to cause
654-
// the above [SliverStickyHeaderList] to run from bottom to top.
655-
const SliverToBoxAdapter(key: centerSliverKey),
691+
topSliver,
692+
bottomSliver,
656693
]);
657694
}
658695

test/widgets/message_list_test.dart

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,29 @@ 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.extentAfter).equals(0);
467+
check(controller.position.extentBefore).isGreaterThan(0);
468+
final oldPosition = controller.position.pixels;
469+
470+
// On new message, position remains at end…
471+
await store.addMessage(eg.streamMessage(content: '<p>a</p><p>b</p>'));
472+
await tester.pump();
473+
check(controller.position.extentAfter).equals(0);
474+
// … even though that means a bigger number now.
475+
check(controller.position.pixels).isGreaterThan(oldPosition);
476+
});
477+
});
478+
456479
group('ScrollToBottomButton interactions', () {
457480
bool isButtonVisible(WidgetTester tester) {
458481
return tester.any(find.descendant(
@@ -1490,8 +1513,15 @@ void main() {
14901513
// as the number of items changes in MessageList. See
14911514
// `findChildIndexCallback` passed into [SliverStickyHeaderList]
14921515
// at [_MessageListState._buildListView].
1516+
1517+
// TODO(#82): Cut paddingMessage. It's there to paper over a glitch:
1518+
// the _UnreadMarker animation *does* get interrupted in the case where
1519+
// the message gets pushed from one sliver to the other. See:
1520+
// https://github.com/zulip/zulip-flutter/pull/1436#issuecomment-2756738779
1521+
// That case will no longer exist when #82 is complete.
14931522
final message = eg.streamMessage(flags: []);
1494-
await setupMessageListPage(tester, messages: [message]);
1523+
final paddingMessage = eg.streamMessage();
1524+
await setupMessageListPage(tester, messages: [message, paddingMessage]);
14951525
check(getAnimation(tester, message.id))
14961526
..value.equals(1.0)
14971527
..status.equals(AnimationStatus.dismissed);
@@ -1515,10 +1545,11 @@ void main() {
15151545
..status.equals(AnimationStatus.forward);
15161546

15171547
// introduce new message
1548+
check(find.byType(MessageItem)).findsExactly(2);
15181549
final newMessage = eg.streamMessage(flags:[MessageFlag.read]);
15191550
await store.addMessage(newMessage);
15201551
await tester.pump(); // process handleEvent
1521-
check(find.byType(MessageItem).evaluate()).length.equals(2);
1552+
check(find.byType(MessageItem)).findsExactly(3);
15221553
check(getAnimation(tester, message.id))
15231554
..value.isGreaterThan(0.0)
15241555
..value.isLessThan(1.0)

0 commit comments

Comments
 (0)