From 19f79ac8daeb6d6574c85204a57cd51a83243aba Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Fri, 15 Feb 2019 14:48:39 -0800 Subject: [PATCH] Add extendBody parameter to Scaffold, body MediaQuery reflects BAB height (#27973) --- AUTHORS | 1 + .../flutter/lib/src/material/scaffold.dart | 111 +++++++++++++++++- packages/flutter/lib/src/rendering/box.dart | 2 +- .../flutter/test/material/scaffold_test.dart | 57 +++++++++ 4 files changed, 166 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index 2b9f1a8a2bd..d5a6f570997 100644 --- a/AUTHORS +++ b/AUTHORS @@ -37,3 +37,4 @@ Sander Dalby Larsen Marco Scannadinari Frederik Schweiger Martin Staadecker +Igor Katsuba diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 1135aaaccaa..1e0759dbb40 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -275,6 +275,76 @@ class _ScaffoldGeometryNotifier extends ChangeNotifier implements ValueListenabl } } +// Used to communicate the height of the Scaffold's bottomNavigationBar and +// persistentFooterButtons to the LayoutBuilder which builds the Scaffold's body. +// +// Scaffold expects a _BodyBoxConstraints to be passed to the _BodyBuilder +// widget's LayoutBuilder, see _ScaffoldLayout.performLayout(). The BoxConstraints +// methods that construct new BoxConstraints objects, like copyWith() have not +// been overridden here because we expect the _BodyBoxConstraintsObject to be +// passed along unmodified to the LayoutBuilder. If that changes in the future +// then _BodyBuilder will assert. +class _BodyBoxConstraints extends BoxConstraints { + const _BodyBoxConstraints({ + double minWidth = 0.0, + double maxWidth = double.infinity, + double minHeight = 0.0, + double maxHeight = double.infinity, + @required this.bottomWidgetsHeight, + }) : assert(bottomWidgetsHeight != null), + assert(bottomWidgetsHeight >= 0), + super(minWidth: minWidth, maxWidth: maxWidth, minHeight: minHeight, maxHeight: maxHeight); + + final double bottomWidgetsHeight; + + // RenderObject.layout() will only short-circuit its call to its performLayout + // method if the new layout constraints are not == to the current constraints. + // If the height of the bottom widgets has changed, even though the constraints' + // min and max values have not, we still want performLayout to happen. + @override + bool operator ==(dynamic other) { + if (super != other) + return false; + final _BodyBoxConstraints typedOther = other; + return bottomWidgetsHeight == typedOther.bottomWidgetsHeight; + } + + @override + int get hashCode { + return hashValues(super.hashCode, bottomWidgetsHeight); + } +} + +// Used when Scaffold.extendBody is true to wrap the scaffold's body in a MediaQuery +// whose padding accounts for the height of the bottomNavigationBar and/or the +// persistentFooterButtons. +// +// The bottom widgets' height is passed along via the _BodyBoxConstraints parameter. +// The constraints parameter is constructed in_ScaffoldLayout.performLayout(). +class _BodyBuilder extends StatelessWidget { + const _BodyBuilder({ Key key, this.body }) : super(key: key); + + final Widget body; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final _BodyBoxConstraints bodyConstraints = constraints; + final MediaQueryData metrics = MediaQuery.of(context); + return MediaQuery( + data: metrics.copyWith( + padding: metrics.padding.copyWith( + bottom: math.max(metrics.padding.bottom, bodyConstraints.bottomWidgetsHeight), + ), + ), + child: body, + ); + }, + ); + } +} + class _ScaffoldLayout extends MultiChildLayoutDelegate { _ScaffoldLayout({ @required this.minInsets, @@ -285,12 +355,15 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { @required this.currentFloatingActionButtonLocation, @required this.floatingActionButtonMoveAnimationProgress, @required this.floatingActionButtonMotionAnimator, + @required this.extendBody, }) : assert(minInsets != null), assert(textDirection != null), assert(geometryNotifier != null), assert(previousFloatingActionButtonLocation != null), - assert(currentFloatingActionButtonLocation != null); + assert(currentFloatingActionButtonLocation != null), + assert(extendBody != null); + final bool extendBody; final EdgeInsets minInsets; final TextDirection textDirection; final _ScaffoldGeometryNotifier geometryNotifier; @@ -343,9 +416,17 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { final double contentBottom = math.max(0.0, bottom - math.max(minInsets.bottom, bottomWidgetsHeight)); if (hasChild(_ScaffoldSlot.body)) { - final BoxConstraints bodyConstraints = BoxConstraints( + double bodyMaxHeight = math.max(0.0, contentBottom - contentTop); + + if (extendBody) { + bodyMaxHeight += bottomWidgetsHeight; + assert(bodyMaxHeight <= math.max(0.0, looseConstraints.maxHeight - contentTop)); + } + + final BoxConstraints bodyConstraints = _BodyBoxConstraints( maxWidth: fullWidthConstraints.maxWidth, - maxHeight: math.max(0.0, contentBottom - contentTop), + maxHeight: bodyMaxHeight, + bottomWidgetsHeight: extendBody ? bottomWidgetsHeight : 0.0, ); layoutChild(_ScaffoldSlot.body, bodyConstraints); positionChild(_ScaffoldSlot.body, Offset(0.0, contentTop)); @@ -795,11 +876,29 @@ class Scaffold extends StatefulWidget { this.resizeToAvoidBottomPadding, this.resizeToAvoidBottomInset, this.primary = true, + this.extendBody = false, this.drawerDragStartBehavior = DragStartBehavior.down, }) : assert(primary != null), + assert(extendBody != null), assert(drawerDragStartBehavior != null), super(key: key); + /// If true, and [bottomNavigationBar] or [persistentFooterButtons] + /// is specified, then the [body] extends to the bottom of the Scaffold, + /// instead of only extending to the top of the [bottomNavigationBar] + /// or the [persistentFooterButtons]. + /// + /// If true, a [MediaQuery] widget whose bottom padding matches the + /// the height of the [bottomNavigationBar] will be added above the + /// scaffold's [body]. + /// + /// This property is often useful when the [bottomNavigationBar] has + /// a non-rectangular shape, like [CircularNotchedRectangle], which + /// adds a [FloatingActionButton] sized notch to the top edge of the bar. + /// In this case specifying `extendBody: true` ensures that that scaffold's + /// body will be visible through the bottom navigation bar's notch. + final bool extendBody; + /// An app bar to display at the top of the scaffold. final PreferredSizeWidget appBar; @@ -1697,7 +1796,7 @@ class ScaffoldState extends State with TickerProviderStateMixin { _addIfNonNull( children, - widget.body, + widget.body != null && widget.extendBody ? _BodyBuilder(body: widget.body) : widget.body, _ScaffoldSlot.body, removeLeftPadding: false, removeTopPadding: widget.appBar != null, @@ -1850,6 +1949,9 @@ class ScaffoldState extends State with TickerProviderStateMixin { bottom: _resizeToAvoidBottomInset ? mediaQuery.viewInsets.bottom : 0.0, ); + // extendBody locked when keyboard is open + final bool _extendBody = minInsets.bottom > 0 ? false : widget.extendBody; + return _ScaffoldScope( hasDrawer: hasDrawer, geometryNotifier: _geometryNotifier, @@ -1861,6 +1963,7 @@ class ScaffoldState extends State with TickerProviderStateMixin { return CustomMultiChildLayout( children: children, delegate: _ScaffoldLayout( + extendBody: _extendBody, minInsets: minInsets, currentFloatingActionButtonLocation: _floatingActionButtonLocation, floatingActionButtonMoveAnimationProgress: _floatingActionButtonMoveController.value, diff --git a/packages/flutter/lib/src/rendering/box.dart b/packages/flutter/lib/src/rendering/box.dart index 947ab1f050d..e297305223c 100644 --- a/packages/flutter/lib/src/rendering/box.dart +++ b/packages/flutter/lib/src/rendering/box.dart @@ -574,7 +574,7 @@ class BoxConstraints extends Constraints { assert(debugAssertIsValid()); if (identical(this, other)) return true; - if (other is! BoxConstraints) + if (runtimeType != other.runtimeType) return false; final BoxConstraints typedOther = other; assert(typedOther.debugAssertIsValid()); diff --git a/packages/flutter/test/material/scaffold_test.dart b/packages/flutter/test/material/scaffold_test.dart index e413b6ce85e..ad0b8c4d34f 100644 --- a/packages/flutter/test/material/scaffold_test.dart +++ b/packages/flutter/test/material/scaffold_test.dart @@ -574,6 +574,63 @@ void main() { expect(tester.element(find.byKey(testKey)).size, const Size(88.0, 48.0)); expect(tester.renderObject(find.byKey(testKey)).localToGlobal(Offset.zero), const Offset(0.0, 0.0)); }); + + testWidgets('body size with extendBody', (WidgetTester tester) async { + final Key bodyKey = UniqueKey(); + double mediaQueryBottom; + + Widget buildFrame({ bool extendBody, bool resizeToAvoidBottomInset, double viewInsetBottom = 0.0 }) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData( + viewInsets: EdgeInsets.only(bottom: viewInsetBottom), + ), + child: Scaffold( + resizeToAvoidBottomInset: resizeToAvoidBottomInset, + extendBody: extendBody, + body: Builder( + builder: (BuildContext context) { + mediaQueryBottom = MediaQuery.of(context).padding.bottom; + return Container(key: bodyKey); + }, + ), + bottomNavigationBar: const BottomAppBar( + child: SizedBox(height: 48.0,), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(extendBody: true)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 600.0)); + expect(mediaQueryBottom, 48.0); + + await tester.pumpWidget(buildFrame(extendBody: false)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 552.0)); // 552 = 600 - 48 (BAB height) + expect(mediaQueryBottom, 0.0); + + // If resizeToAvoidBottomInsets is false, same results as if it was unspecified (null). + await tester.pumpWidget(buildFrame(extendBody: true, resizeToAvoidBottomInset: false, viewInsetBottom: 100.0)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 600.0)); + expect(mediaQueryBottom, 48.0); + + await tester.pumpWidget(buildFrame(extendBody: false, resizeToAvoidBottomInset: false, viewInsetBottom: 100.0)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 552.0)); + expect(mediaQueryBottom, 0.0); + + // If resizeToAvoidBottomInsets is true and viewInsets.bottom is > the bottom + // navigation bar's height then the body always resizes and the MediaQuery + // isn't adjusted. This case corresponds to the keyboard appearing. + await tester.pumpWidget(buildFrame(extendBody: true, resizeToAvoidBottomInset: true, viewInsetBottom: 100.0)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 500.0)); + expect(mediaQueryBottom, 0.0); + + await tester.pumpWidget(buildFrame(extendBody: false, resizeToAvoidBottomInset: true, viewInsetBottom: 100.0)); + expect(tester.getSize(find.byKey(bodyKey)), const Size(800.0, 500.0)); + expect(mediaQueryBottom, 0.0); + }); }); testWidgets('Open drawer hides underlying semantics tree', (WidgetTester tester) async {