Skip to content

Commit bf0b569

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 a64e5fd commit bf0b569

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
@@ -580,11 +580,18 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
580580
}
581581

582582
Widget _buildListView(BuildContext context) {
583+
const bottomSize = 1;
583584
final length = model!.items.length;
585+
final bottomLength = length <= bottomSize ? length : bottomSize;
586+
final topLength = length - bottomLength;
584587
const centerSliverKey = ValueKey('center sliver');
585588
final zulipLocalizations = ZulipLocalizations.of(context);
586589

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

617-
if (i == 1) return MarkAsReadWidget(narrow: widget.narrow);
660+
if (i == bottomLength + 1) return MarkAsReadWidget(narrow: widget.narrow);
618661

619-
if (i == 2) return TypingStatusWidget(narrow: widget.narrow);
662+
if (i == bottomLength) return TypingStatusWidget(narrow: widget.narrow);
620663

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

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

631672
return MessageListScrollView(
@@ -646,12 +687,8 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
646687
paintOrder: SliverPaintOrder.firstIsTop,
647688

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

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)