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.
diff --git a/packages/flutter/lib/src/rendering/sliver_fill.dart b/packages/flutter/lib/src/rendering/sliver_fill.dart
index 1adbe83..fefafe3 100644
--- a/packages/flutter/lib/src/rendering/sliver_fill.dart
+++ b/packages/flutter/lib/src/rendering/sliver_fill.dart
@@ -7,6 +7,7 @@
import 'package:flutter/foundation.dart';
import 'box.dart';
+import 'object.dart';
import 'sliver.dart';
import 'sliver_fixed_extent_list.dart';
import 'sliver_multi_box_adaptor.dart';
@@ -114,34 +115,84 @@
RenderSliverFillRemaining({
RenderBox child,
this.hasScrollBody = true,
+ this.fillOverscroll = false,
}) : assert(hasScrollBody != null),
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
/// scroll, as seen in [NestedScrollView].
///
/// 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;
+ /// 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
void performLayout() {
- final double extent = constraints.remainingPaintExtent
- - math.min(constraints.overlap, 0.0)
- // Adding the offset for when this SliverFillRemaining is not scrollable,
- // so it will stretch to fill on overscroll.
- + (hasScrollBody ? 0.0 : constraints.scrollOffset);
- if (child != null)
- child.layout(constraints.asBoxConstraints(minExtent: extent, maxExtent: extent), parentUsesSize: true);
+ double childExtent;
+ double extent = constraints.viewportMainAxisExtent - constraints.precedingScrollExtent;
+ double maxExtent = constraints.remainingPaintExtent - math.min(constraints.overlap, 0.0);
+
+ if (hasScrollBody) {
+ extent = maxExtent;
+ 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);
assert(paintedChildSize.isFinite);
assert(paintedChildSize >= 0.0);
geometry = SliverGeometry(
- // 0.0 can be applied here for cases when there is not scroll body since
- // SliverFillRemaining will not have any slivers following it.
- scrollExtent: hasScrollBody ? constraints.viewportMainAxisExtent : 0.0,
+ scrollExtent: hasScrollBody ? constraints.viewportMainAxisExtent : extent,
paintExtent: paintedChildSize,
maxPaintExtent: paintedChildSize,
hasVisualOverflow: extent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0,
diff --git a/packages/flutter/lib/src/widgets/sliver.dart b/packages/flutter/lib/src/widgets/sliver.dart
index 0e0fb86..3bf1bf8 100644
--- a/packages/flutter/lib/src/widgets/sliver.dart
+++ b/packages/flutter/lib/src/widgets/sliver.dart
@@ -1369,23 +1369,44 @@
Key key,
Widget child,
this.hasScrollBody = true,
+ this.fillOverscroll = false,
}) : assert(hasScrollBody != null),
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
/// scroll, as seen in [NestedScrollView].
///
/// 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;
- @override
- RenderSliverFillRemaining createRenderObject(BuildContext context) => RenderSliverFillRemaining(hasScrollBody: 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
- void updateRenderObject(BuildContext context, RenderSliverFillRemaining renderObject) => renderObject.hasScrollBody = hasScrollBody;
+ RenderSliverFillRemaining createRenderObject(BuildContext context) {
+ return RenderSliverFillRemaining(
+ hasScrollBody: hasScrollBody,
+ fillOverscroll: fillOverscroll,
+ );
+ }
+
+ @override
+ 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
diff --git a/packages/flutter/test/widgets/sliver_fill_remaining_test.dart b/packages/flutter/test/widgets/sliver_fill_remaining_test.dart
index 1b58378..0ebae1f 100644
--- a/packages/flutter/test/widgets/sliver_fill_remaining_test.dart
+++ b/packages/flutter/test/widgets/sliver_fill_remaining_test.dart
@@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
@@ -64,62 +65,368 @@
expect(tester.renderObject<RenderBox>(find.byType(Container)).size.height, equals(500.0));
});
- testWidgets('SliverFillRemaining does not extend past viewport.', (WidgetTester tester) async {
- final ScrollController controller = ScrollController();
- await tester.pumpWidget(
- Directionality(
- textDirection: TextDirection.ltr,
- child: CustomScrollView(
- controller: controller,
- slivers: <Widget>[
- SliverToBoxAdapter(
- child: Container(
- color: Colors.red,
- height: 150.0,
- ),
- ),
- SliverFillRemaining(
- child: Container(color: Colors.white),
- hasScrollBody: false,
- ),
- ],
- ),
+ group('SliverFillRemaining - hasScrollBody', () {
+ final Widget sliverBox = SliverToBoxAdapter(
+ child: Container(
+ color: Colors.amber,
+ height: 150.0,
),
);
- 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));
- });
+ Widget boilerplate(List<Widget> slivers, {ScrollController controller}) {
+ return MaterialApp(
+ home: Scaffold(
+ body: CustomScrollView(
+ slivers: slivers,
+ controller: controller,
+ ),
+ ),
+ );
+ }
- testWidgets('SliverFillRemaining scrolls beyond viewport by default.', (WidgetTester tester) async {
- final ScrollController controller = ScrollController();
- await tester.pumpWidget(
- Directionality(
- textDirection: TextDirection.ltr,
- child: CustomScrollView(
- controller: controller,
- slivers: <Widget>[
- SliverToBoxAdapter(
- child: Container(
- color: Colors.red,
- height: 150.0,
+ 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),
- ),
- ],
+ ),
),
- ),
- );
- 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);
+ ];
+ 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));
+ await tester.drag(find.byType(Scrollable), const Offset(0.0, -750.0));
+ await tester.pump();
+ expect(tester.renderObject<RenderBox>(find.byKey(key)).size.height, equals(148.0));
+
+ // Also 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));
+ });
+
+ // iOS/Similar scroll physics when hasScrollBody: false & fillOverscroll: true behavior
+ testWidgets('child without size is sized by extent and overscroll', (WidgetTester tester) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ 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, 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: () {},
+ ),
+ ),
+ ),
+ ),
+ ];
+ 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));
+ // 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;
+
+ // 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));
+ });
});
}