blob: c01c31169186a2e2b91168ad3588e249fa0bdde7 [file] [log] [blame]
// 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/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/gestures.dart' show DragStartBehavior;
import 'package:flutter/rendering.dart';
class _CustomPhysics extends ClampingScrollPhysics {
const _CustomPhysics({ ScrollPhysics parent }) : super(parent: parent);
@override
_CustomPhysics applyTo(ScrollPhysics ancestor) {
return _CustomPhysics(parent: buildParent(ancestor));
}
@override
Simulation createBallisticSimulation(ScrollMetrics position, double dragVelocity) {
return ScrollSpringSimulation(spring, 1000.0, 1000.0, 1000.0);
}
}
Widget buildTest({
ScrollController controller,
String title = 'TTTTTTTT',
Key key,
bool expanded = true,
}) {
return Localizations(
locale: const Locale('en', 'US'),
delegates: const <LocalizationsDelegate<dynamic>>[
DefaultMaterialLocalizations.delegate,
DefaultWidgetsLocalizations.delegate,
],
child: Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
child: Scaffold(
drawerDragStartBehavior: DragStartBehavior.down,
body: DefaultTabController(
length: 4,
child: NestedScrollView(
key: key,
dragStartBehavior: DragStartBehavior.down,
controller: controller,
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
title: Text(title),
pinned: true,
expandedHeight: expanded ? 200.0 : 0.0,
forceElevated: innerBoxIsScrolled,
bottom: const TabBar(
tabs: <Tab>[
Tab(text: 'AA'),
Tab(text: 'BB'),
Tab(text: 'CC'),
Tab(text: 'DD'),
],
),
),
];
},
body: TabBarView(
children: <Widget>[
ListView(
children: <Widget>[
Container(
height: 300.0,
child: const Text('aaa1'),
),
Container(
height: 200.0,
child: const Text('aaa2'),
),
Container(
height: 100.0,
child: const Text('aaa3'),
),
Container(
height: 50.0,
child: const Text('aaa4'),
),
],
),
ListView(
dragStartBehavior: DragStartBehavior.down,
children: <Widget>[
Container(
height: 100.0,
child: const Text('bbb1'),
),
],
),
Container(
child: const Center(child: Text('ccc1')),
),
ListView(
dragStartBehavior: DragStartBehavior.down,
children: <Widget>[
Container(
height: 10000.0,
child: const Text('ddd1'),
),
],
),
],
),
),
),
),
),
),
);
}
void main() {
testWidgets('NestedScrollView overscroll and release and hold', (WidgetTester tester) async {
await tester.pumpWidget(buildTest());
expect(find.text('aaa2'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 250));
final Offset point1 = tester.getCenter(find.text('aaa1'));
await tester.dragFrom(point1, const Offset(0.0, 200.0));
await tester.pump();
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
200.0,
);
await tester.flingFrom(point1, const Offset(0.0, -80.0), 50000.0);
await tester.pump(const Duration(milliseconds: 20));
final Offset point2 = tester.getCenter(find.text('aaa1'));
expect(point2.dy, greaterThan(point1.dy));
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('NestedScrollView overscroll and release and hold', (WidgetTester tester) async {
await tester.pumpWidget(buildTest());
expect(find.text('aaa2'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 250));
final Offset point = tester.getCenter(find.text('aaa1'));
await tester.flingFrom(point, const Offset(0.0, 200.0), 5000.0);
await tester.pump(const Duration(milliseconds: 10));
await tester.pump(const Duration(milliseconds: 10));
await tester.pump(const Duration(milliseconds: 10));
expect(find.text('aaa2'), findsNothing);
final TestGesture gesture1 = await tester.startGesture(point);
await tester.pump(const Duration(milliseconds: 5000));
expect(find.text('aaa2'), findsNothing);
await gesture1.moveBy(const Offset(0.0, 50.0));
await tester.pump(const Duration(milliseconds: 10));
await tester.pump(const Duration(milliseconds: 10));
expect(find.text('aaa2'), findsNothing);
await tester.pump(const Duration(milliseconds: 1000));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('NestedScrollView overscroll and release', (WidgetTester tester) async {
await tester.pumpWidget(buildTest());
expect(find.text('aaa2'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500));
final TestGesture gesture1 = await tester.startGesture(
tester.getCenter(find.text('aaa1'))
);
await gesture1.moveBy(const Offset(0.0, 200.0));
await tester.pumpAndSettle();
expect(find.text('aaa2'), findsNothing);
await tester.pump(const Duration(seconds: 1));
await gesture1.up();
await tester.pumpAndSettle();
expect(find.text('aaa2'), findsOneWidget);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('NestedScrollView', (WidgetTester tester) async {
await tester.pumpWidget(buildTest());
expect(find.text('aaa2'), findsOneWidget);
expect(find.text('aaa3'), findsNothing);
expect(find.text('bbb1'), findsNothing);
await tester.pump(const Duration(milliseconds: 250));
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
200.0,
);
await tester.drag(find.text('AA'), const Offset(0.0, -20.0));
await tester.pump(const Duration(milliseconds: 250));
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
180.0,
);
await tester.drag(find.text('AA'), const Offset(0.0, -20.0));
await tester.pump(const Duration(milliseconds: 250));
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
160.0,
);
await tester.drag(find.text('AA'), const Offset(0.0, -20.0));
await tester.pump(const Duration(milliseconds: 250));
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
140.0,
);
expect(find.text('aaa4'), findsNothing);
await tester.pump(const Duration(milliseconds: 250));
await tester.fling(find.text('AA'), const Offset(0.0, -50.0), 10000.0);
await tester.pumpAndSettle(const Duration(milliseconds: 250));
expect(find.text('aaa4'), findsOneWidget);
final double minHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
expect(minHeight, lessThan(140.0));
await tester.pump(const Duration(milliseconds: 250));
await tester.tap(find.text('BB'));
await tester.pumpAndSettle(const Duration(milliseconds: 250));
expect(find.text('aaa4'), findsNothing);
expect(find.text('bbb1'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 250));
await tester.tap(find.text('CC'));
await tester.pumpAndSettle(const Duration(milliseconds: 250));
expect(find.text('bbb1'), findsNothing);
expect(find.text('ccc1'), findsOneWidget);
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
minHeight,
);
await tester.pump(const Duration(milliseconds: 250));
await tester.fling(find.text('AA'), const Offset(0.0, 50.0), 10000.0);
await tester.pumpAndSettle(const Duration(milliseconds: 250));
expect(find.text('ccc1'), findsOneWidget);
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
200.0,
);
});
testWidgets('NestedScrollView with a ScrollController', (WidgetTester tester) async {
final ScrollController controller = ScrollController(
initialScrollOffset: 50.0,
);
double scrollOffset;
controller.addListener(() {
scrollOffset = controller.offset;
});
await tester.pumpWidget(buildTest(controller: controller));
expect(controller.position.minScrollExtent, 0.0);
expect(controller.position.pixels, 50.0);
expect(controller.position.maxScrollExtent, 200.0);
// The appbar's expandedHeight - initialScrollOffset = 150.
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
150.0,
);
// Fully expand the appbar by scrolling (no animation) to 0.0.
controller.jumpTo(0.0);
await tester.pumpAndSettle();
expect(scrollOffset, 0.0);
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
200.0,
);
// Scroll back to 50.0 animating over 100ms.
controller.animateTo(
50.0,
duration: const Duration(milliseconds: 100),
curve: Curves.linear,
);
await tester.pump();
await tester.pump();
expect(scrollOffset, 0.0);
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
200.0,
);
await tester.pump(const Duration(milliseconds: 50)); // 50ms - halfway to scroll offset = 50.0.
expect(scrollOffset, 25.0);
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
175.0,
);
await tester.pump(const Duration(milliseconds: 50)); // 100ms - all the way to scroll offset = 50.0.
expect(scrollOffset, 50.0);
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
150.0,
);
// Scroll to the end, (we're not scrolling to the end of the list that contains aaa1,
// just to the end of the outer scrollview). Verify that the first item in each tab
// is still visible.
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pumpAndSettle();
expect(scrollOffset, 200.0);
expect(find.text('aaa1'), findsOneWidget);
await tester.tap(find.text('BB'));
await tester.pumpAndSettle();
expect(find.text('bbb1'), findsOneWidget);
await tester.tap(find.text('CC'));
await tester.pumpAndSettle();
expect(find.text('ccc1'), findsOneWidget);
await tester.tap(find.text('DD'));
await tester.pumpAndSettle();
expect(find.text('ddd1'), findsOneWidget);
});
testWidgets('Three NestedScrollViews with one ScrollController', (WidgetTester tester) async {
final TrackingScrollController controller = TrackingScrollController();
expect(controller.mostRecentlyUpdatedPosition, isNull);
expect(controller.initialScrollOffset, 0.0);
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: PageView(
children: <Widget>[
buildTest(controller: controller, title: 'Page0'),
buildTest(controller: controller, title: 'Page1'),
buildTest(controller: controller, title: 'Page2'),
],
),
));
// Initially Page0 is visible and Page0's appbar is fully expanded (height = 200.0).
expect(find.text('Page0'), findsOneWidget);
expect(find.text('Page1'), findsNothing);
expect(find.text('Page2'), findsNothing);
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
200.0,
);
// A scroll collapses Page0's appbar to 150.0.
controller.jumpTo(50.0);
await tester.pumpAndSettle();
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
150.0,
);
// Fling to Page1. Page1's appbar height is the same as the appbar for Page0.
await tester.fling(find.text('Page0'), const Offset(-100.0, 0.0), 10000.0);
await tester.pumpAndSettle();
expect(find.text('Page0'), findsNothing);
expect(find.text('Page1'), findsOneWidget);
expect(find.text('Page2'), findsNothing);
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
150.0,
);
// Expand Page1's appbar and then fling to Page2. Page2's appbar appears
// fully expanded.
controller.jumpTo(0.0);
await tester.pumpAndSettle();
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
200.0,
);
await tester.fling(find.text('Page1'), const Offset(-100.0, 0.0), 10000.0);
await tester.pumpAndSettle();
expect(find.text('Page0'), findsNothing);
expect(find.text('Page1'), findsNothing);
expect(find.text('Page2'), findsOneWidget);
expect(
tester.renderObject<RenderBox>(find.byType(AppBar)).size.height,
200.0,
);
});
testWidgets('NestedScrollViews with custom physics', (WidgetTester tester) async {
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: Localizations(
locale: const Locale('en', 'US'),
delegates: const <LocalizationsDelegate<dynamic>>[
DefaultMaterialLocalizations.delegate,
DefaultWidgetsLocalizations.delegate,
],
child: MediaQuery(
data: const MediaQueryData(),
child: NestedScrollView(
physics: const _CustomPhysics(),
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
const SliverAppBar(
floating: true,
title: Text('AA'),
),
];
},
body: Container(),
),
),
),
));
expect(find.text('AA'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500));
final Offset point1 = tester.getCenter(find.text('AA'));
await tester.dragFrom(point1, const Offset(0.0, 200.0));
await tester.pump(const Duration(milliseconds: 20));
final Offset point2 = tester.getCenter(find.text(
'AA',
skipOffstage: false,
));
expect(point1.dy, greaterThan(point2.dy));
});
testWidgets('NestedScrollView and internal scrolling', (WidgetTester tester) async {
debugDisableShadows = false;
const List<String> _tabs = <String>['Hello', 'World'];
int buildCount = 0;
await tester.pumpWidget(
MaterialApp(home: Material(child:
// THE FOLLOWING SECTION IS FROM THE NestedScrollView DOCUMENTATION
// (EXCEPT FOR THE CHANGES TO THE buildCount COUNTER)
DefaultTabController(
length: _tabs.length, // This is the number of tabs.
child: NestedScrollView(
dragStartBehavior: DragStartBehavior.down,
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
buildCount += 1; // THIS LINE IS NOT IN THE ORIGINAL -- ADDED FOR TEST
// These are the slivers that show up in the "outer" scroll view.
return <Widget>[
SliverOverlapAbsorber(
// This widget takes the overlapping behavior of the
// SliverAppBar, and redirects it to the SliverOverlapInjector
// below. If it is missing, then it is possible for the nested
// "inner" scroll view below to end up under the SliverAppBar
// even when the inner scroll view thinks it has not been
// scrolled. This is not necessary if the
// "headerSliverBuilder" only builds widgets that do not
// overlap the next sliver.
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
title: const Text('Books'), // This is the title in the app bar.
pinned: true,
expandedHeight: 150.0,
// The "forceElevated" property causes the SliverAppBar to
// show a shadow. The "innerBoxIsScrolled" parameter is true
// when the inner scroll view is scrolled beyond its "zero"
// point, i.e. when it appears to be scrolled below the
// SliverAppBar. Without this, there are cases where the
// shadow would appear or not appear inappropriately,
// because the SliverAppBar is not actually aware of the
// precise position of the inner scroll views.
forceElevated: innerBoxIsScrolled,
bottom: TabBar(
// These are the widgets to put in each tab in the tab
// bar.
tabs: _tabs.map<Widget>((String name) => Tab(text: name)).toList(),
dragStartBehavior: DragStartBehavior.down,
),
),
),
];
},
body: TabBarView(
dragStartBehavior: DragStartBehavior.down,
// These are the contents of the tab views, below the tabs.
children: _tabs.map<Widget>((String name) {
return SafeArea(
top: false,
bottom: false,
child: Builder(
// This Builder is needed to provide a BuildContext that is
// "inside" the NestedScrollView, so that
// sliverOverlapAbsorberHandleFor() can find the
// NestedScrollView.
builder: (BuildContext context) {
return CustomScrollView(
// The "controller" and "primary" members should be left
// unset, so that the NestedScrollView can control this
// inner scroll view.
// If the "controller" property is set, then this scroll
// view will not be associated with the
// NestedScrollView. The PageStorageKey should be unique
// to this ScrollView; it allows the list to remember
// its scroll position when the tab view is not on the
// screen.
key: PageStorageKey<String>(name),
dragStartBehavior: DragStartBehavior.down,
slivers: <Widget>[
SliverOverlapInjector(
// This is the flip side of the
// SliverOverlapAbsorber above.
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
),
SliverPadding(
padding: const EdgeInsets.all(8.0),
// In this example, the inner scroll view has
// fixed-height list items, hence the use of
// SliverFixedExtentList. However, one could use any
// sliver widget here, e.g. SliverList or
// SliverGrid.
sliver: SliverFixedExtentList(
// The items in this example are fixed to 48
// pixels high. This matches the Material Design
// spec for ListTile widgets.
itemExtent: 48.0,
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
// This builder is called for each child.
// In this example, we just number each list
// item.
return ListTile(
title: Text('Item $index'),
);
},
// The childCount of the
// SliverChildBuilderDelegate specifies how many
// children this inner list has. In this
// example, each tab has a list of exactly 30
// items, but this is arbitrary.
childCount: 30,
),
),
),
],
);
},
),
);
}).toList(),
),
),
),
// END
)),
);
PhysicalModelLayer _dfsFindPhysicalLayer(ContainerLayer layer) {
expect(layer, isNotNull);
Layer child = layer.firstChild;
while (child != null) {
if (child is PhysicalModelLayer) {
return child;
}
if (child is ContainerLayer) {
final PhysicalModelLayer candidate = _dfsFindPhysicalLayer(child);
if (candidate != null) {
return candidate;
}
}
child = child.nextSibling;
}
return null;
}
final ContainerLayer nestedScrollViewLayer = find.byType(NestedScrollView).evaluate().first.renderObject.debugLayer;
void _checkPhysicalLayer({@required double elevation}) {
final PhysicalModelLayer layer = _dfsFindPhysicalLayer(nestedScrollViewLayer);
expect(layer, isNotNull);
expect(layer.elevation, equals(elevation));
}
int expectedBuildCount = 0;
expectedBuildCount += 1;
expect(buildCount, expectedBuildCount);
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Item 18'), findsNothing);
_checkPhysicalLayer(elevation: 0);
// scroll down
final TestGesture gesture0 = await tester.startGesture(
tester.getCenter(find.text('Item 2'))
);
await gesture0.moveBy(const Offset(0.0, -120.0)); // tiny bit more than the pinned app bar height (56px * 2)
await tester.pump();
expect(buildCount, expectedBuildCount);
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Item 18'), findsNothing);
await gesture0.up();
await tester.pump(const Duration(milliseconds: 1)); // start shadow animation
expectedBuildCount += 1;
expect(buildCount, expectedBuildCount);
await tester.pump(const Duration(milliseconds: 1)); // during shadow animation
expect(buildCount, expectedBuildCount);
_checkPhysicalLayer(elevation: 0.00018262863159179688);
await tester.pump(const Duration(seconds: 1)); // end shadow animation
expect(buildCount, expectedBuildCount);
_checkPhysicalLayer(elevation: 4);
// scroll down
final TestGesture gesture1 = await tester.startGesture(
tester.getCenter(find.text('Item 2'))
);
await gesture1.moveBy(const Offset(0.0, -800.0));
await tester.pump();
expect(buildCount, expectedBuildCount);
_checkPhysicalLayer(elevation: 4);
expect(find.text('Item 2'), findsNothing);
expect(find.text('Item 18'), findsOneWidget);
await gesture1.up();
await tester.pump(const Duration(seconds: 1));
expect(buildCount, expectedBuildCount);
_checkPhysicalLayer(elevation: 4);
// swipe left to bring in tap on the right
final TestGesture gesture2 = await tester.startGesture(
tester.getCenter(find.byType(NestedScrollView))
);
await gesture2.moveBy(const Offset(-400.0, 0.0));
await tester.pump();
expect(buildCount, expectedBuildCount);
expect(find.text('Item 18'), findsOneWidget);
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Item 0'), findsOneWidget);
expect(tester.getTopLeft(
find.ancestor(
of: find.text('Item 0'),
matching: find.byType(ListTile),
)).dy,
tester.getBottomLeft(find.byType(AppBar)).dy + 8.0,
);
_checkPhysicalLayer(elevation: 4);
await gesture2.up();
await tester.pump(); // start sideways scroll
await tester.pump(const Duration(seconds: 1)); // end sideways scroll, triggers shadow going away
expect(buildCount, expectedBuildCount);
await tester.pump(const Duration(seconds: 1)); // start shadow going away
expectedBuildCount += 1;
expect(buildCount, expectedBuildCount);
await tester.pump(const Duration(seconds: 1)); // end shadow going away
expect(buildCount, expectedBuildCount);
expect(find.text('Item 18'), findsNothing);
expect(find.text('Item 2'), findsOneWidget);
_checkPhysicalLayer(elevation: 0);
await tester.pump(const Duration(seconds: 1)); // just checking we don't rebuild...
expect(buildCount, expectedBuildCount);
// peek left to see it's still in the right place
final TestGesture gesture3 = await tester.startGesture(
tester.getCenter(find.byType(NestedScrollView))
);
await gesture3.moveBy(const Offset(400.0, 0.0));
await tester.pump(); // bring the left page into view
expect(buildCount, expectedBuildCount);
await tester.pump(); // shadow comes back starting here
expectedBuildCount += 1;
expect(buildCount, expectedBuildCount);
expect(find.text('Item 18'), findsOneWidget);
expect(find.text('Item 2'), findsOneWidget);
_checkPhysicalLayer(elevation: 0);
await tester.pump(const Duration(seconds: 1)); // shadow finishes coming back
expect(buildCount, expectedBuildCount);
_checkPhysicalLayer(elevation: 4);
await gesture3.moveBy(const Offset(-400.0, 0.0));
await gesture3.up();
await tester.pump(); // left tab view goes away
expect(buildCount, expectedBuildCount);
await tester.pump(); // shadow goes away starting here
expectedBuildCount += 1;
expect(buildCount, expectedBuildCount);
_checkPhysicalLayer(elevation: 4);
await tester.pump(const Duration(seconds: 1)); // shadow finishes going away
expect(buildCount, expectedBuildCount);
_checkPhysicalLayer(elevation: 0);
// scroll back up
final TestGesture gesture4 = await tester.startGesture(
tester.getCenter(find.byType(NestedScrollView))
);
await gesture4.moveBy(const Offset(0.0, 200.0)); // expands the appbar again
await tester.pump();
expect(buildCount, expectedBuildCount);
expect(find.text('Item 2'), findsOneWidget);
expect(find.text('Item 18'), findsNothing);
_checkPhysicalLayer(elevation: 0);
await gesture4.up();
await tester.pump(const Duration(seconds: 1));
expect(buildCount, expectedBuildCount);
_checkPhysicalLayer(elevation: 0);
// peek left to see it's now back at zero
final TestGesture gesture5 = await tester.startGesture(
tester.getCenter(find.byType(NestedScrollView))
);
await gesture5.moveBy(const Offset(400.0, 0.0));
await tester.pump(); // bring the left page into view
await tester.pump(); // shadow would come back starting here, but there's no shadow to show
expect(buildCount, expectedBuildCount);
expect(find.text('Item 18'), findsNothing);
expect(find.text('Item 2'), findsNWidgets(2));
_checkPhysicalLayer(elevation: 0);
await tester.pump(const Duration(seconds: 1)); // shadow would be finished coming back
_checkPhysicalLayer(elevation: 0);
await gesture5.up();
await tester.pump(); // right tab view goes away
await tester.pumpAndSettle();
expect(buildCount, expectedBuildCount);
_checkPhysicalLayer(elevation: 0);
debugDisableShadows = true;
});
testWidgets('NestedScrollView and 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.
const Key key1 = ValueKey<int>(1);
const Key key2 = ValueKey<int>(2);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: DefaultTabController(
length: 1,
child: NestedScrollView(
dragStartBehavior: DragStartBehavior.down,
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
const SliverPersistentHeader(
delegate: TestHeader(key: key1),
),
];
},
body: SingleChildScrollView(
dragStartBehavior: DragStartBehavior.down,
child: Container(
height: 1000.0,
child: const Placeholder(key: key2),
),
),
),
),
),
),
);
expect(
tester.getRect(find.byKey(key1)),
const Rect.fromLTWH(0.0, 0.0, 800.0, 100.0),
);
expect(
tester.getRect(find.byKey(key2)),
const 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)),
const Rect.fromLTWH(0.0, -10.0, 800.0, 100.0),
);
expect(
tester.getRect(find.byKey(key2)),
const 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)),
const Rect.fromLTWH(0.0, 0.0, 800.0, 100.0),
);
expect(
tester.getRect(find.byKey(key2)),
const 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)),
const 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)),
const 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(129.0));
await gesture.moveBy(const Offset(0.0, -10.0)); // scroll back a lot
await tester.pump();
expect(
tester.getRect(find.byKey(key1)),
const Rect.fromLTWH(0.0, 0.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)),
const Rect.fromLTWH(0.0, 0.0, 800.0, 100.0),
);
await gesture.up();
debugDefaultTargetPlatformOverride = null;
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
group('NestedScrollViewState exposes inner and outer controllers', () {
testWidgets('Scrolling by less than the outer extent does not scroll the inner body', (WidgetTester tester) async {
final GlobalKey<NestedScrollViewState> globalKey = GlobalKey();
await tester.pumpWidget(buildTest(
key: globalKey,
expanded: false,
));
double appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
expect(appBarHeight, 104.0);
final double scrollExtent = appBarHeight - 50.0;
expect(globalKey.currentState.outerController.offset, 0.0);
expect(globalKey.currentState.innerController.offset, 0.0);
// The scroll gesture should occur in the inner body, so the whole
// scroll view is scrolled.
final TestGesture gesture = await tester.startGesture(Offset(
0.0,
appBarHeight + 1.0,
));
await gesture.moveBy(Offset(0.0, -scrollExtent));
await tester.pump();
appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
// This is not an expanded AppBar.
expect(appBarHeight, 104.0);
// The outer scroll controller should show an offset of the applied
// scrollExtent.
expect(globalKey.currentState.outerController.offset, 54.0);
// the inner scroll controller should not have scrolled.
expect(globalKey.currentState.innerController.offset, 0.0);
});
testWidgets('Scrolling by exactly the outer extent does not scroll the inner body', (WidgetTester tester) async {
final GlobalKey<NestedScrollViewState> globalKey = GlobalKey();
await tester.pumpWidget(buildTest(
key: globalKey,
expanded: false,
));
double appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
expect(appBarHeight, 104.0);
final double scrollExtent = appBarHeight;
expect(globalKey.currentState.outerController.offset, 0.0);
expect(globalKey.currentState.innerController.offset, 0.0);
// The scroll gesture should occur in the inner body, so the whole
// scroll view is scrolled.
final TestGesture gesture = await tester.startGesture(Offset(
0.0,
appBarHeight + 1.0,
));
await gesture.moveBy(Offset(0.0, -scrollExtent));
await tester.pump();
appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
// This is not an expanded AppBar.
expect(appBarHeight, 104.0);
// The outer scroll controller should show an offset of the applied
// scrollExtent.
expect(globalKey.currentState.outerController.offset, 104.0);
// the inner scroll controller should not have scrolled.
expect(globalKey.currentState.innerController.offset, 0.0);
});
testWidgets('Scrolling by greater than the outer extent scrolls the inner body', (WidgetTester tester) async {
final GlobalKey<NestedScrollViewState> globalKey = GlobalKey();
await tester.pumpWidget(buildTest(
key: globalKey,
expanded: false,
));
double appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
expect(appBarHeight, 104.0);
final double scrollExtent = appBarHeight + 50.0;
expect(globalKey.currentState.outerController.offset, 0.0);
expect(globalKey.currentState.innerController.offset, 0.0);
// The scroll gesture should occur in the inner body, so the whole
// scroll view is scrolled.
final TestGesture gesture = await tester.startGesture(Offset(
0.0,
appBarHeight + 1.0,
));
await gesture.moveBy(Offset(0.0, -scrollExtent));
await tester.pump();
appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
// This is not an expanded AppBar.
expect(appBarHeight, 104.0);
// The outer scroll controller should show an offset of the applied
// scrollExtent.
expect(globalKey.currentState.outerController.offset, appBarHeight);
// the inner scroll controller should have scrolled equivalent to the
// difference between the applied scrollExtent and the outer extent.
expect(
globalKey.currentState.innerController.offset,
scrollExtent - appBarHeight,
);
});
testWidgets('scrolling by less than the expanded outer extent does not scroll the inner body', (WidgetTester tester) async {
final GlobalKey<NestedScrollViewState> globalKey = GlobalKey();
await tester.pumpWidget(buildTest(key: globalKey));
double appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
expect(appBarHeight, 200.0);
final double scrollExtent = appBarHeight - 50.0;
expect(globalKey.currentState.outerController.offset, 0.0);
expect(globalKey.currentState.innerController.offset, 0.0);
// The scroll gesture should occur in the inner body, so the whole
// scroll view is scrolled.
final TestGesture gesture = await tester.startGesture(Offset(
0.0,
appBarHeight + 1.0,
));
await gesture.moveBy(Offset(0.0, -scrollExtent));
await tester.pump();
appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
// This is an expanding AppBar.
expect(appBarHeight, 104.0);
// The outer scroll controller should show an offset of the applied
// scrollExtent.
expect(globalKey.currentState.outerController.offset, 150.0);
// the inner scroll controller should not have scrolled.
expect(globalKey.currentState.innerController.offset, 0.0);
});
testWidgets('scrolling by exactly the expanded outer extent does not scroll the inner body', (WidgetTester tester) async {
final GlobalKey<NestedScrollViewState> globalKey = GlobalKey();
await tester.pumpWidget(buildTest(key: globalKey));
double appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
expect(appBarHeight, 200.0);
final double scrollExtent = appBarHeight;
expect(globalKey.currentState.outerController.offset, 0.0);
expect(globalKey.currentState.innerController.offset, 0.0);
// The scroll gesture should occur in the inner body, so the whole
// scroll view is scrolled.
final TestGesture gesture = await tester.startGesture(Offset(
0.0,
appBarHeight + 1.0,
));
await gesture.moveBy(Offset(0.0, -scrollExtent));
await tester.pump();
appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
// This is an expanding AppBar.
expect(appBarHeight, 104.0);
// The outer scroll controller should show an offset of the applied
// scrollExtent.
expect(globalKey.currentState.outerController.offset, 200.0);
// the inner scroll controller should not have scrolled.
expect(globalKey.currentState.innerController.offset, 0.0);
});
testWidgets('scrolling by greater than the expanded outer extent scrolls the inner body', (WidgetTester tester) async {
final GlobalKey<NestedScrollViewState> globalKey = GlobalKey();
await tester.pumpWidget(buildTest(key: globalKey));
double appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
expect(appBarHeight, 200.0);
final double scrollExtent = appBarHeight + 50.0;
expect(globalKey.currentState.outerController.offset, 0.0);
expect(globalKey.currentState.innerController.offset, 0.0);
// The scroll gesture should occur in the inner body, so the whole
// scroll view is scrolled.
final TestGesture gesture = await tester.startGesture(Offset(
0.0,
appBarHeight + 1.0,
));
await gesture.moveBy(Offset(0.0, -scrollExtent));
await tester.pump();
appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
// This is an expanding AppBar.
expect(appBarHeight, 104.0);
// The outer scroll controller should show an offset of the applied
// scrollExtent.
expect(globalKey.currentState.outerController.offset, 200.0);
// the inner scroll controller should have scrolled equivalent to the
// difference between the applied scrollExtent and the outer extent.
expect(globalKey.currentState.innerController.offset, 50.0);
});
testWidgets('NestedScrollViewState.outerController should correspond to NestedScrollView.controller', (
WidgetTester tester) async {
final GlobalKey<NestedScrollViewState> globalKey = GlobalKey();
final ScrollController scrollController = ScrollController();
await tester.pumpWidget(buildTest(
controller: scrollController,
key: globalKey,
));
// Scroll to compare offsets between controllers.
final TestGesture gesture = await tester.startGesture(const Offset(
0.0,
100.0,
));
await gesture.moveBy(const Offset(0.0, -100.0));
await tester.pump();
expect(
scrollController.offset,
globalKey.currentState.outerController.offset,
);
expect(
tester.widget<NestedScrollView>(find.byType(NestedScrollView)).controller.offset,
globalKey.currentState.outerController.offset,
);
});
group('manipulating controllers when', () {
testWidgets('outer: not scrolled, inner: not scrolled', (WidgetTester tester) async {
final GlobalKey<NestedScrollViewState> globalKey1 = GlobalKey();
await tester.pumpWidget(buildTest(
key: globalKey1,
expanded: false,
));
expect(globalKey1.currentState.outerController.position.pixels, 0.0);
expect(globalKey1.currentState.innerController.position.pixels, 0.0);
final double appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
// Manipulating Inner
globalKey1.currentState.innerController.jumpTo(100.0);
expect(globalKey1.currentState.innerController.position.pixels, 100.0);
expect(
globalKey1.currentState.outerController.position.pixels,
appBarHeight,
);
globalKey1.currentState.innerController.jumpTo(0.0);
expect(globalKey1.currentState.innerController.position.pixels, 0.0);
expect(
globalKey1.currentState.outerController.position.pixels,
appBarHeight,
);
// Reset
final GlobalKey<NestedScrollViewState> globalKey2 = GlobalKey();
await tester.pumpWidget(buildTest(
key: globalKey2,
expanded: false,
));
expect(globalKey2.currentState.outerController.position.pixels, 0.0);
expect(globalKey2.currentState.innerController.position.pixels, 0.0);
// Manipulating Outer
globalKey2.currentState.outerController.jumpTo(100.0);
expect(globalKey2.currentState.innerController.position.pixels, 0.0);
expect(globalKey2.currentState.outerController.position.pixels, 100.0);
globalKey2.currentState.outerController.jumpTo(0.0);
expect(globalKey2.currentState.innerController.position.pixels, 0.0);
expect(globalKey2.currentState.outerController.position.pixels, 0.0);
});
testWidgets('outer: not scrolled, inner: scrolled', (WidgetTester tester) async {
final GlobalKey<NestedScrollViewState> globalKey1 = GlobalKey();
await tester.pumpWidget(buildTest(
key: globalKey1,
expanded: false,
));
expect(globalKey1.currentState.outerController.position.pixels, 0.0);
globalKey1.currentState.innerController.position.setPixels(10.0);
expect(globalKey1.currentState.innerController.position.pixels, 10.0);
final double appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
// Manipulating Inner
globalKey1.currentState.innerController.jumpTo(100.0);
expect(globalKey1.currentState.innerController.position.pixels, 100.0);
expect(
globalKey1.currentState.outerController.position.pixels,
appBarHeight,
);
globalKey1.currentState.innerController.jumpTo(0.0);
expect(globalKey1.currentState.innerController.position.pixels, 0.0);
expect(
globalKey1.currentState.outerController.position.pixels,
appBarHeight,
);
// Reset
final GlobalKey<NestedScrollViewState> globalKey2 = GlobalKey();
await tester.pumpWidget(buildTest(
key: globalKey2,
expanded: false,
));
expect(globalKey2.currentState.outerController.position.pixels, 0.0);
globalKey2.currentState.innerController.position.setPixels(10.0);
expect(globalKey2.currentState.innerController.position.pixels, 10.0);
// Manipulating Outer
globalKey2.currentState.outerController.jumpTo(100.0);
expect(globalKey2.currentState.innerController.position.pixels, 0.0);
expect(globalKey2.currentState.outerController.position.pixels, 100.0);
globalKey2.currentState.outerController.jumpTo(0.0);
expect(globalKey2.currentState.innerController.position.pixels, 0.0);
expect(globalKey2.currentState.outerController.position.pixels, 0.0);
});
testWidgets('outer: scrolled, inner: not scrolled', (WidgetTester tester) async {
final GlobalKey<NestedScrollViewState> globalKey1 = GlobalKey();
await tester.pumpWidget(buildTest(
key: globalKey1,
expanded: false,
));
expect(globalKey1.currentState.innerController.position.pixels, 0.0);
globalKey1.currentState.outerController.position.setPixels(10.0);
expect(globalKey1.currentState.outerController.position.pixels, 10.0);
final double appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
// Manipulating Inner
globalKey1.currentState.innerController.jumpTo(100.0);
expect(globalKey1.currentState.innerController.position.pixels, 100.0);
expect(
globalKey1.currentState.outerController.position.pixels,
appBarHeight,
);
globalKey1.currentState.innerController.jumpTo(0.0);
expect(globalKey1.currentState.innerController.position.pixels, 0.0);
expect(
globalKey1.currentState.outerController.position.pixels,
appBarHeight,
);
// Reset
final GlobalKey<NestedScrollViewState> globalKey2 = GlobalKey();
await tester.pumpWidget(buildTest(
key: globalKey2,
expanded: false,
));
expect(globalKey2.currentState.innerController.position.pixels, 0.0);
globalKey2.currentState.outerController.position.setPixels(10.0);
expect(globalKey2.currentState.outerController.position.pixels, 10.0);
// Manipulating Outer
globalKey2.currentState.outerController.jumpTo(100.0);
expect(globalKey2.currentState.innerController.position.pixels, 0.0);
expect(globalKey2.currentState.outerController.position.pixels, 100.0);
globalKey2.currentState.outerController.jumpTo(0.0);
expect(globalKey2.currentState.innerController.position.pixels, 0.0);
expect(globalKey2.currentState.outerController.position.pixels, 0.0);
});
testWidgets('outer: scrolled, inner: scrolled', (WidgetTester tester) async {
final GlobalKey<NestedScrollViewState> globalKey1 = GlobalKey();
await tester.pumpWidget(buildTest(
key: globalKey1,
expanded: false,
));
globalKey1.currentState.innerController.position.setPixels(10.0);
expect(globalKey1.currentState.innerController.position.pixels, 10.0);
globalKey1.currentState.outerController.position.setPixels(10.0);
expect(globalKey1.currentState.outerController.position.pixels, 10.0);
final double appBarHeight = tester.renderObject<RenderBox>(find.byType(AppBar)).size.height;
// Manipulating Inner
globalKey1.currentState.innerController.jumpTo(100.0);
expect(globalKey1.currentState.innerController.position.pixels, 100.0);
expect(
globalKey1.currentState.outerController.position.pixels,
appBarHeight,
);
globalKey1.currentState.innerController.jumpTo(0.0);
expect(globalKey1.currentState.innerController.position.pixels, 0.0);
expect(
globalKey1.currentState.outerController.position.pixels,
appBarHeight,
);
// Reset
final GlobalKey<NestedScrollViewState> globalKey2 = GlobalKey();
await tester.pumpWidget(buildTest(
key: globalKey2,
expanded: false,
));
globalKey2.currentState.innerController.position.setPixels(10.0);
expect(globalKey2.currentState.innerController.position.pixels, 10.0);
globalKey2.currentState.outerController.position.setPixels(10.0);
expect(globalKey2.currentState.outerController.position.pixels, 10.0);
// Manipulating Outer
globalKey2.currentState.outerController.jumpTo(100.0);
expect(globalKey2.currentState.innerController.position.pixels, 0.0);
expect(globalKey2.currentState.outerController.position.pixels, 100.0);
globalKey2.currentState.outerController.jumpTo(0.0);
expect(globalKey2.currentState.innerController.position.pixels, 0.0);
expect(globalKey2.currentState.outerController.position.pixels, 0.0);
});
});
});
// Regression test for https://github.com/flutter/flutter/issues/39963.
testWidgets('NestedScrollView with SliverOverlapAbsorber in or out of the first screen', (WidgetTester tester) async {
await tester.pumpWidget(const _TestLayoutExtentIsNegative(1));
await tester.pumpWidget(const _TestLayoutExtentIsNegative(10));
});
}
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 Placeholder(key: key);
}
@override
bool shouldRebuild(TestHeader oldDelegate) => false;
}
class _TestLayoutExtentIsNegative extends StatelessWidget {
const _TestLayoutExtentIsNegative(this.widgetCountBeforeSliverOverlapAbsorber);
final int widgetCountBeforeSliverOverlapAbsorber;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Test'),
),
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
...List<Widget>.generate(widgetCountBeforeSliverOverlapAbsorber, (_) {
return SliverToBoxAdapter(
child: Container(
color: Colors.red,
height: 200,
margin:const EdgeInsets.all(20),
),
);
},),
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
pinned: true,
forceElevated: innerBoxIsScrolled,
backgroundColor: Colors.blue[300],
title: Container(
height: 50,
child: const Center(
child: Text('Sticky Header'),
),
),
),
)
];
},
body: Container(
height: 2000,
margin: const EdgeInsets.only(top: 50),
child: ListView(
children: List<Widget>.generate(3, (_) {
return Container(
color: Colors.green[200],
height: 200,
margin: const EdgeInsets.all(20),
);
},),
),
),
),
),
);
}
}