ScaffoldGeometry plumbing. (#14580)

Adds a ScaffoldGeometry class and ValueNotifier for it.
A scaffold's ScaffoldGeometry notifier is held in the _ScaffoldState, and is passed to _ScaffoldScope.
New ScaffoldGemometry objects are built and published to the notifier.
diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart
index a0cf4e6..c4f7873 100644
--- a/packages/flutter/lib/src/material/scaffold.dart
+++ b/packages/flutter/lib/src/material/scaffold.dart
@@ -7,6 +7,7 @@
 import 'dart:math' as math;
 
 import 'package:flutter/foundation.dart';
+import 'package:flutter/rendering.dart';
 import 'package:flutter/widgets.dart';
 
 import 'app_bar.dart';
@@ -36,18 +37,111 @@
   statusBar,
 }
 
+// Examples can assume:
+// ScaffoldGeometry scaffoldGeometry;
+
+/// Geometry information for scaffold components.
+///
+/// To get a [ValueNotifier] for the scaffold geometry call
+/// [Scaffold.geometryOf].
+@immutable
+class ScaffoldGeometry {
+  const ScaffoldGeometry({
+    this.bottomNavigationBarTop,
+    this.floatingActionButtonArea,
+    this.floatingActionButtonScale: 1.0,
+  });
+
+  /// The distance from the scaffold's top edge to the top edge of the
+  /// rectangle in which the [Scaffold.bottomNavigationBar] bar is being laid
+  /// out.
+  ///
+  /// When there is no [Scaffold.bottomNavigationBar] set, this will be null.
+  final double bottomNavigationBarTop;
+
+  /// The rectangle in which the scaffold is laying out
+  /// [Scaffold.floatingActionButton].
+  ///
+  /// The floating action button might be scaled inside this rectangle, to get
+  /// the bounding rectangle in which the floating action is painted scale this
+  /// value by [floatingActionButtonScale].
+  ///
+  /// ## Sample code
+  ///
+  /// ```dart
+  ///  final Rect scaledFab = Rect.lerp(
+  ///    scaffoldGeometry.floatingActionButtonArea.center & Size.zero,
+  ///    scaffoldGeometry.floatingActionButtonArea,
+  ///    scaffoldGeometry.floatingActionButtonScale
+  ///  );
+  /// ```
+  ///
+  /// This is null when there is no floating action button showing.
+  final Rect floatingActionButtonArea;
+
+  /// The amount by which the [Scaffold.floatingActionButton] is scaled.
+  ///
+  /// To get the bounding rectangle in which the floating action button is
+  /// painted scaled [floatingActionPosition] by this proportion. 
+  ///
+  /// This will be 0 when there is no [Scaffold.floatingActionButton] set.
+  final double floatingActionButtonScale;
+}
+
+class _ScaffoldGeometryNotifier extends ValueNotifier<ScaffoldGeometry> {
+  _ScaffoldGeometryNotifier(ScaffoldGeometry geometry, this.context)
+    : assert (context != null),
+      super(geometry);
+
+  final BuildContext context;
+
+  @override
+  ScaffoldGeometry get value {
+    assert(() {
+      final RenderObject renderObject = context.findRenderObject();
+      if (renderObject == null || !renderObject.owner.debugDoingPaint)
+        throw new FlutterError(
+            'Scaffold.geometryOf() must only be accessed during the paint phase.\n'
+            'The ScaffoldGeometry is only available during the paint phase, because\n'
+            'its value is computed during the animation and layout phases prior to painting.'
+        );
+      return true;
+    }());
+    return super.value;
+  }
+
+  void _updateWith({
+    double bottomNavigationBarTop,
+    Rect floatingActionButtonArea,
+    double floatingActionButtonScale,
+  }) {
+    final double newFloatingActionButtonScale = floatingActionButtonScale ??  super.value?.floatingActionButtonScale;
+    Rect newFloatingActionButtonArea;
+    if (newFloatingActionButtonScale != 0.0)
+      newFloatingActionButtonArea = floatingActionButtonArea ?? super.value?.floatingActionButtonArea;
+
+    value = new ScaffoldGeometry(
+      bottomNavigationBarTop: bottomNavigationBarTop ?? super.value?.bottomNavigationBarTop,
+      floatingActionButtonArea: newFloatingActionButtonArea,
+      floatingActionButtonScale: newFloatingActionButtonScale,
+    );
+  }
+}
+
 class _ScaffoldLayout extends MultiChildLayoutDelegate {
   _ScaffoldLayout({
     @required this.statusBarHeight,
     @required this.bottomViewInset,
     @required this.endPadding, // for floating action button
     @required this.textDirection,
+    @required this.geometryNotifier,
   });
 
   final double statusBarHeight;
   final double bottomViewInset;
   final double endPadding;
   final TextDirection textDirection;
+  final _ScaffoldGeometryNotifier geometryNotifier;
 
   @override
   void performLayout(Size size) {
@@ -68,10 +162,12 @@
       positionChild(_ScaffoldSlot.appBar, Offset.zero);
     }
 
+    double bottomNavigationBarTop;
     if (hasChild(_ScaffoldSlot.bottomNavigationBar)) {
       final double bottomNavigationBarHeight = layoutChild(_ScaffoldSlot.bottomNavigationBar, fullWidthConstraints).height;
       bottomWidgetsHeight += bottomNavigationBarHeight;
-      positionChild(_ScaffoldSlot.bottomNavigationBar, new Offset(0.0, math.max(0.0, bottom - bottomWidgetsHeight)));
+      bottomNavigationBarTop = math.max(0.0, bottom - bottomWidgetsHeight);
+      positionChild(_ScaffoldSlot.bottomNavigationBar, new Offset(0.0, bottomNavigationBarTop));
     }
 
     if (hasChild(_ScaffoldSlot.persistentFooter)) {
@@ -127,6 +223,7 @@
       positionChild(_ScaffoldSlot.snackBar, new Offset(0.0, contentBottom - snackBarSize.height));
     }
 
+    Rect floatingActionButtonRect;
     if (hasChild(_ScaffoldSlot.floatingActionButton)) {
       final Size fabSize = layoutChild(_ScaffoldSlot.floatingActionButton, looseConstraints);
       double fabX;
@@ -145,6 +242,7 @@
       if (bottomSheetSize.height > 0.0)
         fabY = math.min(fabY, contentBottom - bottomSheetSize.height - fabSize.height / 2.0);
       positionChild(_ScaffoldSlot.floatingActionButton, new Offset(fabX, fabY));
+      floatingActionButtonRect = new Offset(fabX, fabY) & fabSize;
     }
 
     if (hasChild(_ScaffoldSlot.statusBar)) {
@@ -161,6 +259,11 @@
       layoutChild(_ScaffoldSlot.endDrawer, new BoxConstraints.tight(size));
       positionChild(_ScaffoldSlot.endDrawer, Offset.zero);
     }
+
+    geometryNotifier._updateWith(
+      bottomNavigationBarTop: bottomNavigationBarTop,
+      floatingActionButtonArea: floatingActionButtonRect,
+    );
   }
 
   @override
