Skip to content

Commit 25b6111

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. In order to control the behavior with a Simulation rather than a fixed endpoint and duration with a Curve, this commit uses a feature I added recently for this purpose to DrivenScrollActivity upstream: flutter/flutter#166730
1 parent fbd5266 commit 25b6111

File tree

2 files changed

+124
-43
lines changed

2 files changed

+124
-43
lines changed

lib/widgets/scrolling.dart

Lines changed: 92 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,91 @@ class _SingleChildScrollViewWithScrollbarState
4343
}
4444
}
4545

46+
/// A simulation of motion at a constant velocity.
47+
///
48+
/// Models a particle that follows Newton's law of inertia,
49+
/// with no forces acting on the particle, and no end to the motion.
50+
///
51+
/// See also [GravitySimulation], which adds a constant acceleration
52+
/// and a stopping point.
53+
class InertialSimulation extends Simulation { // TODO(upstream)
54+
InertialSimulation(double initialPosition, double velocity)
55+
: _x0 = initialPosition, _v = velocity;
56+
57+
final double _x0;
58+
final double _v;
59+
60+
@override
61+
double x(double time) => _x0 + _v * time;
62+
63+
@override
64+
double dx(double time) => _v;
65+
66+
@override
67+
bool isDone(double time) => false;
68+
69+
@override
70+
String toString() => '${objectRuntimeType(this, 'InertialSimulation')}('
71+
'x₀: ${_x0.toStringAsFixed(1)}, dx₀: ${_v.toStringAsFixed(1)})';
72+
}
73+
74+
/// A simulation of the user impatiently scrolling to the end of a list.
75+
///
76+
/// The position [x] is in logical pixels, and time is in seconds.
77+
///
78+
/// The motion is meant to resemble the user scrolling the list down
79+
/// (by dragging up and flinging), and if the list is long then
80+
/// fling-scrolling again and again to keep it moving quickly.
81+
///
82+
/// In that scenario taken literally, the motion would repeatedly slow down,
83+
/// then speed up again with a fresh drag and fling. But doing that in
84+
/// response to a simulated drag, as opposed to when the user is actually
85+
/// dragging with their own finger, would feel jerky and not a good UX.
86+
/// Instead this takes a smoothed-out approximation of such a trajectory.
87+
class ScrollToEndSimulation extends InertialSimulation {
88+
factory ScrollToEndSimulation(ScrollPosition position) {
89+
final startPosition = position.pixels;
90+
final estimatedEndPosition = position.maxScrollExtent;
91+
final velocityForMinDuration = (estimatedEndPosition - startPosition)
92+
/ (minDuration.inMilliseconds / 1000.0);
93+
assert(velocityForMinDuration > 0);
94+
final velocity = clampDouble(velocityForMinDuration, 0, topSpeed);
95+
return ScrollToEndSimulation._(startPosition, velocity);
96+
}
97+
98+
ScrollToEndSimulation._(super.initialPosition, super.velocity);
99+
100+
/// The top speed to move at, in logical pixels per second.
101+
///
102+
/// This will be the speed whenever the estimated distance to be traveled
103+
/// is long enough to take at least [minDuration] at this speed.
104+
///
105+
/// This is chosen to equal the top speed that can be produced
106+
/// by a fling gesture in a Flutter [ScrollView],
107+
/// which in turn was chosen to equal the top speed of
108+
/// an (initial) fling gesture in a native Android scroll view.
109+
static const double topSpeed = 8000;
110+
111+
/// The desired duration of the animation when traveling short distances.
112+
///
113+
/// The speed will be chosen so that traveling the estimated distance
114+
/// will take this long, whenever that distance is short enough
115+
/// that that means a speed of at most [topSpeed].
116+
static const minDuration = Duration(milliseconds: 300);
117+
}
118+
46119
/// An activity that animates a scroll view smoothly to its end.
47120
///
48121
/// In particular this drives the "scroll to bottom" button
49122
/// in the Zulip message list.
50123
class ScrollToEndActivity extends DrivenScrollActivity {
51-
ScrollToEndActivity(
52-
super.delegate, {
53-
required super.from,
54-
required super.to,
55-
required super.duration,
56-
required super.curve,
57-
required super.vsync,
58-
});
124+
/// Create an activity that animates a scroll view smoothly to its end.
125+
///
126+
/// The [delegate] is required to also implement [ScrollPosition].
127+
ScrollToEndActivity(ScrollActivityDelegate delegate)
128+
: super.simulation(delegate,
129+
vsync: (delegate as ScrollPosition).context.vsync,
130+
ScrollToEndSimulation(delegate as ScrollPosition));
59131

60132
ScrollPosition get _position => delegate as ScrollPosition;
61133

@@ -210,20 +282,20 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext {
210282

211283
/// Scroll the position smoothly to the end of the scrollable content.
212284
///
213-
/// This method only works well if [maxScrollExtent] is accurate
214-
/// and does not change during the animation.
215-
/// (For example, this works if there is no content in forward slivers,
216-
/// so that [maxScrollExtent] is always zero.)
217-
/// The animation will attempt to travel to the value [maxScrollExtent] had
218-
/// at the start of the animation, even if that ends up being more or less far
219-
/// than the actual extent of the content.
285+
/// This is similar to calling [animateTo] with a target of [maxScrollExtent],
286+
/// except that if [maxScrollExtent] changes over the course of the animation
287+
/// (for example due to more content being added at the end,
288+
/// or due to the estimated length of the content changing as
289+
/// different items scroll into the viewport),
290+
/// this animation will carry on until it reaches the updated value
291+
/// of [maxScrollExtent], not the value it had at the start of the animation.
292+
///
293+
/// The animation is typically handled by a [ScrollToEndActivity].
220294
void scrollToEnd() {
221-
final target = maxScrollExtent;
222-
223295
final tolerance = physics.toleranceFor(this);
224-
if (nearEqual(pixels, target, tolerance.distance)) {
296+
if (nearEqual(pixels, maxScrollExtent, tolerance.distance)) {
225297
// Skip the animation; jump right to the target, which is already close.
226-
jumpTo(target);
298+
jumpTo(maxScrollExtent);
227299
return;
228300
}
229301

@@ -235,30 +307,7 @@ class MessageListScrollPosition extends ScrollPositionWithSingleContext {
235307
return;
236308
}
237309

238-
/// The top speed to move at, in logical pixels per second.
239-
///
240-
/// This will be the speed whenever the distance to be traveled
241-
/// is long enough to take at least [minDuration] at this speed.
242-
///
243-
/// This is chosen to equal the top speed that can be produced
244-
/// by a fling gesture in a Flutter [ScrollView],
245-
/// which in turn was chosen to equal the top speed of
246-
/// an (initial) fling gesture in a native Android scroll view.
247-
const double topSpeed = 8000;
248-
249-
/// The desired duration of the animation when traveling short distances.
250-
///
251-
/// The speed will be chosen so that traveling the distance
252-
/// will take this long, whenever that distance is short enough
253-
/// that that means a speed of at most [topSpeed].
254-
const minDuration = Duration(milliseconds: 300);
255-
256-
final durationSecAtSpeedLimit = (target - pixels) / topSpeed;
257-
final durationSec = math.max(durationSecAtSpeedLimit,
258-
minDuration.inMilliseconds / 1000.0);
259-
final duration = Duration(milliseconds: (durationSec * 1000.0).ceil());
260-
beginActivity(ScrollToEndActivity(this, vsync: context.vsync,
261-
from: pixels, to: target, duration: duration, curve: Curves.linear));
310+
beginActivity(ScrollToEndActivity(this));
262311
}
263312
}
264313

test/widgets/scrolling_test.dart

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

335335
debugDefaultTargetPlatformOverride = null;
336336
});
337+
338+
testWidgets('keep going even if content turns out longer', (tester) async {
339+
await prepare(tester, topHeight: 1000, bottomHeight: 3000);
340+
341+
// Scroll up…
342+
position.jumpTo(0);
343+
await tester.pump();
344+
check(position.extentAfter).equals(3000);
345+
346+
// … then invoke `scrollToEnd`…
347+
position.scrollToEnd();
348+
await tester.pump();
349+
350+
// … but have the bottom sliver turn out to be longer than it was.
351+
await prepare(tester, topHeight: 1000, bottomHeight: 6000,
352+
reuseController: true);
353+
check(position.extentAfter).equals(6000);
354+
355+
// Let the scrolling animation go until it stops.
356+
int steps = 0;
357+
double prevRemaining;
358+
double remaining = position.extentAfter;
359+
do {
360+
prevRemaining = remaining;
361+
check(++steps).isLessThan(100);
362+
await tester.pump(Duration(milliseconds: 10));
363+
remaining = position.extentAfter;
364+
} while (remaining < prevRemaining);
365+
366+
// The scroll position should be all the way at the end.
367+
check(remaining).equals(0);
368+
});
337369
});
338370
});
339371
}

0 commit comments

Comments
 (0)