Skip to content

Commit eec002d

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 60fae99 commit eec002d

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
@@ -281,9 +281,42 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext {
281281
/// 0.0 and 60.0, or -10.0 and 10.0, or -40.0 and 0.0, or other values,
282282
/// depending on the value of [Viewport.anchor].
283283
bool applyContentDimensionsRaw(double wholeMinScrollExtent, double wholeMaxScrollExtent) {
284-
// This makes the simplifying assumption that `anchor` is 1.0.
285-
final effectiveMin = math.min(0.0, wholeMinScrollExtent + viewportDimension);
284+
// The origin point of these scroll coordinates, scroll extent 0.0,
285+
// is that the boundary between slivers is the bottom edge of the viewport.
286+
// (That's expressed by setting `anchor` to 1.0, consulted in
287+
// `_attemptLayout` below.)
288+
289+
// The farthest the list can scroll down (moving the content up)
290+
// is to the point where the bottom end of the list
291+
// touches the bottom edge of the viewport.
286292
final effectiveMax = wholeMaxScrollExtent;
293+
294+
// The farthest the list can scroll up (moving the content down)
295+
// is either:
296+
// * the same as the farthest it can scroll down,
297+
// * or the point where the top end of the list
298+
// touches the top edge of the viewport,
299+
// whichever is farther up.
300+
final effectiveMin = math.min(effectiveMax,
301+
wholeMinScrollExtent + viewportDimension);
302+
303+
// The first point comes into effect when the list is short,
304+
// so the whole thing fits into the viewport. In that case,
305+
// the only scroll position allowed is with the bottom end of the list
306+
// at the bottom edge of the viewport.
307+
308+
// The upstream answer (with no `applyContentDimensionsRaw`) would
309+
// effectively say:
310+
// final effectiveMin = math.min(0.0,
311+
// wholeMinScrollExtent + viewportDimension);
312+
//
313+
// In other words, the farthest the list can scroll up might be farther up
314+
// than the answer here: it could always scroll up to 0.0, meaning that the
315+
// boundary between slivers is at the bottom edge of the viewport.
316+
// Whenever the top sliver is shorter than the viewport (and the bottom
317+
// sliver isn't empty), this would mean one can scroll up past
318+
// the top of the list, even though that scrolls other content offscreen.
319+
287320
return applyContentDimensions(effectiveMin, effectiveMax);
288321
}
289322

@@ -297,13 +330,21 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext {
297330
if (!_hasEverCompletedLayout) {
298331
// The list is being laid out for the first time (its first performLayout).
299332
// Start out scrolled to the end.
333+
// This also brings [pixels] within bounds, which
334+
// the initial value of 0.0 might not have been.
300335
final target = maxScrollExtent;
301336
if (!hasPixels || pixels != target) {
302337
correctPixels(target);
303338
changed = true;
304339
}
305340
}
306341

342+
// This step must come after the first-time correction above.
343+
// Otherwise, if the initial [pixels] value of 0.0 was out of bounds
344+
// (which happens if the top slivers are shorter than the viewport),
345+
// then the base implementation of [applyContentDimensions] would
346+
// bring it in bounds via a scrolling animation, which isn't right when
347+
// starting from the meaningless initial 0.0 value.
307348
if (!super.applyContentDimensions(minScrollExtent, maxScrollExtent)) {
308349
changed = true;
309350
}

test/widgets/scrolling_test.dart

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

163-
testWidgets('short/short -> starts scrolled to bottom', (tester) async {
163+
testWidgets('short/short -> pinned at bottom', (tester) async {
164164
// Starts out with items at bottom of viewport.
165165
await prepare(tester, topHeight: 100, bottomHeight: 100);
166166
check(tester.getRect(findBottom)).bottom.equals(600);
@@ -169,9 +169,14 @@ void main() {
169169
await tester.drag(findTop, Offset(0, -100));
170170
await tester.pump();
171171
check(tester.getRect(findBottom)).bottom.equals(600);
172+
173+
// Try scrolling up (by dragging down); doesn't move.
174+
await tester.drag(findTop, Offset(0, 100));
175+
await tester.pump();
176+
check(tester.getRect(findBottom)).bottom.equals(600);
172177
});
173178

174-
testWidgets('short/long -> starts scrolled to bottom', (tester) async {
179+
testWidgets('short/long -> scrolls to ends and no farther', (tester) async {
175180
// Starts out scrolled to bottom.
176181
await prepare(tester, topHeight: 100, bottomHeight: 800);
177182
check(tester.getRect(findBottom)).bottom.equals(600);
@@ -180,6 +185,34 @@ void main() {
180185
await tester.drag(findBottom, Offset(0, -100));
181186
await tester.pump();
182187
check(tester.getRect(findBottom)).bottom.equals(600);
188+
189+
// Try scrolling up (by dragging down); moves only as far as top of list.
190+
await tester.drag(findBottom, Offset(0, 400));
191+
await tester.pump();
192+
check(tester.getRect(findBottom)).bottom.equals(900);
193+
check(tester.getRect(findTop)).top.equals(0);
194+
});
195+
196+
testWidgets('short/short -> starts at bottom, immediately without animation', (tester) async {
197+
await prepare(tester, topHeight: 100, bottomHeight: 100);
198+
199+
final ys = <double>[];
200+
for (int i = 0; i < 10; i++) {
201+
ys.add(tester.getRect(findBottom).bottom - 600);
202+
await tester.pump(Duration(milliseconds: 15));
203+
}
204+
check(ys).deepEquals(List.generate(10, (_) => 0.0));
205+
});
206+
207+
testWidgets('short/long -> starts at bottom, immediately without animation', (tester) async {
208+
await prepare(tester, topHeight: 100, bottomHeight: 800);
209+
210+
final ys = <double>[];
211+
for (int i = 0; i < 10; i++) {
212+
ys.add(tester.getRect(findBottom).bottom - 600);
213+
await tester.pump(Duration(milliseconds: 15));
214+
}
215+
check(ys).deepEquals(List.generate(10, (_) => 0.0));
183216
});
184217

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

0 commit comments

Comments
 (0)