Skip to content

Commit ac21040

Browse files
committed
scroll: Drive "scroll to end" through uncertainty about endpoint
As long as the bottom sliver is size zero (or more generally, as long as maxScrollExtent does not change during the animation), this is nearly NFC: I believe the only changes in behavior would come from differences in rounding. By describing the animation in terms of velocity, rather than a duration and exact target position, this lets us smoothly handle the case where we may not know exactly what the position coordinate of the end will be. A previous commit handled the case where the end comes sooner than estimated, by promptly stopping when that happens. This commit ensures the scroll continues past the original estimate, in the case where the end comes later. That case is a possibility as soon as there's a bottom sliver with a message in it: scroll up so the message is offscreen and no longer built; then have the message edited so it becomes taller; then scroll back down. It's impossible for the viewport to know that the bottom sliver's content has gotten taller until we actually scroll back down and cause the message's widget to get built. And naturally that will become even more salient of an issue when we enable the message list to jump into the middle of a long history, so that the bottom sliver may have content that hasn't yet been scrolled to, has never been built as widgets, and may not even have yet been fetched from the server.
1 parent f09976a commit ac21040

File tree

2 files changed

+125
-44
lines changed

2 files changed

+125
-44
lines changed

lib/widgets/scrolling.dart

Lines changed: 93 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -321,19 +321,91 @@ class SimulationDrivenScrollActivity extends ScrollActivity {
321321
}
322322
}
323323

