Skip to content

Commit 07a3905

Browse files
committed
scroll: Keep short list pinned at bottom of viewport, not past bottom
This is NFC as to the real message list, because so far the bottom sliver there always has height 0. Before this change, the user could always scroll up (moving the content down) so that the bottom sliver was entirely off the bottom of the viewport, even if that exposed blank space at the top of the viewport because the top sliver was shorter than the viewport. After this change, it's never in bounds to have part of the viewport be blank for lack of content while there's content scrolled out of the viewport at the other end. This is a step toward letting us move part of the message list into the bottom sliver, because it fixes a bug that would otherwise create in the case where the top sliver fits entirely on the screen.
1 parent 1667c6e commit 07a3905

File tree

2 files changed

+78
-4
lines changed

2 files changed

+78
-4
lines changed

lib/widgets/scrolling.dart

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,42 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext {
273273
/// 0.0 and 60.0, or -10.0 and 10.0, or -40.0 and 0.0, or other values,
274274
/// depending on the value of [Viewport.anchor].
275275
bool applyContentDimensionsRaw(double wholeMinScrollExtent, double wholeMaxScrollExtent) {
276-
// This makes the simplifying assumption that `anchor` is 1.0.
277-
final effectiveMin = math.min(0.0, wholeMinScrollExtent + viewportDimension);
276+
// The origin point of these scroll coordinates, scroll extent 0.0,
277+
// is that the boundary between slivers is the bottom edge of the viewport.
278+
// (That's expressed by setting `anchor` to 1.0, consulted in
279+
// `_attemptLayout` below.)
280+
281+
// The farthest the list can scroll down (moving the content up)
282+
// is to the point where the bottom end of the list
283+
// touches the bottom edge of the viewport.
278284
final effectiveMax = wholeMaxScrollExtent;
285+
286+
// The farthest the list can scroll up (moving the content down)
287+
// is either:
288+
// * the same as the farthest it can scroll down,
289+
// * or the point where the top end of the list
290+
// touches the top edge of the viewport,
291+
// whichever is farther up.
292+
final effectiveMin = math.min(effectiveMax,
293+
wholeMinScrollExtent + viewportDimension);
294+
295+
// The first point comes into effect when the list is short,
296+
// so the whole thing fits into the viewport. In that case,
297+
// the only scroll position allowed is with the bottom end of the list
298+
// at the bottom edge of the viewport.
299+
300+
// The upstream answer (with no `applyContentDimensionsRaw`) would
301+
// effectively say:
302+
// final effectiveMin = math.min(0.0,
303+
// wholeMinScrollExtent + viewportDimension);
304+
//
305+
// In other words, the farthest the list can scroll up might be farther up
306+
// than the answer here: it could always scroll up to 0.0, meaning that the
307+
// boundary between slivers is at the bottom edge of the viewport.
308+
// Whenever the top sliver is shorter than the viewport (and the bottom
309+
// sliver isn't empty), this would mean one can scroll up past
310+
// the top of the list, even though that scrolls other content offscreen.
311+
279312
return applyContentDimensions(effectiveMin, effectiveMax);
280313
}
281314

@@ -289,13 +322,21 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext {
289322
if (!_hasEverCompletedLayout) {
290323
// The list is being laid out for the first time (its first performLayout).
291324
// Start out scrolled to the end.
325+
// This also brings [pixels] within bounds, which
326+
// the initial value of 0.0 might not have been.
292327
final target = maxScrollExtent;
293328
if (!hasPixels || pixels != target) {
294329
correctPixels(target);
295330
changed = true;
296331
}
297332
}
298333

334+
// This step must come after the first-time correction above.
335+
// Otherwise, if the initial [pixels] value of 0.0 was out of bounds
336+
// (which happens if the top slivers are shorter than the viewport),
337+
// then the base implementation of [applyContentDimensions] would
338+
// bring it in bounds via a scrolling animation, which isn't right when
339+
// starting from the meaningless initial 0.0 value.
299340
if (!super.applyContentDimensions(minScrollExtent, maxScrollExtent)) {
300341
changed = true;
301342
}

test/widgets/scrolling_test.dart

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ void main() {
151151
final findTop = find.text('top', skipOffstage: false);
152152
final findBottom = find.text('bottom', skipOffstage: false);
153153

154-
testWidgets('short/short -> starts scrolled to bottom', (tester) async {
154+
testWidgets('short/short -> pinned at bottom', (tester) async {
155155
// Starts out with items at bottom of viewport.
156156
await prepare(tester, topHeight: 100, bottomHeight: 100);
157157
check(tester.getRect(findBottom)).bottom.equals(600);
@@ -160,9 +160,14 @@ void main() {
160160
await tester.drag(findTop, Offset(0, -100));
161161
await tester.pump();
162162
check(tester.getRect(findBottom)).bottom.equals(600);
163+
164+
// Try scrolling up (by dragging down); doesn't move.
165+
await tester.drag(findTop, Offset(0, 100));
166+
await tester.pump();
167+
check(tester.getRect(findBottom)).bottom.equals(600);
163168
});
164169

165-
testWidgets('short/long -> starts scrolled to bottom', (tester) async {
170+
testWidgets('short/long -> scrolls to ends and no farther', (tester) async {
166171
// Starts out scrolled to bottom.
167172
await prepare(tester, topHeight: 100, bottomHeight: 800);
168173
check(tester.getRect(findBottom)).bottom.equals(600);
@@ -171,6 +176,34 @@ void main() {
171176
await tester.drag(findBottom, Offset(0, -100));
172177
await tester.pump();
173178
check(tester.getRect(findBottom)).bottom.equals(600);
179+
180+
// Try scrolling up (by dragging down); moves only as far as top of list.
181+
await tester.drag(findBottom, Offset(0, 400));
182+
await tester.pump();
183+
check(tester.getRect(findBottom)).bottom.equals(900);
184+
check(tester.getRect(findTop)).top.equals(0);
185+
});
186+
187+
testWidgets('short/short -> starts at bottom, immediately without animation', (tester) async {
188+
await prepare(tester, topHeight: 100, bottomHeight: 100);
189+
190+
final ys = <double>[];
191+
for (int i = 0; i < 10; i++) {
192+
ys.add(tester.getRect(findBottom).bottom - 600);
193+
await tester.pump(Duration(milliseconds: 15));
194+
}
195+
check(ys).deepEquals(List.generate(10, (_) => 0.0));
196+
});
197+
198+
testWidgets('short/long -> starts at bottom, immediately without animation', (tester) async {
199+
await prepare(tester, topHeight: 100, bottomHeight: 800);
200+
201+
final ys = <double>[];
202+
for (int i = 0; i < 10; i++) {
203+
ys.add(tester.getRect(findBottom).bottom - 600);
204+
await tester.pump(Duration(milliseconds: 15));
205+
}
206+
check(ys).deepEquals(List.generate(10, (_) => 0.0));
174207
});
175208

176209
testWidgets('starts at bottom, even when bottom underestimated at first', (tester) async {

0 commit comments

Comments
 (0)