@@ -176,9 +279,11 @@
   const _FloatingActionButtonTransition({
     Key key,
     this.child,
+    this.geometryNotifier,
   }) : super(key: key);
 
   final Widget child;
+  final _ScaffoldGeometryNotifier geometryNotifier;
 
   @override
   _FloatingActionButtonTransitionState createState() => new _FloatingActionButtonTransitionState();
@@ -203,6 +308,7 @@
       parent: _previousController,
       curve: Curves.easeIn
     );
+    _previousAnimation.addListener(_onProgressChanged);
 
     _currentController = new AnimationController(
       duration: _kFloatingActionButtonSegue,
@@ -212,11 +318,18 @@
       parent: _currentController,
       curve: Curves.easeIn
     );
+    _currentAnimation.addListener(_onProgressChanged);
 
-    // If we start out with a child, have the child appear fully visible instead
-    // of animating in.
-    if (widget.child != null)
+    if (widget.child != null) {
+      // If we start out with a child, have the child appear fully visible instead
+      // of animating in.
       _currentController.value = 1.0;
+    }
+    else {
+      // If we start without a child we update the geometry object with a
+      // floating action button scale of 0, as it is not showing on the screen.
+      _updateGeometryScale(0.0);
+    }
   }
 
   @override
@@ -284,6 +397,23 @@
     }
     return new Stack(children: children);
   }
+
+  void _onProgressChanged() {
+    if (_previousAnimation.status != AnimationStatus.dismissed) {
+      _updateGeometryScale(_previousAnimation.value);
+      return;
+    }
+    if (_currentAnimation.status != AnimationStatus.dismissed) {
+      _updateGeometryScale(_currentAnimation.value);
+      return;
+    }
+  }
+
+  void _updateGeometryScale(double scale) {
+    widget.geometryNotifier._updateWith(
+      floatingActionButtonScale: scale,
+    );
+  }
 }
 
 /// Implements the basic material design visual layout structure.
@@ -514,6 +644,48 @@
     );
   }
 
+  /// Returns a [ValueListenable] for the [ScaffoldGeometry] for the closest
+  /// [Scaffold] ancestor of the given context.
+  ///
+  /// The [ValueListenable.value] is only available at paint time.
+  ///
+  /// Notifications are guaranteed to be sent before the first paint pass
+  /// with the new geometry, but there is no guarantee whether a build or
+  /// layout passes are going to happen between the notification and the next
+  /// paint pass.
+  ///
+  /// The closest [Scaffold] ancestor for the context might change, e.g when
+  /// an element is moved from one scaffold to another. For [StatefulWidget]s
+  /// using this listenable, a change of the [Scaffold] ancestor will
+  /// trigger a [State.didChangeDependencies].
+  ///
+  /// A typical pattern for listening to the scaffold geometry would be to
+  /// call [Scaffold.geometryOf] in [State.didChangeDependencies], compare the
+  /// return value with the previous listenable, if it has changed, unregister
+  /// the listener, and register a listener to the new [ScaffoldGeometry]
+  /// listenable.
+  static ValueListenable<ScaffoldGeometry> geometryOf(BuildContext context) {
+    final _ScaffoldScope scaffoldScope = context.inheritFromWidgetOfExactType(_ScaffoldScope);
+    if (scaffoldScope == null)
+      throw new FlutterError(
+        'Scaffold.geometryOf() called with a context that does not contain a Scaffold.\n'
+        'This usually happens when the context provided is from the same StatefulWidget as that '
+        'whose build function actually creates the Scaffold widget being sought.\n'
+        'There are several ways to avoid this problem. The simplest is to use a Builder to get a '
+        'context that is "under" the Scaffold. For an example of this, please see the '
+        'documentation for Scaffold.of():\n'
+        '  https://docs.flutter.io/flutter/material/Scaffold/of.html\n'
+        'A more efficient solution is to split your build function into several widgets. This '
+        'introduces a new context from which you can obtain the Scaffold. In this solution, '
+        'you would have an outer widget that creates the Scaffold populated by instances of '
+        'your new inner widgets, and then in these inner widgets you would use Scaffold.geometryOf().\n'
+        'The context used was:\n'
+        '  $context'
+      );
+
+    return scaffoldScope.geometryNotifier;
+  }
+
   /// Whether the Scaffold that most tightly encloses the given context has a
   /// drawer.
   ///
@@ -798,12 +970,21 @@
 
   // INTERNALS
 
+  _ScaffoldGeometryNotifier _geometryNotifier;
+
+  @override
+  void initState() {
+    super.initState();
+    _geometryNotifier = new _ScaffoldGeometryNotifier(null, context);
+  }
+
   @override
   void dispose() {
     _snackBarController?.dispose();
     _snackBarController = null;
     _snackBarTimer?.cancel();
     _snackBarTimer = null;
+    _geometryNotifier.dispose();
     for (_PersistentBottomSheet bottomSheet in _dismissedBottomSheets)
       bottomSheet.animationController.dispose();
     if (_currentBottomSheet != null)
@@ -970,6 +1151,7 @@
       children,
       new _FloatingActionButtonTransition(
         child: widget.floatingActionButton,
+        geometryNotifier: _geometryNotifier,
       ),
       _ScaffoldSlot.floatingActionButton,
       removeLeftPadding: true,
@@ -1044,6 +1226,7 @@
 
     return new _ScaffoldScope(
       hasDrawer: hasDrawer,
+      geometryNotifier: _geometryNotifier,
       child: new PrimaryScrollController(
         controller: _primaryScrollController,
         child: new Material(
@@ -1055,6 +1238,7 @@
               bottomViewInset: widget.resizeToAvoidBottomPadding ? mediaQuery.viewInsets.bottom : 0.0,
               endPadding: endPadding,
               textDirection: textDirection,
+              geometryNotifier: _geometryNotifier,
             ),
           ),
         ),
@@ -1161,11 +1345,13 @@
 class _ScaffoldScope extends InheritedWidget {
   const _ScaffoldScope({
     @required this.hasDrawer,
+    @required this.geometryNotifier,
     @required Widget child,
   }) : assert(hasDrawer != null),
        super(child: child);
 
   final bool hasDrawer;
+  final _ScaffoldGeometryNotifier geometryNotifier;
 
   @override
   bool updateShouldNotify(_ScaffoldScope oldWidget) {
diff --git a/packages/flutter/test/material/scaffold_test.dart b/packages/flutter/test/material/scaffold_test.dart
index 72abcfd..c312382 100644
--- a/packages/flutter/test/material/scaffold_test.dart
+++ b/packages/flutter/test/material/scaffold_test.dart
@@ -2,9 +2,10 @@
 // Use of this source code is governed by a BSD-style license that can be
 // 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/rendering.dart';
+import 'package:flutter_test/flutter_test.dart';
 
 import '../widgets/semantics_tester.dart';
 
@@ -770,4 +771,180 @@
     semantics.dispose();
   });
 
+  group('ScaffoldGeometry', () {
+    testWidgets('bottomNavigationBar', (WidgetTester tester) async {
+      final GlobalKey key = new GlobalKey();
+      await tester.pumpWidget(new MaterialApp(home: new Scaffold(
+            body: new Container(),
+            bottomNavigationBar: new ConstrainedBox(
+              key: key,
+              constraints: const BoxConstraints.expand(height: 80.0),
+              child: new GeometryListener(),
+            ),
+      )));
+
+      final RenderBox navigationBox = tester.renderObject(find.byKey(key));
+      final RenderBox appBox = tester.renderObject(find.byType(MaterialApp));
+      final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener));
+      final ScaffoldGeometry geometry = listenerState.cache.value;
+
+      expect(
+        geometry.bottomNavigationBarTop,
+        appBox.size.height - navigationBox.size.height
+      );
+    });
+
+    testWidgets('no bottomNavigationBar', (WidgetTester tester) async {
+      await tester.pumpWidget(new MaterialApp(home: new Scaffold(
+            body: new ConstrainedBox(
+              constraints: const BoxConstraints.expand(height: 80.0),
+              child: new GeometryListener(),
+            ),
+      )));
+
+      final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener));
+      final ScaffoldGeometry geometry = listenerState.cache.value;
+
+      expect(
+        geometry.bottomNavigationBarTop,
+        null
+      );
+    });
+
+    testWidgets('floatingActionButton', (WidgetTester tester) async {
+      final GlobalKey key = new GlobalKey();
+      await tester.pumpWidget(new MaterialApp(home: new Scaffold(
+            body: new Container(),
+            floatingActionButton: new FloatingActionButton(
+              key: key,
+              child: new GeometryListener(),
+              onPressed: () {},
+            ),
+      )));
+
+      final RenderBox floatingActionButtonBox = tester.renderObject(find.byKey(key));
+      final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener));
+      final ScaffoldGeometry geometry = listenerState.cache.value;
+
+      final Rect fabRect = floatingActionButtonBox.localToGlobal(Offset.zero) & floatingActionButtonBox.size;
+
+      expect(
+        geometry.floatingActionButtonArea,
+        fabRect
+      );
+      expect(
+        geometry.floatingActionButtonScale,
+        1.0
+      );
+    });
+
+    testWidgets('no floatingActionButton', (WidgetTester tester) async {
+      await tester.pumpWidget(new MaterialApp(home: new Scaffold(
+            body: new ConstrainedBox(
+              constraints: const BoxConstraints.expand(height: 80.0),
+              child: new GeometryListener(),
+            ),
+      )));
+
+      final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener));
+      final ScaffoldGeometry geometry = listenerState.cache.value;
+
+      expect(
+        geometry.floatingActionButtonScale,
+        0.0
+      );
+
+      expect(
+          geometry.floatingActionButtonArea,
+          null
+      );
+    });
+
+    testWidgets('floatingActionButton animation', (WidgetTester tester) async {
+      final GlobalKey key = new GlobalKey();
+      await tester.pumpWidget(new MaterialApp(home: new Scaffold(
+            body: new ConstrainedBox(
+              constraints: const BoxConstraints.expand(height: 80.0),
+              child: new GeometryListener(),
+            ),
+      )));
+
+      await tester.pumpWidget(new MaterialApp(home: new Scaffold(
+            body: new Container(),
+            floatingActionButton: new FloatingActionButton(
+              key: key,
+              child: new GeometryListener(),
+              onPressed: () {},
+            ),
+      )));
+
+      await tester.pump(const Duration(milliseconds: 50));
+
+      final GeometryListenerState listenerState = tester.state(find.byType(GeometryListener));
+      final ScaffoldGeometry geometry = listenerState.cache.value;
+
+      expect(
+        geometry.floatingActionButtonScale,
+        inExclusiveRange(0.0, 1.0),
+      );
+    });
+  });
+
+}
+
+class GeometryListener extends StatefulWidget {
+  @override
+  State createState() => new GeometryListenerState();
+}
+
+class GeometryListenerState extends State<GeometryListener> {
+  @override
+  Widget build(BuildContext context) {
+    return new CustomPaint(
+      painter: cache
+    );
+  }
+
+  int numNotifications = 0;
+  ValueListenable<ScaffoldGeometry> geometryListenable;
+  GeometryCachePainter cache;
+
+  @override
+  void didChangeDependencies() {
+    super.didChangeDependencies();
+    final ValueListenable<ScaffoldGeometry> newListenable = Scaffold.geometryOf(context);
+    if (geometryListenable == newListenable)
+      return;
+
+    if (geometryListenable != null)
+      geometryListenable.removeListener(onGeometryChanged);
+    
+    geometryListenable = newListenable;
+    geometryListenable.addListener(onGeometryChanged);
+    cache = new GeometryCachePainter(geometryListenable);
+  }
+
+  void onGeometryChanged() {
+    numNotifications += 1;
+  }
+}
+
+// The Scaffold.geometryOf() value is only available at paint time.
+// To fetch it for the tests we implement this CustomPainter that just
+// caches the ScaffoldGeometry value in its paint method.
+class GeometryCachePainter extends CustomPainter {
+  GeometryCachePainter(this.geometryListenable);
+
+  final ValueListenable<ScaffoldGeometry> geometryListenable;
+
+  ScaffoldGeometry value;
+  @override
+  void paint(Canvas canvas, Size size) {
+    value = geometryListenable.value;
+  }
+
+  @override
+  bool shouldRepaint(GeometryCachePainter oldDelegate) {
+    return true;
+  }
 }