324+
/// A simulation of motion at a constant velocity.
325+
///
326+
/// Models a particle that follows Newton's law of inertia,
327+
/// with no forces acting on the particle, and no end to the motion.
328+
///
329+
/// See also [GravitySimulation], which adds a constant acceleration
330+
/// and a stopping point.
331+
class InertialSimulation extends Simulation { // TODO(upstream)
332+
InertialSimulation(double initialPosition, double velocity)
333+
: _x0 = initialPosition, _v = velocity;
334+
335+
final double _x0;
336+
final double _v;
337+
338+
@override
339+
double x(double time) => _x0 + _v * time;
340+
341+
@override
342+
double dx(double time) => _v;
343+
344+
@override
345+
bool isDone(double time) => false;
346+
347+
@override
348+
String toString() => '${objectRuntimeType(this, 'InertialSimulation')}('
349+
'x₀: ${_x0.toStringAsFixed(1)}, dx₀: ${_v.toStringAsFixed(1)})';
350+
}
351+
352+
/// A simulation of the user impatiently scrolling to the end of a list.
353+
///
354+
/// The position [x] is in logical pixels, and time is in seconds.
355+
///
356+
/// The motion is meant to resemble the user scrolling the list down
357+
/// (by dragging up and flinging), and if the list is long then
358+
/// fling-scrolling again and again to keep it moving quickly.
359+
///
360+
/// In that scenario taken literally, the motion would repeatedly slow down,
361+
/// then speed up again with a fresh drag and fling. But doing that in
362+
/// response to a simulated drag, as opposed to when the user is actually
363+
/// dragging with their own finger, would feel jerky and not a good UX.
364+
/// Instead this takes a smoothed-out approximation of such a trajectory.
365+
class ScrollToEndSimulation extends InertialSimulation {
366+
factory ScrollToEndSimulation(ScrollPosition position) {
367+
final startPosition = position.pixels;
368+
final estimatedEndPosition = position.maxScrollExtent;
369+
final velocityForMinDuration = (estimatedEndPosition - startPosition)
370+
/ (minDuration.inMilliseconds / 1000.0);
371+
assert(velocityForMinDuration > 0);
372+
final velocity = clampDouble(velocityForMinDuration, 0, topSpeed);
373+
return ScrollToEndSimulation._(startPosition, velocity);
374+
}
375+
376+
ScrollToEndSimulation._(super.initialPosition, super.velocity);
377+
378+
/// The top speed to move at, in logical pixels per second.
379+
///
380+
/// This will be the speed whenever the estimated distance to be traveled
381+
/// is long enough to take at least [minDuration] at this speed.
382+
///
383+
/// This is chosen to equal the top speed that can be produced
384+
/// by a fling gesture in a Flutter [ScrollView],
385+
/// which in turn was chosen to equal the top speed of
386+
/// an (initial) fling gesture in a native Android scroll view.
387+
static const double topSpeed = 8000;
388+
389+
/// The desired duration of the animation when traveling short distances.
390+
///
391+
/// The speed will be chosen so that traveling the estimated distance
392+
/// will take this long, whenever that distance is short enough
393+
/// that that means a speed of at most [topSpeed].
394+
static const minDuration = Duration(milliseconds: 300);
395+
}
396+
324397
/// An activity that animates a scroll view smoothly to its end.
325398
///
326399
/// In particular this drives the "scroll to bottom" button
327400
/// in the Zulip message list.
328-
class ScrollToEndActivity extends DrivenScrollActivity {
329-
ScrollToEndActivity(
330-
super.delegate, {
331-
required super.from,
332-
required super.to,
333-
required super.duration,
334-
required super.curve,
335-
required super.vsync,
336-
});
401+
class ScrollToEndActivity extends SimulationDrivenScrollActivity {
402+
/// Create an activity that animates a scroll view smoothly to its end.
403+
///
404+
/// The [delegate] is required to also implement [ScrollPosition].
405+
ScrollToEndActivity(ScrollActivityDelegate delegate)
406+
: super.simulation(delegate,
407+
vsync: (delegate as ScrollPosition).context.vsync,
408+
ScrollToEndSimulation(delegate as ScrollPosition));
337409

338410
ScrollPosition get _position => delegate as ScrollPosition;
339411

@@ -488,20 +560,20 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext {
488560

489561
/// Scroll the position smoothly to the end of the scrollable content.
490562
///
491-
/// This method only works well if [maxScrollExtent] is accurate
492-
/// and does not change during the animation.
493-
/// (For example, this works if there is no content in forward slivers,
494-
/// so that [maxScrollExtent] is always zero.)
495-
/// The animation will attempt to travel to the value [maxScrollExtent] had
496-
/// at the start of the animation, even if that ends up being more or less far
497-
/// than the actual extent of the content.
563+
/// This is similar to calling [animateTo] with a target of [maxScrollExtent],
564+
/// except that if [maxScrollExtent] changes over the course of the animation
565+
/// (for example due to more content being added at the end,
566+
/// or due to the estimated length of the content changing as
567+
/// different items scroll into the viewport),
568+
/// this animation will carry on until it reaches the updated value
569+
/// of [maxScrollExtent], not the value it had at the start of the animation.
570+
///
571+
/// The animation is typically handled by a [ScrollToEndActivity].
498572
void scrollToEnd() {
499-
final target = maxScrollExtent;
500-
501573
final tolerance = physics.toleranceFor(this);
502-
if (nearEqual(pixels, target, tolerance.distance)) {
574+
if (nearEqual(pixels, maxScrollExtent, tolerance.distance)) {
503575
// Skip the animation; jump right to the target, which is already close.
504-
jumpTo(target);
576+
jumpTo(maxScrollExtent);
505577
return;
506578
}
507579

@@ -513,30 +585,7 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext {
513585
return;
514586
}
515587

516-
/// The top speed to move at, in logical pixels per second.
517-
///
518-
/// This will be the speed whenever the distance to be traveled
519-
/// is long enough to take at least [minDuration] at this speed.
520-
///
521-
/// This is chosen to equal the top speed that can be produced
522-
/// by a fling gesture in a Flutter [ScrollView],
523-
/// which in turn was chosen to equal the top speed of
524-
/// an (initial) fling gesture in a native Android scroll view.
525-
const double topSpeed = 8000;
526-
527-
/// The desired duration of the animation when traveling short distances.
528-
///
529-
/// The speed will be chosen so that traveling the distance
530-
/// will take this long, whenever that distance is short enough
531-
/// that that means a speed of at most [topSpeed].
532-
const minDuration = Duration(milliseconds: 300);
533-
534-
final durationSecAtSpeedLimit = (target - pixels) / topSpeed;
535-
final durationSec = math.max(durationSecAtSpeedLimit,
536-
minDuration.inMilliseconds / 1000.0);
537-
final duration = Duration(milliseconds: (durationSec * 1000.0).ceil());
538-
beginActivity(ScrollToEndActivity(this, vsync: context.vsync,
539-
from: pixels, to: target, duration: duration, curve: Curves.linear));
588+
beginActivity(ScrollToEndActivity(this));
540589
}
541590
}
542591

test/widgets/scrolling_test.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,38 @@ void main() {
455455

456456
debugDefaultTargetPlatformOverride = null;
457457
});
458+
459+
testWidgets('keep going even if content turns out longer', (tester) async {
460+
await prepare(tester, topHeight: 1000, bottomHeight: 3000);
461+
462+
// Scroll up…
463+
position.jumpTo(0);
464+
await tester.pump();
465+
check(position.extentAfter).equals(3000);
466+
467+
// … then invoke `scrollToEnd`…
468+
position.scrollToEnd();
469+
await tester.pump();
470+
471+
// … but have the bottom sliver turn out to be longer than it was.
472+
await prepare(tester, topHeight: 1000, bottomHeight: 6000,
473+
reuseController: true);
474+
check(position.extentAfter).equals(6000);
475+
476+
// Let the scrolling animation go until it stops.
477+
int steps = 0;
478+
double prevRemaining;
479+
double remaining = position.extentAfter;
480+
do {
481+
prevRemaining = remaining;
482+
check(++steps).isLessThan(100);
483+
await tester.pump(Duration(milliseconds: 10));
484+
remaining = position.extentAfter;
485+
} while (remaining < prevRemaining);
486+
487+
// The scroll position should be all the way at the end.
488+
check(remaining).equals(0);
489+
});
458490
});
459491
});
460492
}

0 commit comments

Comments
 (0)