ListView shouldn't be scrollable when there isn't any scroll extent (#8318)

Previously, a ListView would always accept user input, even if it wasn't
actually scrollable. Now, by default, we don't accept user input if there's no
scroll range. You can override this behavior using the ScrollPhysics.

Fixes #8276
Fixes #8278
Fixes #8271
This commit is contained in:
Adam Barth 2017-02-21 16:54:23 -08:00 committed by GitHub
parent 7db8241a6d
commit 0e8b6aab46
6 changed files with 81 additions and 4 deletions

View File

@ -145,6 +145,28 @@ class ClampingScrollPhysics extends ScrollPhysics {
} }
} }
/// Scroll physics that always lets the user scroll.
///
/// 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.
///
/// See also:
///
/// * [BouncingScrollPhysics], which provides the bouncing overscroll behavior
/// found on iOS.
/// * [ClampingScrollPhysics], which provides the clamping overscroll behavior
/// found on Android.
class AlwaysScrollableScrollPhysics extends ScrollPhysics {
const AlwaysScrollableScrollPhysics({ ScrollPhysics parent }) : super(parent);
@override
AlwaysScrollableScrollPhysics applyTo(ScrollPhysics parent) => new AlwaysScrollableScrollPhysics(parent: parent);
@override
bool shouldAcceptUserOffset(ScrollPosition position) => true;
}
class PageScrollPhysics extends ScrollPhysics { class PageScrollPhysics extends ScrollPhysics {
const PageScrollPhysics({ ScrollPhysics parent }) : super(parent); const PageScrollPhysics({ ScrollPhysics parent }) : super(parent);

View File

@ -51,6 +51,17 @@ abstract class ScrollPhysics {
return parent.applyPhysicsToUserOffset(position, offset); return parent.applyPhysicsToUserOffset(position, offset);
} }
/// Whether the scrollable should let the user adjust the scroll offset, for
/// example by dragging.
///
/// By default, the user can manipulate the scroll offset if, and only if,
/// there is actually content outside the viewport to reveal.
bool shouldAcceptUserOffset(ScrollPosition position) {
if (parent == null)
return position.minScrollExtent != position.maxScrollExtent;
return parent.shouldAcceptUserOffset(position);
}
/// Determines the overscroll by applying the boundary conditions. /// Determines the overscroll by applying the boundary conditions.
/// ///
/// Called by [ScrollPosition.setPixels] just before the [pixels] value is /// Called by [ScrollPosition.setPixels] just before the [pixels] value is
@ -329,7 +340,6 @@ class ScrollPosition extends ViewportOffset {
// soon afterwards in the same layout phase. So we put all the logic that // soon afterwards in the same layout phase. So we put all the logic that
// relies on both values being computed into applyContentDimensions. // relies on both values being computed into applyContentDimensions.
} }
state.setCanDrag(canDrag);
return true; return true;
} }
@ -343,7 +353,7 @@ class ScrollPosition extends ViewportOffset {
activity.applyNewDimensions(); activity.applyNewDimensions();
_didChangeViewportDimension = false; _didChangeViewportDimension = false;
} }
state.setCanDrag(canDrag); state.setCanDrag(physics.shouldAcceptUserOffset(this));
return true; return true;
} }
@ -392,8 +402,6 @@ class ScrollPosition extends ViewportOffset {
activity.resetActivity(); activity.resetActivity();
} }
bool get canDrag => true;
bool get shouldIgnorePointer => activity?.shouldIgnorePointer; bool get shouldIgnorePointer => activity?.shouldIgnorePointer;
void touched() { void touched() {

View File

@ -35,4 +35,36 @@ void main() {
expect(buildCount, equals(2)); expect(buildCount, equals(2));
}); });
testWidgets('Verify that a scrollable BottomSheet can be dismissed', (WidgetTester tester) async {
final GlobalKey<ScaffoldState> scaffoldKey = new GlobalKey<ScaffoldState>();
await tester.pumpWidget(new MaterialApp(
home: new Scaffold(
key: scaffoldKey,
body: new Center(child: new Text('body'))
)
));
scaffoldKey.currentState.showBottomSheet<Null>((BuildContext context) {
return new ListView(
shrinkWrap: true,
children: <Widget>[
new Container(height: 100.0, child: new Text('One')),
new Container(height: 100.0, child: new Text('Two')),
new Container(height: 100.0, child: new Text('Three')),
],
);
});
await tester.pumpUntilNoTransientCallbacks();
expect(find.text('Two'), findsOneWidget);
await tester.scroll(find.text('Two'), const Offset(0.0, 400.0));
await tester.pump();
await tester.pumpUntilNoTransientCallbacks();
expect(find.text('Two'), findsNothing);
});
} }

View File

