Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions packages/flutter/lib/src/widgets/scroll_physics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,12 @@ 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.
/// This attempts to maintain the amount of overscroll or underscroll already present,
/// if the scroll position is already out of range _and_ the extents
/// have decreased, meaning that some content was removed. The reason for this
/// condition is that when new content is added, keeping the same overscroll
/// would mean that instead of showing it to the user, all of it is
/// being skipped by jumping right to the max extent.
///
/// If the scroll activity is animating the scroll position, sudden changes to
/// the scroll dimensions are allowed to happen (so as to prevent animations
Expand Down Expand Up @@ -513,9 +517,9 @@ class RangeMaintainingScrollPhysics extends ScrollPhysics {
maintainOverscroll = false;
if (oldPosition.minScrollExtent.isFinite && oldPosition.maxScrollExtent.isFinite &&
newPosition.minScrollExtent.isFinite && newPosition.maxScrollExtent.isFinite) {
// In addition, if the position changed then we only enforce
// the new boundary if the previous boundary was not entirely
// finite. A common case where the position changes while one
// In addition, if the position changed then we don't enforce the new
// boundary if both the new and previous boundaries are entirely finite.
// A common case where the position changes while one
// of the extents is infinite is a lazily-loaded list. (If the
// boundaries were finite, and the position changed, then we
// assume it was intentional.)
Expand All @@ -529,13 +533,19 @@ class RangeMaintainingScrollPhysics extends ScrollPhysics {
enforceBoundary = false;
}
if (maintainOverscroll) {
// Force the new position to be no more out of range
// than it was before, if it was overscrolled.
if (oldPosition.pixels < oldPosition.minScrollExtent) {
// Force the new position to be no more out of range than it was before, if:
// * it was overscrolled, and
// * the extents have decreased, meaning that some content was removed. The
// reason for this condition is that when new content is added, keeping
// the same overscroll would mean that instead of showing it to the user,
// all of it is being skipped by jumping right to the max extent.
if (oldPosition.pixels < oldPosition.minScrollExtent &&
newPosition.minScrollExtent > oldPosition.minScrollExtent) {
final double oldDelta = oldPosition.minScrollExtent - oldPosition.pixels;
return newPosition.minScrollExtent - oldDelta;
}
if (oldPosition.pixels > oldPosition.maxScrollExtent) {
if (oldPosition.pixels > oldPosition.maxScrollExtent &&
newPosition.maxScrollExtent < oldPosition.maxScrollExtent) {
final double oldDelta = oldPosition.pixels - oldPosition.maxScrollExtent;
return newPosition.maxScrollExtent + oldDelta;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ void main() {
expect(bike2.center, bike1.shift(const Offset(100.0, 0.0)).center);
});

testWidgets('Changing the size of the viewport while you are overdragged', (WidgetTester tester) async {
testWidgets('changing the size of the viewport when overscrolled', (WidgetTester tester) async {
Widget build(double height) {
return Directionality(
textDirection: TextDirection.rtl,
Expand Down Expand Up @@ -263,6 +263,82 @@ void main() {
final Rect newPosition = tester.getRect(find.byType(Placeholder));
expect(oldPosition, newPosition);
});

testWidgets('inserting and removing an item when overscrolled', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/62890

const double itemExtent = 100.0;
final UniqueKey key = UniqueKey();
final Finder finder = find.byKey(key);
Widget build({required bool twoItems}) {
return Directionality(
textDirection: TextDirection.rtl,
child: ScrollConfiguration(
behavior: const RangeMaintainingTestScrollBehavior(),
child: Align(
child: SizedBox(
width: 100.0,
height: 100.0,
child: ListView(
children: <Widget>[
SizedBox(height: itemExtent, child: Placeholder(key: key)),
if (twoItems)
const SizedBox(height: itemExtent, child: Placeholder()),
],
),
),
),
),
);
}

await tester.pumpWidget(build(twoItems: false));
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;

// overscroll bottom
final TestGesture drag1 = await tester.startGesture(tester.getCenter(finder));
await tester.pump();
await drag1.moveBy(const Offset(0.0, -50.0));
await tester.pump();

final double oldOverscroll1 = position.pixels - position.maxScrollExtent;
final Rect oldPosition1 = tester.getRect(finder);
await tester.pumpWidget(build(twoItems: true));
// verify inserting new item didn't change the position of the first one
expect(oldPosition1, tester.getRect(finder));
// verify the overscroll changed by the size of the added item
final double newOverscroll1 = position.pixels - position.maxScrollExtent;
expect(oldOverscroll1, isPositive);
expect(newOverscroll1, isNegative);
expect(newOverscroll1, oldOverscroll1 - itemExtent);

await drag1.up();

// verify there's no ballistic animation, because we weren't overscrolled
expect(await tester.pumpAndSettle(), 1);

// overscroll bottom
final TestGesture drag2 = await tester.startGesture(tester.getCenter(finder));
await tester.pump();
await drag2.moveBy(const Offset(0.0, -100.0));
await tester.pump();

final double oldOverscroll2 = position.pixels - position.maxScrollExtent;
// should find nothing because item is not visible
expect(finder, findsNothing);
await tester.pumpWidget(build(twoItems: false));
// verify removing an item changed the position of the first one, because prior it was not visible
expect(oldPosition1, tester.getRect(finder));
// verify the overscroll was maintained
final double newOverscroll2 = position.pixels - position.maxScrollExtent;
expect(oldOverscroll2, isPositive);
expect(oldOverscroll2, newOverscroll2);

await drag2.up();

// verify there's a ballistic animation from overscroll
expect(await tester.pumpAndSettle(), 9);
});
}

class TabBarDemo extends StatelessWidget {
Expand Down
18 changes: 16 additions & 2 deletions packages/flutter/test/widgets/scroll_activity_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ List<Widget> children(int n) {
}

void main() {
testWidgets('Scrolling with list view changes', (WidgetTester tester) async {
testWidgets('Scrolling with list view changes, leaving the overscroll', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(home: ListView(controller: controller, children: children(30))));
final double thirty = controller.position.maxScrollExtent;
Expand All @@ -22,7 +22,21 @@ void main() {
controller.jumpTo(thirty + 100.0); // past the end
await tester.pump();
await tester.pumpWidget(MaterialApp(home: ListView(controller: controller, children: children(31))));
expect(controller.position.pixels, thirty + 200.0); // same distance past the end
expect(controller.position.pixels, thirty + 100.0); // has the same position, but no longer overscrolled
expect(await tester.pumpAndSettle(), 1); // doesn't have ballistic animation...
expect(controller.position.pixels, thirty + 100.0); // and ends up at the end
});

testWidgets('Scrolling with list view changes, remaining overscrolled', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
await tester.pumpWidget(MaterialApp(home: ListView(controller: controller, children: children(30))));
final double thirty = controller.position.maxScrollExtent;
controller.jumpTo(thirty);
await tester.pump();
controller.jumpTo(thirty + 200.0); // past the end
await tester.pump();
await tester.pumpWidget(MaterialApp(home: ListView(controller: controller, children: children(31))));
expect(controller.position.pixels, thirty + 200.0); // has the same position, still overscrolled
expect(await tester.pumpAndSettle(), 8); // now it goes ballistic...
expect(controller.position.pixels, thirty + 100.0); // and ends up at the end
});
Expand Down