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));
+    });
   });
 }