Unverified Commit 38e41f5a authored by Kate Lovett's avatar Kate Lovett Committed by GitHub

SliverFillRemaining accounts for child size when hasScrollBody is false (#35810)

Fixes the hasScrollBody flag not accounting for child size. Adds the ability to specify over-scroll behavior.
parent 252491f8
...@@ -7,6 +7,7 @@ import 'dart:math' as math; ...@@ -7,6 +7,7 @@ import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'box.dart'; import 'box.dart';
import 'object.dart';
import 'sliver.dart'; import 'sliver.dart';
import 'sliver_fixed_extent_list.dart'; import 'sliver_fixed_extent_list.dart';
import 'sliver_multi_box_adaptor.dart'; import 'sliver_multi_box_adaptor.dart';
...@@ -114,34 +115,84 @@ class RenderSliverFillRemaining extends RenderSliverSingleBoxAdapter { ...@@ -114,34 +115,84 @@ class RenderSliverFillRemaining extends RenderSliverSingleBoxAdapter {
RenderSliverFillRemaining({ RenderSliverFillRemaining({
RenderBox child, RenderBox child,
this.hasScrollBody = true, this.hasScrollBody = true,
this.fillOverscroll = false,
}) : assert(hasScrollBody != null), }) : assert(hasScrollBody != null),
super(child: child); super(child: child);
/// Whether the child has a scrollable body, this value cannot be null. /// Indicates whether the child has a scrollable body, this value cannot be
/// null.
/// ///
/// Defaults to true such that the child will extend beyond the viewport and /// Defaults to true such that the child will extend beyond the viewport and
/// scroll, as seen in [NestedScrollView]. /// scroll, as seen in [NestedScrollView].
/// ///
/// Setting this value to false will allow the child to fill the remainder of /// Setting this value to false will allow the child to fill the remainder of
/// the viewport and not extend further. /// the viewport and not extend further. However, if the
/// [precedingScrollExtent] exceeds the size of the viewport, the sliver will
/// defer to the child's size rather than overriding it.
bool hasScrollBody; bool hasScrollBody;
/// Indicates whether the child should stretch to fill the overscroll area
/// created by certain scroll physics, such as iOS' default scroll physics.
/// This value cannot be null. This flag is only relevant when the
/// [hasScrollBody] value is false.
///
/// Defaults to false, meaning the default behavior is for the child to
/// maintain its size and not extend into the overscroll area.
bool fillOverscroll;
@override @override
void performLayout() { void performLayout() {
final double extent = constraints.remainingPaintExtent double childExtent;
- math.min(constraints.overlap, 0.0) double extent = constraints.viewportMainAxisExtent - constraints.precedingScrollExtent;
// Adding the offset for when this SliverFillRemaining is not scrollable, double maxExtent = constraints.remainingPaintExtent - math.min(constraints.overlap, 0.0);
// so it will stretch to fill on overscroll.
+ (hasScrollBody ? 0.0 : constraints.scrollOffset); if (hasScrollBody) {
if (child != null) extent = maxExtent;
child.layout(constraints.asBoxConstraints(minExtent: extent, maxExtent: extent), parentUsesSize: true); if (child != null)
child.layout(
constraints.asBoxConstraints(
minExtent: extent,
maxExtent: extent,
),
parentUsesSize: true,
);
} else if (child != null) {
child.layout(constraints.asBoxConstraints(), parentUsesSize: true);
switch (constraints.axis) {
case Axis.horizontal:
childExtent = child.size.width;
break;
case Axis.vertical:
childExtent = child.size.height;
break;
}
if (constraints.precedingScrollExtent > constraints.viewportMainAxisExtent || childExtent > extent)
extent = childExtent;
if (maxExtent < extent)
maxExtent = extent;
if ((fillOverscroll ? maxExtent : extent) > childExtent) {
child.layout(
constraints.asBoxConstraints(
minExtent: extent,
maxExtent: fillOverscroll ? maxExtent : extent,
),
parentUsesSize: true,
);
}
}
assert(extent.isFinite,
'The calculated extent for the child of SliverFillRemaining is not finite.'
'This can happen if the child is a scrollable, in which case, the'
'hasScrollBody property of SliverFillRemaining should not be set to'
'false.',
);
final double paintedChildSize = calculatePaintOffset(constraints, from: 0.0, to: extent); final double paintedChildSize = calculatePaintOffset(constraints, from: 0.0, to: extent);
assert(paintedChildSize.isFinite); assert(paintedChildSize.isFinite);
assert(paintedChildSize >= 0.0); assert(paintedChildSize >= 0.0);
geometry = SliverGeometry( geometry = SliverGeometry(
// 0.0 can be applied here for cases when there is not scroll body since scrollExtent: hasScrollBody ? constraints.viewportMainAxisExtent : extent,
// SliverFillRemaining will not have any slivers following it.
scrollExtent: hasScrollBody ? constraints.viewportMainAxisExtent : 0.0,
paintExtent: paintedChildSize, paintExtent: paintedChildSize,
maxPaintExtent: paintedChildSize, maxPaintExtent: paintedChildSize,
hasVisualOverflow: extent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0, hasVisualOverflow: extent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0,
......
...@@ -1369,23 +1369,44 @@ class SliverFillRemaining extends SingleChildRenderObjectWidget { ...@@ -1369,23 +1369,44 @@ class SliverFillRemaining extends SingleChildRenderObjectWidget {
Key key, Key key,
Widget child, Widget child,
this.hasScrollBody = true, this.hasScrollBody = true,
this.fillOverscroll = false,
}) : assert(hasScrollBody != null), }) : assert(hasScrollBody != null),
super(key: key, child: child); super(key: key, child: child);
/// Whether the child has a scrollable body, this value cannot be null. /// Indicates whether the child has a scrollable body, this value cannot be
/// null.
/// ///
/// Defaults to true such that the child will extend beyond the viewport and /// Defaults to true such that the child will extend beyond the viewport and
/// scroll, as seen in [NestedScrollView]. /// scroll, as seen in [NestedScrollView].
/// ///
/// Setting this value to false will allow the child to fill the remainder of /// Setting this value to false will allow the child to fill the remainder of
/// the viewport and not extend further. /// the viewport and not extend further. However, if the
/// [precedingScrollExtent] exceeds the size of the viewport, the sliver will
/// defer to the child's size rather than overriding it.
final bool hasScrollBody; final bool hasScrollBody;
/// Indicates whether the child should stretch to fill the overscroll area
/// created by certain scroll physics, such as iOS' default scroll physics.
/// This value cannot be null. This flag is only relevant when the
/// [hasScrollBody] value is false.
///
/// Defaults to false, meaning the default behavior is for the child to
/// maintain its size and not extend into the overscroll area.
final bool fillOverscroll;
@override @override
RenderSliverFillRemaining createRenderObject(BuildContext context) => RenderSliverFillRemaining(hasScrollBody: hasScrollBody); RenderSliverFillRemaining createRenderObject(BuildContext context) {
return RenderSliverFillRemaining(
hasScrollBody: hasScrollBody,
fillOverscroll: fillOverscroll,
);
}
@override @override
void updateRenderObject(BuildContext context, RenderSliverFillRemaining renderObject) => renderObject.hasScrollBody = hasScrollBody; void updateRenderObject(BuildContext context, RenderSliverFillRemaining renderObject) {
renderObject.hasScrollBody = hasScrollBody;
renderObject.fillOverscroll = fillOverscroll;
}
} }
/// Mark a child as needing to stay alive even when it's in a lazy list that /// Mark a child as needing to stay alive even when it's in a lazy list that
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
...@@ -64,62 +65,368 @@ void main() { ...@@ -64,62 +65,368 @@ void main() {
expect(tester.renderObject<RenderBox>(find.byType(Container)).size.height, equals(500.0)); expect(tester.renderObject<RenderBox>(find.byType(Container)).size.height, equals(500.0));
}); });
testWidgets('SliverFillRemaining does not extend past viewport.', (WidgetTester tester) async { group('SliverFillRemaining - hasScrollBody', () {
final ScrollController controller = ScrollController(); final Widget sliverBox = SliverToBoxAdapter(
await tester.pumpWidget( child: Container(
Directionality( color: Colors.amber,
textDirection: TextDirection.ltr, height: 150.0,
child: CustomScrollView( ),
controller: controller, );
slivers: <Widget>[ Widget boilerplate(List<Widget> slivers, {ScrollController controller}) {
SliverToBoxAdapter( return MaterialApp(
child: Container( home: Scaffold(
color: Colors.red, body: CustomScrollView(
height: 150.0, slivers: slivers,
controller: controller,
),
),
);
}
testWidgets('does not extend past viewport when false', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
final List<Widget> slivers = <Widget>[
sliverBox,
SliverFillRemaining(
child: Container(color: Colors.white),
hasScrollBody: false,
),
];
await tester.pumpWidget(boilerplate(slivers, controller: controller));
expect(controller.offset, 0.0);
expect(find.byType(Container), findsNWidgets(2));
controller.jumpTo(150.0);
await tester.pumpAndSettle();
expect(controller.offset, 0.0);
expect(find.byType(Container), findsNWidgets(2));
});
testWidgets('scrolls beyond viewport by default', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
final List<Widget> slivers = <Widget>[
sliverBox,
SliverFillRemaining(
child: Container(color: Colors.white),
),
];
await tester.pumpWidget(boilerplate(slivers, controller: controller));
expect(controller.offset, 0.0);
expect(find.byType(Container), findsNWidgets(2));
controller.jumpTo(150.0);
await tester.pumpAndSettle();
expect(controller.offset, 150.0);
expect(find.byType(Container), findsOneWidget);
});
// SliverFillRemaining considers child size when hasScrollBody: false
testWidgets('child without size is sized by extent when false', (WidgetTester tester) async {
final List<Widget> slivers = <Widget>[
sliverBox,
SliverFillRemaining(
hasScrollBody: false,
child: Container(color: Colors.blue),
),
];
await tester.pumpWidget(boilerplate(slivers));
final RenderBox box = tester.renderObject<RenderBox>(find.byType(Container).last);
expect(box.size.height, equals(450));
});
testWidgets('child with size is sized by extent when false', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
final List<Widget> slivers = <Widget>[
sliverBox,
SliverFillRemaining(
hasScrollBody: false,
child: Container(
key: key,
color: Colors.blue,
child: Align(
alignment: Alignment.bottomCenter,
child: RaisedButton(
child: const Text('bottomCenter button'),
onPressed: () {},
), ),
), ),
SliverFillRemaining( ),
child: Container(color: Colors.white), ),
hasScrollBody: false, ];
await tester.pumpWidget(boilerplate(slivers));
expect(tester.renderObject<RenderBox>(find.byKey(key)).size.height, equals(450));
// Also check that the button alignment is true to expectations
final Finder button = find.byType(RaisedButton);
expect(tester.getBottomLeft(button).dy, equals(600.0));
expect(tester.getCenter(button).dx, equals(400.0));
});
testWidgets('extent is overridden by child with larger size when false', (WidgetTester tester) async {
final List<Widget> slivers = <Widget>[
sliverBox,
SliverFillRemaining(
hasScrollBody: false,
child: Container(
color: Colors.blue,
height: 600,
),
),
];
await tester.pumpWidget(boilerplate(slivers));
final RenderBox box = tester.renderObject<RenderBox>(find.byType(Container).last);
expect(box.size.height, equals(600));
});
testWidgets('extent is overridden by child size if precedingScrollExtent > viewportMainAxisExtent when false', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
final List<Widget> slivers = <Widget>[
SliverFixedExtentList(
itemExtent: 150,
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => Container(color: Colors.amber),
childCount: 5,
),
),
SliverFillRemaining(
hasScrollBody: false,
child: Container(
key: key,
color: Colors.blue[300],
child: Align(
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.all(50.0),
child: RaisedButton(
child: const Text('center button'),
onPressed: () {},
),
),
), ),
], ),
), ),
), ];
); await tester.pumpWidget(boilerplate(slivers));
expect(controller.offset, 0.0); await tester.drag(find.byType(Scrollable), const Offset(0.0, -750.0));
expect(find.byType(Container), findsNWidgets(2)); await tester.pump();
controller.jumpTo(150.0); expect(tester.renderObject<RenderBox>(find.byKey(key)).size.height, equals(148.0));
await tester.pumpAndSettle();
expect(controller.offset, 0.0);
expect(find.byType(Container), findsNWidgets(2));
});
testWidgets('SliverFillRemaining scrolls beyond viewport by default.', (WidgetTester tester) async { // Also check that the button alignment is true to expectations
final ScrollController controller = ScrollController(); final Finder button = find.byType(RaisedButton);
await tester.pumpWidget( expect(tester.getBottomLeft(button).dy, equals(550.0));
Directionality( expect(tester.getCenter(button).dx, equals(400.0));
textDirection: TextDirection.ltr, });
child: CustomScrollView(
controller: controller, // iOS/Similar scroll physics when hasScrollBody: false & fillOverscroll: true behavior
slivers: <Widget>[ testWidgets('child without size is sized by extent and overscroll', (WidgetTester tester) async {
SliverToBoxAdapter( debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
child: Container( final List<Widget> slivers = <Widget>[
color: Colors.red, sliverBox,
height: 150.0, SliverFillRemaining(
hasScrollBody: false,
fillOverscroll: true,
child: Container(color: Colors.blue),
),
];
await tester.pumpWidget(boilerplate(slivers));
final RenderBox box1 = tester.renderObject<RenderBox>(find.byType(Container).last);
expect(box1.size.height, equals(450));
await tester.drag(find.byType(Scrollable), const Offset(0.0, -50.0));
await tester.pump();
final RenderBox box2 = tester.renderObject<RenderBox>(find.byType(Container).last);
expect(box2.size.height, greaterThan(450));
debugDefaultTargetPlatformOverride = null;
});
testWidgets('child with size is overridden and sized by extent and overscroll', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final GlobalKey key = GlobalKey();
final List<Widget> slivers = <Widget>[
sliverBox,
SliverFillRemaining(
hasScrollBody: false,
fillOverscroll: true,
child: Container(
key: key,
color: Colors.blue,
child: Align(
alignment: Alignment.bottomCenter,
child: RaisedButton(
child: const Text('bottomCenter button'),
onPressed: () {},
), ),
), ),
SliverFillRemaining( ),
child: Container(color: Colors.white), ),
];
await tester.pumpWidget(boilerplate(slivers));
expect(tester.renderObject<RenderBox>(find.byKey(key)).size.height, equals(450));
await tester.drag(find.byType(Scrollable), const Offset(0.0, -50.0));
await tester.pump();
expect(tester.renderObject<RenderBox>(find.byKey(key)).size.height, greaterThan(450));
// Also check that the button alignment is true to expectations, even with
// child stretching to fill overscroll
final Finder button = find.byType(RaisedButton);
expect(tester.getBottomLeft(button).dy, equals(600.0));
expect(tester.getCenter(button).dx, equals(400.0));
debugDefaultTargetPlatformOverride = null;
});
testWidgets('extent is overridden by child size and overscroll if precedingScrollExtent > viewportMainAxisExtent', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final GlobalKey key = GlobalKey();
final ScrollController controller = ScrollController();
final List<Widget> slivers = <Widget>[
SliverFixedExtentList(
itemExtent: 150,
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => Container(color: Colors.amber),
childCount: 5,
),
),
SliverFillRemaining(
hasScrollBody: false,
fillOverscroll: true,
child: Container(
key: key,
color: Colors.blue[300],
child: Align(
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.all(50.0),
child: RaisedButton(
child: const Text('center button'),
onPressed: () {},
),
),
), ),
], ),
), ),
), ];
); await tester.pumpWidget(boilerplate(slivers, controller: controller));
expect(controller.offset, 0.0); // Scroll to the end
expect(find.byType(Container), findsNWidgets(2)); controller.jumpTo(controller.position.maxScrollExtent);
controller.jumpTo(150.0); await tester.pump();
await tester.pumpAndSettle(); expect(tester.renderObject<RenderBox>(find.byKey(key)).size.height, equals(148.0));
expect(controller.offset, 150.0); // Check that the button alignment is true to expectations
expect(find.byType(Container), findsOneWidget); final Finder button = find.byType(RaisedButton);
expect(tester.getBottomLeft(button).dy, equals(550.0));
expect(tester.getCenter(button).dx, equals(400.0));
debugDefaultTargetPlatformOverride = null;
// Drag for overscroll
await tester.drag(find.byType(Scrollable), const Offset(0.0, -50.0));
await tester.pump();
expect(tester.renderObject<RenderBox>(find.byKey(key)).size.height, greaterThan(148.0));
// Check that the button alignment is still centered in stretched child
expect(tester.getBottomLeft(button).dy, lessThan(550.0));
expect(tester.getCenter(button).dx, equals(400.0));
debugDefaultTargetPlatformOverride = null;
});
// Android/Other scroll physics when hasScrollBody: false, ignores fillOverscroll: true
testWidgets('child without size is sized by extent, fillOverscroll is ignored', (WidgetTester tester) async {
final List<Widget> slivers = <Widget>[
sliverBox,
SliverFillRemaining(
hasScrollBody: false,
fillOverscroll: true,
child: Container(color: Colors.blue),
),
];
await tester.pumpWidget(boilerplate(slivers));
final RenderBox box1 = tester.renderObject<RenderBox>(find.byType(Container).last);
expect(box1.size.height, equals(450));
await tester.drag(find.byType(Scrollable), const Offset(0.0, -50.0));
await tester.pump();
final RenderBox box2 = tester.renderObject<RenderBox>(find.byType(Container).last);
expect(box2.size.height, equals(450));
});
testWidgets('child with size is overridden and sized by extent, fillOverscroll is ignored', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
final List<Widget> slivers = <Widget>[
sliverBox,
SliverFillRemaining(
hasScrollBody: false,
fillOverscroll: true,
child: Container(
key: key,
color: Colors.blue,
child: Align(
alignment: Alignment.bottomCenter,
child: RaisedButton(
child: const Text('bottomCenter button'),
onPressed: () {},
),
),
),
),
];
await tester.pumpWidget(boilerplate(slivers));
expect(tester.renderObject<RenderBox>(find.byKey(key)).size.height, equals(450));
await tester.drag(find.byType(Scrollable), const Offset(0.0, -50.0));
await tester.pump();
expect(tester.renderObject<RenderBox>(find.byKey(key)).size.height, equals(450));
// Also check that the button alignment is true to expectations
final Finder button = find.byType(RaisedButton);
expect(tester.getBottomLeft(button).dy, equals(600.0));
expect(tester.getCenter(button).dx, equals(400.0));
});
testWidgets('extent is overridden by child size if precedingScrollExtent > viewportMainAxisExtent, fillOverscroll is ignored', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
final ScrollController controller = ScrollController();
final List<Widget> slivers = <Widget>[
SliverFixedExtentList(
itemExtent: 150,
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => Container(color: Colors.amber),
childCount: 5,
),
),
SliverFillRemaining(
hasScrollBody: false,
fillOverscroll: true,
child: Container(
key: key,
color: Colors.blue[300],
child: Align(
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.all(50.0),
child: RaisedButton(
child: const Text('center button'),
onPressed: () {},
),
),
),
),
),
];
await tester.pumpWidget(boilerplate(slivers, controller: controller));
// Scroll to the end
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pump();
expect(tester.renderObject<RenderBox>(find.byKey(key)).size.height, equals(148.0));
// Check that the button alignment is true to expectations
final Finder button = find.byType(RaisedButton);
expect(tester.getBottomLeft(button).dy, equals(550.0));
expect(tester.getCenter(button).dx, equals(400.0));
debugDefaultTargetPlatformOverride = null;
await tester.drag(find.byType(Scrollable), const Offset(0.0, -50.0));
await tester.pump();
expect(tester.renderObject<RenderBox>(find.byKey(key)).size.height, equals(148.0));
// Check that the button alignment is still centered in stretched child
expect(tester.getBottomLeft(button).dy, equals(550.0));
expect(tester.getCenter(button).dx, equals(400.0));
});
}); });
} }
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