diff --git a/packages/flutter/lib/src/rendering/sliver_fill.dart b/packages/flutter/lib/src/rendering/sliver_fill.dart index 1adbe83af1..fefafe3e99 100644 --- a/packages/flutter/lib/src/rendering/sliver_fill.dart +++ b/packages/flutter/lib/src/rendering/sliver_fill.dart @@ -7,6 +7,7 @@ import 'dart:math' as math; 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 @@ class RenderSliverFillRemaining extends RenderSliverSingleBoxAdapter { 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 0e0fb864d5..3bf1bf8eca 100644 --- a/packages/flutter/lib/src/widgets/sliver.dart +++ b/packages/flutter/lib/src/widgets/sliver.dart @@ -1369,23 +1369,44 @@ class SliverFillRemaining extends SingleChildRenderObjectWidget { 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 1b58378730..0ebae1fad7 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 @@ void main() { expect(tester.renderObject(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: [ - 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 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: [ - 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 slivers = [ + 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 slivers = [ + 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 slivers = [ + sliverBox, + SliverFillRemaining( + hasScrollBody: false, + child: Container(color: Colors.blue), + ), + ]; + await tester.pumpWidget(boilerplate(slivers)); + final RenderBox box = tester.renderObject(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 slivers = [ + 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(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 slivers = [ + sliverBox, + SliverFillRemaining( + hasScrollBody: false, + child: Container( + color: Colors.blue, + height: 600, + ), + ), + ]; + await tester.pumpWidget(boilerplate(slivers)); + final RenderBox box = tester.renderObject(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 slivers = [ + 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(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 slivers = [ + sliverBox, + SliverFillRemaining( + hasScrollBody: false, + fillOverscroll: true, + child: Container(color: Colors.blue), + ), + ]; + await tester.pumpWidget(boilerplate(slivers)); + final RenderBox box1 = tester.renderObject(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(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 slivers = [ + 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(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(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 slivers = [ + 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(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(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 slivers = [ + sliverBox, + SliverFillRemaining( + hasScrollBody: false, + fillOverscroll: true, + child: Container(color: Colors.blue), + ), + ]; + await tester.pumpWidget(boilerplate(slivers)); + final RenderBox box1 = tester.renderObject(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(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 slivers = [ + 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(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(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 slivers = [ + 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(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(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)); + }); }); }