Skip to content

Commit 6e03b9e

Browse files
committed
scroll: Show start of latest message if long, instead of end
This makes our first payoff in actual UX from having the message list split into two back-to-back slivers! With this change, if you open a message list and the latest message is very tall, the list starts out scrolled so that you can see the top of that latest message -- plus a bit of context above it (25% of the viewport's height). Previously the list would always start out scrolled to the end, so you'd have to scroll up in order to read even the one latest message from the beginning. In addition to a small UX improvement now, this makes a preview of behavior we'll want to have when the bottom sliver starts at the first unread message, and may have many messages after that. This new behavior is nice already with one message, if the message happens to be very tall; but it'll become critical when the bottom sliver is routinely many screenfuls tall.
1 parent 26d2d3a commit 6e03b9e

File tree

2 files changed

+65
-21
lines changed

2 files changed

+65
-21
lines changed

lib/widgets/scrolling.dart

+5-2
Original file line numberDiff line numberDiff line change
@@ -325,10 +325,13 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext {
325325

326326
if (!_hasEverCompletedLayout) {
327327
// The list is being laid out for the first time (its first performLayout).
328-
// Start out scrolled to the end.
328+
// Start out scrolled down so the bottom sliver (the new messages)
329+
// occupies 75% of the viewport,
330+
// or at the in-range scroll position closest to that.
329331
// This also brings [pixels] within bounds, which
330332
// the initial value of 0.0 might not have been.
331-
final target = maxScrollExtent;
333+
final target = clampDouble(0.75 * viewportDimension,
334+
minScrollExtent, maxScrollExtent);
332335
if (!hasPixels || pixels != target) {
333336
correctPixels(target);
334337
changed = true;

test/widgets/scrolling_test.dart

+60-19
Original file line numberDiff line numberDiff line change
@@ -171,20 +171,58 @@ void main() {
171171
});
172172

173173
testWidgets('short/long -> scrolls to ends and no farther', (tester) async {
174-
// Starts out scrolled to bottom.
174+
// Starts out scrolled to top (to show top of the bottom sliver).
175175
await prepare(tester, topHeight: 100, bottomHeight: 800);
176-
check(tester.getRect(findBottom)).bottom.equals(600);
176+
check(tester.getRect(findTop)).top.equals(0);
177+
check(tester.getRect(findBottom)).bottom.equals(900);
177178

178-
// Try scrolling down (by dragging up); doesn't move.
179-
await tester.drag(findBottom, Offset(0, -100));
179+
// Try scrolling up (by dragging down); doesn't move.
180+
await tester.drag(findBottom, Offset(0, 100));
180181
await tester.pump();
181-
check(tester.getRect(findBottom)).bottom.equals(600);
182+
check(tester.getRect(findBottom)).bottom.equals(900);
182183

183-
// Try scrolling up (by dragging down); moves only as far as top of list.
184-
await tester.drag(findBottom, Offset(0, 400));
184+
// Try scrolling down (by dragging up); moves only as far as bottom of list.
185+
await tester.drag(findBottom, Offset(0, -400));
185186
await tester.pump();
186-
check(tester.getRect(findBottom)).bottom.equals(900);
187+
check(tester.getRect(findBottom)).bottom.equals(600);
188+
});
189+
190+
testWidgets('starts by showing top of bottom sliver, long/long', (tester) async {
191+
// Both slivers are long; the bottom sliver gets 75% of the viewport.
192+
await prepare(tester, topHeight: 1000, bottomHeight: 3000);
193+
check(tester.getRect(findBottom)).top.equals(150);
194+
});
195+
196+
testWidgets('starts by showing top of bottom sliver, short/long', (tester) async {
197+
// The top sliver is shorter than 25% of the viewport.
198+
// It's shown in full, and the bottom sliver gets the rest (so >75%).
199+
await prepare(tester, topHeight: 50, bottomHeight: 3000);
187200
check(tester.getRect(findTop)).top.equals(0);
201+
check(tester.getRect(findBottom)).top.equals(50);
202+
});
203+
204+
testWidgets('starts by showing top of bottom sliver, short/medium', (tester) async {
205+
// The whole list fits in the viewport. It's pinned to the bottom,
206+
// even when that gives the bottom sliver more than 75%.
207+
await prepare(tester, topHeight: 50, bottomHeight: 500);
208+
check(tester.getRect(findTop))..top.equals(50)..bottom.equals(100);
209+
check(tester.getRect(findBottom)).bottom.equals(600);
210+
});
211+
212+
testWidgets('starts by showing top of bottom sliver, medium/short', (tester) async {
213+
// The whole list fits in the viewport. It's pinned to the bottom,
214+
// even when that gives the top sliver more than 25%.
215+
await prepare(tester, topHeight: 300, bottomHeight: 100);
216+
check(tester.getRect(findTop))..top.equals(200)..bottom.equals(500);
217+
check(tester.getRect(findBottom)).bottom.equals(600);
218+
});
219+
220+
testWidgets('starts by showing top of bottom sliver, long/short', (tester) async {
221+
// The bottom sliver is shorter than 75% of the viewport.
222+
// It's shown in full, and the top sliver gets the rest (so >25%).
223+
await prepare(tester, topHeight: 1000, bottomHeight: 300);
224+
check(tester.getRect(findTop)).bottom.equals(300);
225+
check(tester.getRect(findBottom)).bottom.equals(600);
188226
});
189227

190228
testWidgets('short/short -> starts at bottom, immediately without animation', (tester) async {
@@ -198,43 +236,46 @@ void main() {
198236
check(ys).deepEquals(List.generate(10, (_) => 0.0));
199237
});
200238

201-
testWidgets('short/long -> starts at bottom, immediately without animation', (tester) async {
239+
testWidgets('short/long -> starts at desired start, immediately without animation', (tester) async {
202240
await prepare(tester, topHeight: 100, bottomHeight: 800);
203241

204242
final ys = <double>[];
205243
for (int i = 0; i < 10; i++) {
206-
ys.add(tester.getRect(findBottom).bottom - 600);
244+
ys.add(tester.getRect(findTop).top);
207245
await tester.pump(Duration(milliseconds: 15));
208246
}
209247
check(ys).deepEquals(List.generate(10, (_) => 0.0));
210248
});
211249

212-
testWidgets('starts at bottom, even when bottom underestimated at first', (tester) async {
250+
testWidgets('starts at desired start, even when bottom underestimated at first', (tester) async {
213251
const numItems = 10;
214-
const itemHeight = 300.0;
252+
const itemHeight = 20.0;
215253

216254
// A list where the bottom sliver takes several rounds of layout
217255
// to see how long it really is.
218256
final controller = MessageListScrollController();
219257
await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr,
220258
child: MessageListScrollView(
221259
controller: controller,
260+
// The tiny cacheExtent causes each layout round to only reach
261+
// the first item it expects will go beyond the viewport.
262+
cacheExtent: 1.0, // in (logical) pixels!
222263
center: const ValueKey('center'),
223264
slivers: [
224265
SliverToBoxAdapter(
225-
child: SizedBox(height: 100, child: Text('top'))),
266+
child: SizedBox(height: 300, child: Text('top'))),
226267
SliverList.list(key: const ValueKey('center'),
227268
children: List.generate(numItems, (i) =>
228269
SizedBox(height: (i+1) * itemHeight, child: Text('item $i')))),
229270
])));
230271
await tester.pump();
231272

232-
// Starts out scrolled all the way to the bottom,
233-
// even though it must have taken several rounds of layout to find that.
234-
check(controller.position.pixels)
235-
.equals(itemHeight * numItems * (numItems + 1)/2);
236-
check(tester.getRect(find.text('item ${numItems-1}', skipOffstage: false)))
237-
.bottom.equals(600);
273+
// Starts out with the bottom sliver occupying 75% of the viewport…
274+
check(controller.position.pixels).equals(450);
275+
// … even though it has more height than that.
276+
check(tester.getRect(find.text('item 6'))).bottom.isGreaterThan(600);
277+
// (And even though on the first round of layout, it would have looked
278+
// much shorter so that the view would have tried to scroll to its end.)
238279
});
239280

240281
testWidgets('stick to end of list when it grows', (tester) async {

0 commit comments

Comments
 (0)