@ -26,6 +26,7 @@ void main() {
new RefreshIndicator( new RefreshIndicator(
onRefresh: refresh, onRefresh: refresh,
child: new ListView( child: new ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map((String item) { children: <String>['A', 'B', 'C', 'D', 'E', 'F'].map((String item) {
return new SizedBox( return new SizedBox(
height: 200.0, height: 200.0,
@ -51,6 +52,7 @@ void main() {
onRefresh: refresh, onRefresh: refresh,
child: new ListView( child: new ListView(
reverse: true, reverse: true,
physics: const AlwaysScrollableScrollPhysics(),
children: <Widget>[ children: <Widget>[
new SizedBox( new SizedBox(
height: 200.0, height: 200.0,
@ -75,6 +77,7 @@ void main() {
new RefreshIndicator( new RefreshIndicator(
onRefresh: holdRefresh, onRefresh: holdRefresh,
child: new ListView( child: new ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: <Widget>[ children: <Widget>[
new SizedBox( new SizedBox(
height: 200.0, height: 200.0,
@ -99,6 +102,7 @@ void main() {
onRefresh: holdRefresh, onRefresh: holdRefresh,
child: new ListView( child: new ListView(
reverse: true, reverse: true,
physics: const AlwaysScrollableScrollPhysics(),
children: <Widget>[ children: <Widget>[
new SizedBox( new SizedBox(
height: 200.0, height: 200.0,
@ -122,6 +126,7 @@ void main() {
new RefreshIndicator( new RefreshIndicator(
onRefresh: refresh, onRefresh: refresh,
child: new ListView( child: new ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: <Widget>[ children: <Widget>[
new SizedBox( new SizedBox(
height: 200.0, height: 200.0,
@ -147,6 +152,7 @@ void main() {
new RefreshIndicator( new RefreshIndicator(
onRefresh: refresh, onRefresh: refresh,
child: new ListView( child: new ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: <Widget>[ children: <Widget>[
new SizedBox( new SizedBox(
height: 200.0, height: 200.0,
@ -171,6 +177,7 @@ void main() {
new RefreshIndicator( new RefreshIndicator(
onRefresh: holdRefresh, // this one never returns onRefresh: holdRefresh, // this one never returns
child: new ListView( child: new ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: <Widget>[ children: <Widget>[
new SizedBox( new SizedBox(
height: 200.0, height: 200.0,
@ -211,6 +218,7 @@ void main() {
new RefreshIndicator( new RefreshIndicator(
onRefresh: refresh, onRefresh: refresh,
child: new ListView( child: new ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: <Widget>[ children: <Widget>[
new SizedBox( new SizedBox(
height: 200.0, height: 200.0,
@ -252,6 +260,7 @@ void main() {
new RefreshIndicator( new RefreshIndicator(
onRefresh: refresh, onRefresh: refresh,
child: new ListView( child: new ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: <Widget>[ children: <Widget>[
new SizedBox( new SizedBox(
height: 200.0, height: 200.0,

View File

@ -126,6 +126,7 @@ void main() {
testWidgets('down', (WidgetTester tester) async { testWidgets('down', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
new CustomScrollView( new CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: <Widget>[ slivers: <Widget>[
new SliverToBoxAdapter(child: const SizedBox(height: 20.0)), new SliverToBoxAdapter(child: const SizedBox(height: 20.0)),
], ],
@ -143,6 +144,7 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
new CustomScrollView( new CustomScrollView(
reverse: true, reverse: true,
physics: const AlwaysScrollableScrollPhysics(),
slivers: <Widget>[ slivers: <Widget>[
new SliverToBoxAdapter(child: const SizedBox(height: 20.0)), new SliverToBoxAdapter(child: const SizedBox(height: 20.0)),
], ],
@ -160,6 +162,7 @@ void main() {
testWidgets('Overscroll in both directions', (WidgetTester tester) async { testWidgets('Overscroll in both directions', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
new CustomScrollView( new CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: <Widget>[ slivers: <Widget>[
new SliverToBoxAdapter(child: const SizedBox(height: 20.0)), new SliverToBoxAdapter(child: const SizedBox(height: 20.0)),
], ],
@ -180,6 +183,7 @@ void main() {
await tester.pumpWidget( await tester.pumpWidget(
new CustomScrollView( new CustomScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const AlwaysScrollableScrollPhysics(),
slivers: <Widget>[ slivers: <Widget>[
new SliverToBoxAdapter(child: const SizedBox(height: 20.0)), new SliverToBoxAdapter(child: const SizedBox(height: 20.0)),
], ],
@ -228,6 +232,7 @@ void main() {
behavior: new TestScrollBehavior1(), behavior: new TestScrollBehavior1(),
child: new CustomScrollView( child: new CustomScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const AlwaysScrollableScrollPhysics(),
reverse: true, reverse: true,
slivers: <Widget>[ slivers: <Widget>[
new SliverToBoxAdapter(child: const SizedBox(height: 20.0)), new SliverToBoxAdapter(child: const SizedBox(height: 20.0)),
@ -246,6 +251,7 @@ void main() {
behavior: new TestScrollBehavior2(), behavior: new TestScrollBehavior2(),
child: new CustomScrollView( child: new CustomScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const AlwaysScrollableScrollPhysics(),
slivers: <Widget>[ slivers: <Widget>[
new SliverToBoxAdapter(child: const SizedBox(height: 20.0)), new SliverToBoxAdapter(child: const SizedBox(height: 20.0)),
], ],