Skip to content

Commit 13fa730

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 bf3af70 commit 13fa730

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
@@ -163,7 +163,7 @@ void main() {
163163
final findTop = find.text('top', skipOffstage: false);
164164
final findBottom = find.text('bottom', skipOffstage: false);
165165

166-
testWidgets('short/short -> starts scrolled to bottom', (tester) async {
166+
testWidgets('short/short -> pinned at bottom', (tester) async {
167167
// Starts out with items at bottom of viewport.
168168
await prepare(tester, topHeight: 100, bottomHeight: 100);
169169
check(tester.getRect(findBottom)).bottom.equals(600);
@@ -172,9 +172,14 @@ void main() {
172172
await tester.drag(findTop, Offset(0, -100));
173173
await tester.pump();
174174
check(tester.getRect(findBottom)).bottom.equals(600);
175+
176+
// Try scrolling up (by dragging down); doesn't move.
177+
await tester.drag(findTop, Offset(0, 100));
178+
await tester.pump();
179+
check(tester.getRect(findBottom)).bottom.equals(600);
175180
});
176181

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

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

0 commit comments

Comments
 (0)