diff --git a/packages/flutter/lib/src/rendering/sliver_fixed_extent_list.dart b/packages/flutter/lib/src/rendering/sliver_fixed_extent_list.dart index 14cebe9afc..7884578e5f 100644 --- a/packages/flutter/lib/src/rendering/sliver_fixed_extent_list.dart +++ b/packages/flutter/lib/src/rendering/sliver_fixed_extent_list.dart @@ -214,7 +214,7 @@ abstract class RenderSliverFixedExtentBoxAdaptor extends RenderSliverMultiBoxAda ) { possibleFirstIndex -= 1; } - max = possibleFirstIndex * itemExtent; + max = (possibleFirstIndex + 1) * itemExtent; } geometry = SliverGeometry( scrollExtent: max, diff --git a/packages/flutter/lib/src/widgets/scroll_activity.dart b/packages/flutter/lib/src/widgets/scroll_activity.dart index 0ce728c430..18f91474eb 100644 --- a/packages/flutter/lib/src/widgets/scroll_activity.dart +++ b/packages/flutter/lib/src/widgets/scroll_activity.dart @@ -111,12 +111,24 @@ abstract class ScrollActivity { /// Whether the scroll view should ignore pointer events while performing this /// activity. + /// + /// See also: + /// + /// * [isScrolling], which describes whether the activity is considered + /// to represent user interaction or not. bool get shouldIgnorePointer; /// Whether performing this activity constitutes scrolling. /// - /// Used, for example, to determine whether the user scroll direction is + /// Used, for example, to determine whether the user scroll + /// direction (see [ScrollPosition.userScrollDirection]) is /// [ScrollDirection.idle]. + /// + /// See also: + /// + /// * [shouldIgnorePointer], which controls whether pointer events + /// are allowed while the activity is live. + /// * [UserScrollNotification], which exposes this status. bool get isScrolling; /// If applicable, the velocity at which the scroll offset is currently @@ -515,9 +527,6 @@ class BallisticScrollActivity extends ScrollActivity { .whenComplete(_end); // won't trigger if we dispose _controller first } - @override - double get velocity => _controller.velocity; - AnimationController _controller; @override @@ -562,6 +571,9 @@ class BallisticScrollActivity extends ScrollActivity { @override bool get isScrolling => true; + @override + double get velocity => _controller.velocity; + @override void dispose() { _controller.dispose(); @@ -622,9 +634,6 @@ class DrivenScrollActivity extends ScrollActivity { /// animation to stop before it reaches the end. Future get done => _completer.future; - @override - double get velocity => _controller.velocity; - void _tick() { if (delegate.setPixels(_controller.value) != 0.0) delegate.goIdle(); @@ -645,6 +654,9 @@ class DrivenScrollActivity extends ScrollActivity { @override bool get isScrolling => true; + @override + double get velocity => _controller.velocity; + @override void dispose() { _completer.complete(); diff --git a/packages/flutter/lib/src/widgets/scroll_configuration.dart b/packages/flutter/lib/src/widgets/scroll_configuration.dart index 86b16cd280..2c5cfd31f1 100644 --- a/packages/flutter/lib/src/widgets/scroll_configuration.dart +++ b/packages/flutter/lib/src/widgets/scroll_configuration.dart @@ -50,20 +50,24 @@ class ScrollBehavior { return null; } + static const ScrollPhysics _bouncingPhysics = BouncingScrollPhysics(parent: RangeMaintainingScrollPhysics()); + static const ScrollPhysics _clampingPhysics = ClampingScrollPhysics(parent: RangeMaintainingScrollPhysics()); + /// The scroll physics to use for the platform given by [getPlatform]. /// - /// Defaults to [BouncingScrollPhysics] on iOS and [ClampingScrollPhysics] on + /// Defaults to [RangeMaintainingScrollPhysics] mixed with + /// [BouncingScrollPhysics] on iOS and [ClampingScrollPhysics] on /// Android. ScrollPhysics getScrollPhysics(BuildContext context) { switch (getPlatform(context)) { case TargetPlatform.iOS: case TargetPlatform.macOS: - return const BouncingScrollPhysics(); + return _bouncingPhysics; case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: - return const ClampingScrollPhysics(); + return _clampingPhysics; } return null; } diff --git a/packages/flutter/lib/src/widgets/scroll_physics.dart b/packages/flutter/lib/src/widgets/scroll_physics.dart index 834092ebd4..855600a157 100644 --- a/packages/flutter/lib/src/widgets/scroll_physics.dart +++ b/packages/flutter/lib/src/widgets/scroll_physics.dart @@ -36,6 +36,20 @@ export 'package:flutter/physics.dart' show Simulation, ScrollSpringSimulation, T /// /// Instead of creating your own subclasses, [parent] can be used to combine /// [ScrollPhysics] objects of different types to get the desired scroll physics. +/// For example: +/// +/// ```dart +/// const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()) +/// ``` +/// +/// You can also use `applyTo`, which is useful when you already have +/// an instance of `ScrollPhysics`: +/// +/// ```dart +/// ScrollPhysics physics = const BouncingScrollPhysics(); +/// // ... +/// physics.applyTo(const AlwaysScrollableScrollPhysics()) +/// ``` @immutable class ScrollPhysics { /// Creates an object with the default scroll physics. @@ -49,9 +63,9 @@ class ScrollPhysics { /// [ScrollPhysics] subclasses at runtime. For example: /// /// ```dart - /// BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()) - /// + /// const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()) /// ``` + /// /// will result in a [ScrollPhysics] that has the combined behavior /// of [BouncingScrollPhysics] and [AlwaysScrollableScrollPhysics]: /// behaviors that are not specified in [BouncingScrollPhysics] @@ -228,6 +242,61 @@ class ScrollPhysics { return parent.applyBoundaryConditions(position, value); } + /// Describes what the scroll position should be given new viewport dimensions. + /// + /// This is called by [ScrollPosition.correctForNewDimensions]. + /// + /// The arguments consist of the scroll metrics as they stood in the previous + /// frame and the scroll metrics as they now stand after the last layout, + /// including the position and minimum and maximum scroll extents; a flag + /// indicating if the current [ScrollActivity] considers that the user is + /// actively scrolling (see [ScrollActivity.isScrolling]); and the current + /// velocity of the scroll position, if it is being driven by the scroll + /// activity (this is 0.0 during a user gesture) (see + /// [ScrollActivity.velocity]). + /// + /// The scroll metrics will be identical except for the + /// [ScrollMetrics.minScrollExtent] and [ScrollMetrics.maxScrollExtent]. They + /// are referred to as the `oldPosition` and `newPosition` (even though they + /// both technically have the same "position", in the form of + /// [ScrollMetrics.pixels]) because they are generated from the + /// [ScrollPosition] before and after updating the scroll extents. + /// + /// If the returned value does not exactly match the scroll offset given by + /// the `newPosition` argument (see [ScrollMetrics.pixels]), then the + /// [ScrollPosition] will call [ScrollPosition.correctPixels] to update the + /// new scroll position to the returned value, and layout will be re-run. This + /// is expensive. The new value is subject to further manipulation by + /// [applyBoundaryConditions]. + /// + /// If the returned value _does_ match the `newPosition.pixels` scroll offset + /// exactly, then [ScrollPosition.applyNewDimensions] will be called next. In + /// that case, [applyBoundaryConditions] is not applied to the return value. + /// + /// The given [ScrollMetrics] are only valid during this method call. Do not + /// keep references to them to use later, as the values may update, may not + /// update, or may update to reflect an entirely unrelated scrollable. + /// + /// The default implementation returns the [ScrollMetrics.pixels] of the + /// `newPosition`, which indicates that the current scroll offset is + /// acceptable. + /// + /// See also: + /// + /// * [RangeMaintainingScrollPhysics], which is enabled by default, and + /// which prevents unexpected changes to the content dimensions from + /// causing the scroll position to get any further out of bounds. + double adjustPositionForNewDimensions({ + @required ScrollMetrics oldPosition, + @required ScrollMetrics newPosition, + @required bool isScrolling, + @required double velocity, + }) { + if (parent == null) + return newPosition.pixels; + return parent.adjustPositionForNewDimensions(oldPosition: oldPosition, newPosition: newPosition, isScrolling: isScrolling, velocity: velocity); + } + /// Returns a simulation for ballistic scrolling starting from the given /// position with the given velocity. /// @@ -328,6 +397,49 @@ class ScrollPhysics { } } +/// Scroll physics that attempt to keep the scroll position in range when the +/// contents change dimensions suddenly. +/// +/// If the scroll position is already out of range, this attempts to maintain +/// the amount of overscroll or underscroll already present. +/// +/// If the scroll activity is animating the scroll position, sudden changes to +/// the scroll dimensions are allowed to happen (so as to prevent animations +/// from jumping back and forth between in-range and out-of-range values). +/// +/// These physics should be combined with other scroll physics, e.g. +/// [BouncingScrollPhysics] or [ClampingScrollPhysics], to obtain a complete +/// description of typical scroll physics. See [applyTo]. +class RangeMaintainingScrollPhysics extends ScrollPhysics { + /// Creates scroll physics that maintain the scroll position in range. + const RangeMaintainingScrollPhysics({ ScrollPhysics parent }) : super(parent: parent); + + @override + RangeMaintainingScrollPhysics applyTo(ScrollPhysics ancestor) { + return RangeMaintainingScrollPhysics(parent: buildParent(ancestor)); + } + + @override + double adjustPositionForNewDimensions({ + @required ScrollMetrics oldPosition, + @required ScrollMetrics newPosition, + @required bool isScrolling, + @required double velocity, + }) { + if (velocity != 0.0 || ((oldPosition.minScrollExtent == newPosition.minScrollExtent) && (oldPosition.maxScrollExtent == newPosition.maxScrollExtent))) + return super.adjustPositionForNewDimensions(oldPosition: oldPosition, newPosition: newPosition, isScrolling: isScrolling, velocity: velocity); + if (oldPosition.pixels < oldPosition.minScrollExtent) { + final double oldDelta = oldPosition.minScrollExtent - oldPosition.pixels; + return newPosition.minScrollExtent - oldDelta; + } + if (oldPosition.pixels > oldPosition.maxScrollExtent) { + final double oldDelta = oldPosition.pixels - oldPosition.maxScrollExtent; + return newPosition.maxScrollExtent + oldDelta; + } + return newPosition.pixels.clamp(newPosition.minScrollExtent, newPosition.maxScrollExtent) as double; + } +} + /// Scroll physics for environments that allow the scroll offset to go beyond /// the bounds of the content, but then bounce the content back to the edge of /// those bounds. @@ -534,9 +646,13 @@ class ClampingScrollPhysics extends ScrollPhysics { /// Scroll physics that always lets the user scroll. /// +/// This overrides the default behavior which is to disable scrolling +/// when there is no content to scroll. It does not override the +/// handling of overscrolling. +/// /// On Android, overscrolls will be clamped by default and result in an -/// overscroll glow. On iOS, overscrolls will load a spring that will return -/// the scroll view to its normal range when released. +/// overscroll glow. On iOS, overscrolls will load a spring that will return the +/// scroll view to its normal range when released. /// /// See also: /// diff --git a/packages/flutter/lib/src/widgets/scroll_position.dart b/packages/flutter/lib/src/widgets/scroll_position.dart index 3fb21fc495..3777f1ae33 100644 --- a/packages/flutter/lib/src/widgets/scroll_position.dart +++ b/packages/flutter/lib/src/widgets/scroll_position.dart @@ -489,11 +489,44 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { assert(minScrollExtent != null); assert(maxScrollExtent != null); assert(minScrollExtent <= maxScrollExtent); + final ScrollMetrics oldPosition = haveDimensions ? copyWith() : null; _minScrollExtent = minScrollExtent; _maxScrollExtent = maxScrollExtent; + final ScrollMetrics newPosition = haveDimensions ? copyWith() : null; + _didChangeViewportDimensionOrReceiveCorrection = false; + if (haveDimensions && !correctForNewDimensions(oldPosition, newPosition)) + return false; _haveDimensions = true; applyNewDimensions(); - _didChangeViewportDimensionOrReceiveCorrection = false; + } + assert(!_didChangeViewportDimensionOrReceiveCorrection, 'Use correctForNewDimensions() (and return true) to change the scroll offset during applyContentDimensions().'); + return true; + } + + /// Verifies that the new content and viewport dimensions are acceptable. + /// + /// Called by [applyContentDimensions] to determine its return value. + /// + /// Should return true if the current scroll offset is correct given + /// the new content and viewport dimensions. + /// + /// Otherwise, should call [correctPixels] to correct the scroll + /// offset given the new dimensions, and then return false. + /// + /// This is only called when [haveDimensions] is true. + /// + /// The default implementation defers to [ScrollPhysics.adjustPositionForNewDimensions]. + @protected + bool correctForNewDimensions(ScrollMetrics oldPosition, ScrollMetrics newPosition) { + final double newPixels = physics.adjustPositionForNewDimensions( + oldPosition: oldPosition, + newPosition: newPosition, + isScrolling: activity.isScrolling, + velocity: activity.velocity, + ); + if (newPixels != pixels) { + correctPixels(newPixels); + return false; } return true; } diff --git a/packages/flutter/test/widgets/keep_alive_test.dart b/packages/flutter/test/widgets/keep_alive_test.dart index 8875e6db05..8406b992b8 100644 --- a/packages/flutter/test/widgets/keep_alive_test.dart +++ b/packages/flutter/test/widgets/keep_alive_test.dart @@ -289,8 +289,9 @@ void main() { ' │ crossAxisDirection: right\n' ' │ offset: ScrollPositionWithSingleContext#00000(offset: 0.0, range:\n' ' │ 0.0..39400.0, viewport: 600.0, ScrollableState,\n' - ' │ AlwaysScrollableScrollPhysics -> ClampingScrollPhysics,\n' - ' │ IdleScrollActivity#00000, ScrollDirection.idle)\n' + ' │ AlwaysScrollableScrollPhysics -> ClampingScrollPhysics ->\n' + ' │ RangeMaintainingScrollPhysics, IdleScrollActivity#00000,\n' + ' │ ScrollDirection.idle)\n' ' │ anchor: 0.0\n' ' │\n' ' └─center child: RenderSliverFixedExtentList#00000 relayoutBoundary=up1\n' @@ -436,8 +437,9 @@ void main() { ' │ crossAxisDirection: right\n' ' │ offset: ScrollPositionWithSingleContext#00000(offset: 2000.0,\n' ' │ range: 0.0..39400.0, viewport: 600.0, ScrollableState,\n' - ' │ AlwaysScrollableScrollPhysics -> ClampingScrollPhysics,\n' - ' │ IdleScrollActivity#00000, ScrollDirection.idle)\n' + ' │ AlwaysScrollableScrollPhysics -> ClampingScrollPhysics ->\n' + ' │ RangeMaintainingScrollPhysics, IdleScrollActivity#00000,\n' + ' │ ScrollDirection.idle)\n' ' │ anchor: 0.0\n' ' │\n' ' └─center child: RenderSliverFixedExtentList#00000 relayoutBoundary=up1\n' diff --git a/packages/flutter/test/widgets/range_maintaining_scroll_physics_test.dart b/packages/flutter/test/widgets/range_maintaining_scroll_physics_test.dart new file mode 100644 index 0000000000..4e103f4ab3 --- /dev/null +++ b/packages/flutter/test/widgets/range_maintaining_scroll_physics_test.dart @@ -0,0 +1,220 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class ExpandingBox extends StatefulWidget { + const ExpandingBox({this.collapsedSize, this.expandedSize}); + + final double collapsedSize; + final double expandedSize; + + @override + State createState() => _ExpandingBoxState(); +} + +class _ExpandingBoxState extends State with AutomaticKeepAliveClientMixin{ + double _height; + + @override + void initState() { + super.initState(); + _height = widget.collapsedSize; + } + + void toggleSize() { + setState(() { + _height = _height == widget.collapsedSize ? widget.expandedSize : widget.collapsedSize; + }); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Container( + height: _height, + color: Colors.green, + child: Align( + alignment: Alignment.bottomCenter, + child: FlatButton( + child: const Text('Collapse'), + onPressed: toggleSize, + ), + ), + ); + } + + @override + bool get wantKeepAlive => true; +} + +void main() { + testWidgets('shrink listview', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: ListView.builder( + itemBuilder: (BuildContext context, int index) => index == 0 + ? const ExpandingBox(collapsedSize: 400, expandedSize: 1200) + : Container(height: 300, color: Colors.red), + itemCount: 2, + ), + )); + + final ScrollPosition position = tester.state(find.byType(Scrollable)).position; + expect(position.activity, isInstanceOf()); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 100.0); + expect(position.pixels, 0.0); + await tester.tap(find.byType(FlatButton)); + await tester.pump(); + + final TestGesture drag1 = await tester.startGesture(const Offset(10.0, 500.0)); + await tester.pump(); + await drag1.moveTo(const Offset(10.0, 0.0)); + await tester.pump(); + await drag1.up(); + await tester.pump(); + expect(position.pixels, moreOrLessEquals(500.0)); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 900.0); + + final TestGesture drag2 = await tester.startGesture(const Offset(10.0, 500.0)); + await tester.pump(); + await drag2.moveTo(const Offset(10.0, 100.0)); + await tester.pump(); + await drag2.up(); + await tester.pump(); + expect(position.maxScrollExtent, 900.0); + expect(position.pixels, moreOrLessEquals(900.0)); + + await tester.pump(); + await tester.tap(find.byType(FlatButton)); + await tester.pump(); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 100.0); + expect(position.pixels, 100.0); + }); + + testWidgets('shrink listview while dragging', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: ListView.builder( + itemBuilder: (BuildContext context, int index) => index == 0 + ? const ExpandingBox(collapsedSize: 400, expandedSize: 1200) + : Container(height: 300, color: Colors.red), + itemCount: 2, + ), + )); + + final ScrollPosition position = tester.state(find.byType(Scrollable)).position; + expect(position.activity, isInstanceOf()); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 100.0); + expect(position.pixels, 0.0); + await tester.tap(find.byType(FlatButton)); + await tester.pump(); // start button animation + await tester.pump(const Duration(seconds: 1)); // finish button animation + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 1800.0); + expect(position.pixels, 0.0); + + final TestGesture drag1 = await tester.startGesture(const Offset(10.0, 500.0)); + expect(await tester.pumpAndSettle(), 1); // Nothing to animate + await drag1.moveTo(const Offset(10.0, 0.0)); + expect(await tester.pumpAndSettle(), 1); // Nothing to animate + await drag1.up(); + expect(await tester.pumpAndSettle(), 1); // Nothing to animate + expect(position.pixels, moreOrLessEquals(500.0)); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 900.0); + + final TestGesture drag2 = await tester.startGesture(const Offset(10.0, 500.0)); + expect(await tester.pumpAndSettle(), 1); // Nothing to animate + await drag2.moveTo(const Offset(10.0, 100.0)); + expect(await tester.pumpAndSettle(), 1); // Nothing to animate + expect(position.maxScrollExtent, 900.0); + expect(position.pixels, lessThanOrEqualTo(900.0)); + expect(position.activity, isInstanceOf()); + + final _ExpandingBoxState expandingBoxState = tester.state<_ExpandingBoxState>(find.byType(ExpandingBox)); + expandingBoxState.toggleSize(); + expect(await tester.pumpAndSettle(), 1); // Nothing to animate + expect(position.activity, isInstanceOf()); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 100.0); + expect(position.pixels, 100.0); + + await drag2.moveTo(const Offset(10.0, 150.0)); + await drag2.up(); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 100.0); + expect(position.pixels, 50.0); + expect(await tester.pumpAndSettle(), 1); // Nothing to animate + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 100.0); + expect(position.pixels, 50.0); + }); + + testWidgets('shrink listview while ballistic', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: GestureDetector( + onTap: () { assert(false); }, + child: ListView.builder( + physics: const RangeMaintainingScrollPhysics(parent: BouncingScrollPhysics()), + itemBuilder: (BuildContext context, int index) => index == 0 + ? const ExpandingBox(collapsedSize: 400, expandedSize: 1200) + : Container(height: 300, color: Colors.red), + itemCount: 2, + ), + ), + )); + + final _ExpandingBoxState expandingBoxState = tester.state<_ExpandingBoxState>(find.byType(ExpandingBox)); + expandingBoxState.toggleSize(); + + final ScrollPosition position = tester.state(find.byType(Scrollable)).position; + expect(position.activity, isInstanceOf()); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 100.0); + expect(position.pixels, 0.0); + await tester.pump(); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 1800.0); + expect(position.pixels, 0.0); + + final TestGesture drag1 = await tester.startGesture(const Offset(10.0, 10.0)); + await tester.pump(); + expect(position.activity, isInstanceOf()); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 1800.0); + expect(position.pixels, 0.0); + await drag1.moveTo(const Offset(10.0, 50.0)); // to get past the slop and trigger the drag + await drag1.moveTo(const Offset(10.0, 550.0)); + expect(position.pixels, -500.0); + await tester.pump(); + expect(position.activity, isInstanceOf()); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 1800.0); + expect(position.pixels, -500.0); + await drag1.up(); + await tester.pump(); + expect(position.activity, isInstanceOf()); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 1800.0); + expect(position.pixels, -500.0); + + expandingBoxState.toggleSize(); + await tester.pump(); // apply physics without moving clock forward + expect(position.activity, isInstanceOf()); + // TODO(ianh): Determine why the maxScrollOffset is 200.0 here instead of 100.0 or double.infinity. + // expect(position.minScrollExtent, 0.0); + // expect(position.maxScrollExtent, 100.0); + expect(position.pixels, -500.0); + + await tester.pumpAndSettle(); // ignoring the exact effects of the animation + expect(position.activity, isInstanceOf()); + expect(position.minScrollExtent, 0.0); + expect(position.maxScrollExtent, 100.0); + expect(position.pixels, 0.0); + }); +} diff --git a/packages/flutter/test/widgets/sliver_list_test.dart b/packages/flutter/test/widgets/sliver_list_test.dart index 65cb8e9bc4..2549194ffa 100644 --- a/packages/flutter/test/widgets/sliver_list_test.dart +++ b/packages/flutter/test/widgets/sliver_list_test.dart @@ -136,7 +136,7 @@ void main() { viewportHeight: viewportHeight, )); final int frames = await tester.pumpAndSettle(); - expect(frames, greaterThan(1)); // ensure animation to bring tile17 into view + expect(frames, 1); // No animation when content shrinks suddenly. expect(controller.offset, scrollPosition - itemHeight); expect(find.text('Tile 0'), findsNothing); diff --git a/packages/flutter/test/widgets/slivers_test.dart b/packages/flutter/test/widgets/slivers_test.dart index d440a36bac..7bb16a8b6a 100644 --- a/packages/flutter/test/widgets/slivers_test.dart +++ b/packages/flutter/test/widgets/slivers_test.dart @@ -384,11 +384,26 @@ void main() { ), ), ); + expect(find.text('Page 0'), findsNothing); + expect(find.text('Page 6'), findsNothing); + await tester.drag(find.text('Page 5'), const Offset(0, -1000)); - // Controller will be temporarily over-scrolled. + // Controller will be temporarily over-scrolled (before the frame triggered by the drag) because + // SliverFixedExtentList doesn't report its size until it has built its last child, so the + // maxScrollExtent is infinite, so when we move by 1000 pixels in one go, we go all the way. + // + // This never actually gets rendered, it's just the controller state before we lay out. expect(controller.offset, 1600.0); - await tester.pumpAndSettle(); - // It will be corrected after a auto scroll animation. + + // However, once we pump, the scroll offset gets clamped to the newly discovered maximum, which + // is the itemExtent (200) times the number of items (7) minus the height of the viewport (600). + // This adds up to 800.0. + await tester.pump(); + expect(find.text('Page 0'), findsNothing); + expect(find.text('Page 6'), findsOneWidget); + expect(controller.offset, 800.0); + + expect(await tester.pumpAndSettle(), 1); // there should be no animation here expect(controller.offset, 800.0); });