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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 54 additions & 17 deletions lib/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -583,11 +583,18 @@ class _MessageListState extends State<MessageList> 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]
Expand All @@ -609,26 +616,60 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
final valueKey = key as ValueKey<int>;
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<int>;
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)];
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 MessageListScrollView(
Expand All @@ -649,12 +690,8 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
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,
]);
}

Expand Down
7 changes: 5 additions & 2 deletions lib/widgets/scrolling.dart
Original file line number Diff line number Diff line change
Expand Up @@ -333,10 +333,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;
Expand Down
79 changes: 60 additions & 19 deletions test/widgets/scrolling_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -177,20 +177,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 {
Expand All @@ -204,43 +242,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 {
Expand Down
Loading