Unverified Commit 9a263466 authored by Kate Lovett's avatar Kate Lovett Committed by GitHub

Fix update drag error that made NestedScrollView un-scrollable (#127718)

#### (plus some more docs)

Fixes https://github.com/flutter/flutter/issues/76760
Fixes https://github.com/flutter/flutter/issues/82391
Fixes https://github.com/flutter/flutter/issues/45619
Fixes #117316
Fixes #110956
Fixes #127282 
Fixes #32563
Fixes #46089 
Fixes #79077
Part of fixing #62833

This fixes (a bunch of) issues that have been reported differently over the years, but all have the same root cause. Sometimes the NestedScrollView would incorrectly calculate whether or not there is enough content to allow scrolling. This would only apply to drag scrolling (not mouse wheel scrolling for example). This did not relate to how the extent of the NestedScrollView is computed, but just the logic that enabled the actual drag gestures. This fixes that. :)
parent 270b3d48
......@@ -197,6 +197,13 @@ class NestedScrollView extends StatefulWidget {
final ScrollController? controller;
/// {@macro flutter.widgets.scroll_view.scrollDirection}
///
/// This property only applies to the [Axis] of the outer scroll view,
/// composed of the slivers returned from [headerSliverBuilder]. Since the
/// inner scroll view is not directly configured by the [NestedScrollView],
/// for the axes to match, configure the scroll view of the [body] the same
/// way if they are expected to scroll in the same orientation. This allows
/// for flexible configurations of the NestedScrollView.
final Axis scrollDirection;
/// Whether the scroll view scrolls in the reading direction.
......@@ -210,6 +217,13 @@ class NestedScrollView extends StatefulWidget {
/// scrolls from top to bottom when [reverse] is false and from bottom to top
/// when [reverse] is true.
///
/// This property only applies to the outer scroll view, composed of the
/// slivers returned from [headerSliverBuilder]. Since the inner scroll view
/// is not directly configured by the [NestedScrollView]. For both to scroll
/// in reverse, configure the scroll view of the [body] the same way if they
/// are expected to match. This allows for flexible configurations of the
/// NestedScrollView.
///
/// Defaults to false.
final bool reverse;
......@@ -232,6 +246,22 @@ class NestedScrollView extends StatefulWidget {
/// [ScrollMetrics.maxScrollExtent] properties passed to that method. If that
/// invariant is not maintained, the nested scroll view may respond to user
/// scrolling erratically.
///
/// This property only applies to the outer scroll view, composed of the
/// slivers returned from [headerSliverBuilder]. Since the inner scroll view
/// is not directly configured by the [NestedScrollView]. For both to scroll
/// with the same [ScrollPhysics], configure the scroll view of the [body]
/// the same way if they are expected to match, or use a [ScrollBehavior] as
/// an ancestor so both the inner and outer scroll views inherit the same
/// [ScrollPhysics]. This allows for flexible configurations of the
/// NestedScrollView.
///
/// The [ScrollPhysics] also determine whether or not the [NestedScrollView]
/// can accept input from the user to change the scroll offset. For example,
/// [NeverScrollableScrollPhysics] typically will not allow the user to drag a
/// scroll view, but in this case, if one of the two scroll views can be
/// dragged, then dragging will be allowed. Configuring both scroll views with
/// [NeverScrollableScrollPhysics] will disallow dragging in this case.
final ScrollPhysics? physics;
/// A builder for any widgets that are to precede the inner scroll views (as
......@@ -845,17 +875,18 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
if (!_outerPosition!.haveDimensions) {
return;
}
double maxInnerExtent = 0.0;
bool innerCanDrag = false;
for (final _NestedScrollPosition position in _innerPositions) {
if (!position.haveDimensions) {
return;
}
maxInnerExtent = math.max(
maxInnerExtent,
position.maxScrollExtent - position.minScrollExtent,
);
innerCanDrag = innerCanDrag
// This refers to the physics of the actual inner scroll position, not
// the whole NestedScrollView, since it is possible to have different
// ScrollPhysics for the inner and outer positions.
|| position.physics.shouldAcceptUserOffset(position);
}
_outerPosition!.updateCanDrag(maxInnerExtent);
_outerPosition!.updateCanDrag(innerCanDrag);
}
Future<void> animateTo(
......@@ -1438,9 +1469,16 @@ class _NestedScrollPosition extends ScrollPosition implements ScrollActivityDele
coordinator.updateCanDrag();
}
void updateCanDrag(double totalExtent) {
context.setCanDrag(physics.allowUserScrolling &&
(totalExtent > (viewportDimension - maxScrollExtent) || minScrollExtent != maxScrollExtent));
void updateCanDrag(bool innerCanDrag) {
// This is only called for the outer position
assert(coordinator._outerPosition == this);
context.setCanDrag(
// This refers to the physics of the actual outer scroll position, not
// the whole NestedScrollView, since it is possible to have different
// ScrollPhysics for the inner and outer positions.
physics.shouldAcceptUserOffset(this)
|| innerCanDrag,
);
}
@override
......@@ -1756,17 +1794,9 @@ class RenderSliverOverlapAbsorber extends RenderSliver with RenderObjectWithChil
}
child!.layout(constraints, parentUsesSize: true);
final SliverGeometry childLayoutGeometry = child!.geometry!;
geometry = SliverGeometry(
geometry = childLayoutGeometry.copyWith(
scrollExtent: childLayoutGeometry.scrollExtent - childLayoutGeometry.maxScrollObstructionExtent,
paintExtent: childLayoutGeometry.paintExtent,
paintOrigin: childLayoutGeometry.paintOrigin,
layoutExtent: math.max(0, childLayoutGeometry.paintExtent - childLayoutGeometry.maxScrollObstructionExtent),
maxPaintExtent: childLayoutGeometry.maxPaintExtent,
maxScrollObstructionExtent: childLayoutGeometry.maxScrollObstructionExtent,
hitTestExtent: childLayoutGeometry.hitTestExtent,
visible: childLayoutGeometry.visible,
hasVisualOverflow: childLayoutGeometry.hasVisualOverflow,
scrollOffsetCorrection: childLayoutGeometry.scrollOffsetCorrection,
);
handle._setExtents(
childLayoutGeometry.maxScrollObstructionExtent,
......
......@@ -871,7 +871,11 @@ void main() {
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
const SliverPersistentHeader(
delegate: TestHeader(key: key1),
delegate: TestHeader(
key: key1,
minExtent: 100.0,
maxExtent: 100.0,
),
),
];
},
......@@ -2948,17 +2952,284 @@ void main() {
contains('SliverOverlapInjector has found no absorbed extent to inject.'),
);
});
group('NestedScrollView properly sets drag', () {
Future<bool> canDrag(WidgetTester tester) async {
await tester.drag(
find.byType(CustomScrollView),
const Offset(0.0, -20.0),
);
await tester.pumpAndSettle();
final NestedScrollViewState nestedScrollView = tester.state<NestedScrollViewState>(
find.byType(NestedScrollView)
);
return nestedScrollView.outerController.position.pixels > 0.0
|| nestedScrollView.innerController.position.pixels > 0.0;
}
Widget buildTest({
// The body length is to test when the nested scroll view should or
// should not be allowing drag.
required _BodyLength bodyLength,
Widget? header,
bool applyOverlap = false,
}) {
return MaterialApp(
home: Scaffold(
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, _) {
if (applyOverlap) {
return <Widget>[
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: header,
),
];
}
return header != null ? <Widget>[ header ] : <Widget>[];
},
body: Builder(
builder: (BuildContext context) {
return CustomScrollView(
slivers: <Widget>[
SliverList.builder(
itemCount: switch (bodyLength) {
_BodyLength.short => 10,
_BodyLength.long => 100,
},
itemBuilder: (_, int index) => Text('Item $index'),
),
],
);
}
),
),
)
);
}
testWidgets('when headerSliverBuilder is empty', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/117316
// Regression test for https://github.com/flutter/flutter/issues/46089
// Short body / long body
for (final _BodyLength bodyLength in _BodyLength.values) {
await tester.pumpWidget(
buildTest(bodyLength: bodyLength),
);
await tester.pumpAndSettle();
switch (bodyLength) {
case _BodyLength.short:
expect(await canDrag(tester), isFalse);
case _BodyLength.long:
expect(await canDrag(tester), isTrue);
}
}
}, variant: TargetPlatformVariant.all());
testWidgets('when headerSliverBuilder extent is 0', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/79077
// Short body / long body
for (final _BodyLength bodyLength in _BodyLength.values) {
// SliverPersistentHeader
await tester.pumpWidget(
buildTest(
bodyLength: bodyLength,
header: const SliverPersistentHeader(
delegate: TestHeader(minExtent: 0.0, maxExtent: 0.0),
),
),
);
await tester.pumpAndSettle();
switch (bodyLength) {
case _BodyLength.short:
expect(await canDrag(tester), isFalse);
case _BodyLength.long:
expect(await canDrag(tester), isTrue);
}
// SliverPersistentHeader pinned
await tester.pumpWidget(
buildTest(
bodyLength: bodyLength,
header: const SliverPersistentHeader(
pinned: true,
delegate: TestHeader(minExtent: 0.0, maxExtent: 0.0),
),
),
);
await tester.pumpAndSettle();
switch (bodyLength) {
case _BodyLength.short:
expect(await canDrag(tester), isFalse);
case _BodyLength.long:
expect(await canDrag(tester), isTrue);
}
// SliverPersistentHeader floating
await tester.pumpWidget(
buildTest(
bodyLength: bodyLength,
header: const SliverPersistentHeader(
floating: true,
delegate: TestHeader(minExtent: 0.0, maxExtent: 0.0),
),
),
);
await tester.pumpAndSettle();
switch (bodyLength) {
case _BodyLength.short:
expect(await canDrag(tester), isFalse);
case _BodyLength.long:
expect(await canDrag(tester), isTrue);
}
// SliverPersistentHeader pinned+floating
await tester.pumpWidget(
buildTest(
bodyLength: bodyLength,
header: const SliverPersistentHeader(
pinned: true,
floating: true,
delegate: TestHeader(minExtent: 0.0, maxExtent: 0.0),
),
),
);
await tester.pumpAndSettle();
switch (bodyLength) {
case _BodyLength.short:
expect(await canDrag(tester), isFalse);
case _BodyLength.long:
expect(await canDrag(tester), isTrue);
}
// SliverPersistentHeader w/ overlap
await tester.pumpWidget(
buildTest(
bodyLength: bodyLength,
applyOverlap: true,
header: const SliverPersistentHeader(
delegate: TestHeader(minExtent: 0.0, maxExtent: 0.0),
),
),
);
await tester.pumpAndSettle();
switch (bodyLength) {
case _BodyLength.short:
expect(await canDrag(tester), isFalse);
case _BodyLength.long:
expect(await canDrag(tester), isTrue);
}
// SliverPersistentHeader pinned w/ overlap
await tester.pumpWidget(
buildTest(
bodyLength: bodyLength,
applyOverlap: true,
header: const SliverPersistentHeader(
pinned: true,
delegate: TestHeader(minExtent: 0.0, maxExtent: 0.0),
),
),
);
await tester.pumpAndSettle();
switch (bodyLength) {
case _BodyLength.short:
expect(await canDrag(tester), isFalse);
case _BodyLength.long:
expect(await canDrag(tester), isTrue);
}
// SliverPersistentHeader floating w/ overlap
await tester.pumpWidget(
buildTest(
bodyLength: bodyLength,
applyOverlap: true,
header: const SliverPersistentHeader(
floating: true,
delegate: TestHeader(minExtent: 0.0, maxExtent: 0.0),
),
),
);
await tester.pumpAndSettle();
switch (bodyLength) {
case _BodyLength.short:
expect(await canDrag(tester), isFalse);
case _BodyLength.long:
expect(await canDrag(tester), isTrue);
}
// SliverPersistentHeader pinned+floating w/ overlap
await tester.pumpWidget(
buildTest(
bodyLength: bodyLength,
applyOverlap: true,
header: const SliverPersistentHeader(
floating: true,
pinned: true,
delegate: TestHeader(minExtent: 0.0, maxExtent: 0.0),
),
),
);
await tester.pumpAndSettle();
switch (bodyLength) {
case _BodyLength.short:
expect(await canDrag(tester), isFalse);
case _BodyLength.long:
expect(await canDrag(tester), isTrue);
}
}
}, variant: TargetPlatformVariant.all());
testWidgets('With a pinned SliverAppBar', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/110956
// Regression test for https://github.com/flutter/flutter/issues/127282
// Regression test for https://github.com/flutter/flutter/issues/32563
// Regression test for https://github.com/flutter/flutter/issues/79077
// Short / long body
for (final _BodyLength bodyLength in _BodyLength.values) {
await tester.pumpWidget(
buildTest(
bodyLength: bodyLength,
applyOverlap: true,
header: const SliverAppBar(
title: Text('Test'),
pinned: true,
bottom: PreferredSize(
preferredSize: Size.square(25),
child: SizedBox(),
),
),
),
);
await tester.pumpAndSettle();
switch (bodyLength) {
case _BodyLength.short:
expect(await canDrag(tester), isFalse);
case _BodyLength.long:
expect(await canDrag(tester), isTrue);
}
}
});
});
}
double appBarHeight(WidgetTester tester) => tester.getSize(find.byType(AppBar, skipOffstage: false)).height;
enum _BodyLength {
short,
long,
}
class TestHeader extends SliverPersistentHeaderDelegate {
const TestHeader({ this.key });
const TestHeader({
this.key,
required this.minExtent,
required this.maxExtent,
});
final Key? key;
@override
double get minExtent => 100.0;
final double minExtent;
@override
double get maxExtent => 100.0;
final double maxExtent;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return Placeholder(key: key);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment