diff --git a/packages/flutter/lib/src/widgets/nested_scroll_view.dart b/packages/flutter/lib/src/widgets/nested_scroll_view.dart index 01be339d36..b8fe5385b3 100644 --- a/packages/flutter/lib/src/widgets/nested_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/nested_scroll_view.dart @@ -895,6 +895,10 @@ class _NestedScrollController extends ScrollController { } } +// The _NestedScrollPosition is used by both the inner and outer viewports of a +// NestedScrollView. It tracks the offset to use for those viewports, and knows +// about the _NestedScrollCoordinator, so that when activities are triggered on +// this class, they can defer, or be influenced by, the coordinator. class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDelegate { _NestedScrollPosition({ @required ScrollPhysics physics, @@ -945,10 +949,31 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele } // Returns the amount of delta that was not used. + // + // Positive delta means going down (exposing stuff above), negative delta + // going up (exposing stuff below). double applyClampedDragUpdate(double delta) { assert(delta != 0.0); - final double min = delta < 0.0 ? -double.INFINITY : minScrollExtent; - final double max = delta > 0.0 ? double.INFINITY : maxScrollExtent; + // If we are going towards the maxScrollExtent (negative scroll offset), + // then the furthest we can be in the minScrollExtent direction is negative + // infinity. For example, if we are already overscrolled, then scrolling to + // reduce the overscroll should not disallow the overscroll. + // + // If we are going towards the minScrollExtent (positive scroll offset), + // then the furthest we can be in the minScrollExtent direction is wherever + // we are now, if we are already overscrolled (in which case pixels is less + // than the minScrollExtent), or the minScrollExtent if we are not. + // + // In other words, we cannot, via applyClampedDragUpdate, _enter_ an + // overscroll situation. + // + // An overscroll situation might be nonetheless entered via several means. + // One is if the physics allow it, via applyFullDragUpdate (see below). An + // overscroll situation can also be forced, e.g. if the scroll position is + // artificially set using the scroll controller. + final double min = delta < 0.0 ? -double.INFINITY : math.min(minScrollExtent, pixels); + // The logic for max is equivalent but on the other side. + final double max = delta > 0.0 ? double.INFINITY : math.max(maxScrollExtent, pixels); final double oldPixels = pixels; final double newPixels = (pixels - delta).clamp(min, max); final double clampedDelta = newPixels - pixels; diff --git a/packages/flutter/test/widgets/nested_scroll_view_test.dart b/packages/flutter/test/widgets/nested_scroll_view_test.dart index 9fd97d12d8..23eb123778 100644 --- a/packages/flutter/test/widgets/nested_scroll_view_test.dart +++ b/packages/flutter/test/widgets/nested_scroll_view_test.dart @@ -566,4 +566,84 @@ void main() { expect(buildCount, expectedBuildCount); expect(find.byType(NestedScrollView), isNot(paints..shadow())); }); + + testWidgets('NestedScrollView and iOS bouncing', (WidgetTester tester) async { + // This verifies that overscroll bouncing works correctly on iOS. For + // example, this checks that if you pull to overscroll, friction is applied; + // it also makes sure that if you scroll back the other way, the scroll + // positions of the inner and outer list don't have a discontinuity. + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const Key key1 = const ValueKey(1); + const Key key2 = const ValueKey(2); + await tester.pumpWidget( + new MaterialApp( + home: new Material( + child: new DefaultTabController( + length: 1, + child: new NestedScrollView( + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + return [ + const SliverPersistentHeader( + delegate: const TestHeader(key: key1), + ), + ]; + }, + body: new SingleChildScrollView( + child: new Container( + height: 1000.0, + child: const Placeholder(key: key2), + ), + ), + ), + ), + ), + ), + ); + expect(tester.getRect(find.byKey(key1)), new Rect.fromLTWH(0.0, 0.0, 800.0, 100.0)); + expect(tester.getRect(find.byKey(key2)), new Rect.fromLTWH(0.0, 100.0, 800.0, 1000.0)); + final TestGesture gesture = await tester.startGesture(const Offset(10.0, 10.0)); + await gesture.moveBy(const Offset(0.0, -10.0)); // scroll up + await tester.pump(); + expect(tester.getRect(find.byKey(key1)), new Rect.fromLTWH(0.0, -10.0, 800.0, 100.0)); + expect(tester.getRect(find.byKey(key2)), new Rect.fromLTWH(0.0, 90.0, 800.0, 1000.0)); + await gesture.moveBy(const Offset(0.0, 10.0)); // scroll back to origin + await tester.pump(); + expect(tester.getRect(find.byKey(key1)), new Rect.fromLTWH(0.0, 0.0, 800.0, 100.0)); + expect(tester.getRect(find.byKey(key2)), new Rect.fromLTWH(0.0, 100.0, 800.0, 1000.0)); + await gesture.moveBy(const Offset(0.0, 10.0)); // overscroll + await gesture.moveBy(const Offset(0.0, 10.0)); // overscroll + await gesture.moveBy(const Offset(0.0, 10.0)); // overscroll + await tester.pump(); + expect(tester.getRect(find.byKey(key1)), new Rect.fromLTWH(0.0, 0.0, 800.0, 100.0)); + expect(tester.getRect(find.byKey(key2)).top, greaterThan(100.0)); + expect(tester.getRect(find.byKey(key2)).top, lessThan(130.0)); + await gesture.moveBy(const Offset(0.0, -1.0)); // scroll back a little + await tester.pump(); + expect(tester.getRect(find.byKey(key1)), new Rect.fromLTWH(0.0, -1.0, 800.0, 100.0)); + expect(tester.getRect(find.byKey(key2)).top, greaterThan(100.0)); + expect(tester.getRect(find.byKey(key2)).top, lessThan(129.0)); + await gesture.moveBy(const Offset(0.0, -10.0)); // scroll back a lot + await tester.pump(); + expect(tester.getRect(find.byKey(key1)), new Rect.fromLTWH(0.0, -11.0, 800.0, 100.0)); + await gesture.moveBy(const Offset(0.0, 20.0)); // overscroll again + await tester.pump(); + expect(tester.getRect(find.byKey(key1)), new Rect.fromLTWH(0.0, 0.0, 800.0, 100.0)); + await gesture.up(); + debugDefaultTargetPlatformOverride = null; + }); } + +class TestHeader extends SliverPersistentHeaderDelegate { + const TestHeader({ this.key }); + final Key key; + @override + double get minExtent => 100.0; + @override + double get maxExtent => 100.0; + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + return new Placeholder(key: key); + } + @override + bool shouldRebuild(TestHeader oldDelegate) => false; +} \ No newline at end of file