Check for invalid elevations (#30215)

* Check for invalid elevation usage in the layer tree
diff --git a/packages/flutter/lib/src/rendering/binding.dart b/packages/flutter/lib/src/rendering/binding.dart
index 7b8a044..261b566 100644
--- a/packages/flutter/lib/src/rendering/binding.dart
+++ b/packages/flutter/lib/src/rendering/binding.dart
@@ -87,6 +87,17 @@
           return Future<void>.value();
         },
       );
+      registerBoolServiceExtension(
+        name: 'debugCheckElevationsEnabled',
+        getter: () async => debugCheckElevationsEnabled,
+        setter: (bool value) {
+          if (debugCheckElevationsEnabled == value) {
+            return Future<void>.value();
+          }
+          debugCheckElevationsEnabled = value;
+          return _forceRepaint();
+        }
+      );
       registerSignalServiceExtension(
         name: 'debugDumpLayerTree',
         callback: () {
diff --git a/packages/flutter/lib/src/rendering/debug.dart b/packages/flutter/lib/src/rendering/debug.dart
index ebdded6..86a3841 100644
--- a/packages/flutter/lib/src/rendering/debug.dart
+++ b/packages/flutter/lib/src/rendering/debug.dart
@@ -50,6 +50,44 @@
 /// Overlay a rotating set of colors when repainting text in checked mode.
 bool debugRepaintTextRainbowEnabled = false;
 
+/// Causes [PhysicalModelLayer]s to paint a red rectangle around themselves if
+/// they are overlapping and painted out of order with regard to their elevation.
+///
+/// Android and iOS will show the last painted layer on top, whereas Fuchsia
+/// will show the layer with the highest elevation on top.
+///
+/// For example, a rectangular elevation at 3.0 that is painted before an
+/// overlapping rectangular elevation at 2.0 would render this way on Android
+/// and iOS (with fake shadows):
+/// ```
+/// ┌───────────────────┐
+/// │                   │
+/// │      3.0          │
+/// │            ┌───────────────────┐
+/// │            │                   │
+/// └────────────│                   │
+///              │        2.0        │
+///              │                   │
+///              └───────────────────┘
+/// ```
+///
+/// But this way on Fuchsia (with real shadows):
+/// ```
+/// ┌───────────────────┐
+/// │                   │
+/// │      3.0          │
+/// │                   │────────────┐
+/// │                   │            │
+/// └───────────────────┘            │
+///              │         2.0       │
+///              │                   │
+///              └───────────────────┘
+/// ```
+///
+/// This check helps developers that want a consistent look and feel detect
+/// where this inconsistency would occur.
+bool debugCheckElevationsEnabled = false;
+
 /// The current color to overlay when repainting a layer.
 ///
 /// This is used by painting debug code that implements
diff --git a/packages/flutter/lib/src/rendering/layer.dart b/packages/flutter/lib/src/rendering/layer.dart
index c51835b..31cb333 100644
--- a/packages/flutter/lib/src/rendering/layer.dart
+++ b/packages/flutter/lib/src/rendering/layer.dart
@@ -4,7 +4,8 @@
 
 import 'dart:async';
 import 'dart:collection';
-import 'dart:ui' as ui show EngineLayer, Image, ImageFilter, Picture, Scene, SceneBuilder;
+import 'dart:ui' as ui show EngineLayer, Image, ImageFilter, PathMetric,
+                            Picture, PictureRecorder, Scene, SceneBuilder;
 
 import 'package:flutter/foundation.dart';
 import 'package:flutter/painting.dart';
@@ -503,6 +504,100 @@
     return child == equals;
   }
 
+  PictureLayer _highlightConflictingLayer(PhysicalModelLayer child) {
+    final ui.PictureRecorder recorder = ui.PictureRecorder();
+    final Canvas canvas = Canvas(recorder);
+    canvas.drawPath(
+      child.clipPath,
+      Paint()
+        ..color = const Color(0xFFAA0000)
+        ..style = PaintingStyle.stroke
+        // The elevation may be 0 or otherwise too small to notice.
+        // Adding 10 to it makes it more visually obvious.
+        ..strokeWidth = child.elevation + 10.0,
+    );
+    final PictureLayer pictureLayer = PictureLayer(child.clipPath.getBounds())
+      ..picture = recorder.endRecording()
+      ..debugCreator = child;
+    child.append(pictureLayer);
+    return pictureLayer;
+  }
+
+  List<PictureLayer> _processConflictingPhysicalLayers(PhysicalModelLayer predecessor, PhysicalModelLayer child) {
+    FlutterError.reportError(FlutterErrorDetails(
+      exception: FlutterError('Painting order is out of order with respect to elevation.\n'
+                              'See https://api.flutter.dev/flutter/rendering/debugCheckElevations.html '
+                              'for more details.'),
+      library: 'rendering library',
+      context: 'during compositing',
+      informationCollector: (StringBuffer buffer) {
+        buffer.writeln('Attempted to composite layer:');
+        buffer.writeln(child);
+        buffer.writeln('after layer:');
+        buffer.writeln(predecessor);
+        buffer.writeln('which occupies the same area at a higher elevation.');
+      }
+    ));
+    return <PictureLayer>[
+      _highlightConflictingLayer(predecessor),
+      _highlightConflictingLayer(child),
+    ];
+  }
+
+  /// Checks that no [PhysicalModelLayer] would paint after another overlapping
+  /// [PhysicalModelLayer] that has a higher elevation.
+  ///
+  /// Returns a list of [PictureLayer] objects it added to the tree to highlight
+  /// bad nodes. These layers should be removed from the tree after building the
+  /// [Scene].
+  List<PictureLayer> _debugCheckElevations() {
+    final List<PhysicalModelLayer> physicalModelLayers = depthFirstIterateChildren().whereType<PhysicalModelLayer>().toList();
+    final List<PictureLayer> addedLayers = <PictureLayer>[];
+
+    for (int i = 0; i < physicalModelLayers.length; i++) {
+      final PhysicalModelLayer physicalModelLayer = physicalModelLayers[i];
+      assert(
+        physicalModelLayer.lastChild?.debugCreator != physicalModelLayer,
+        'debugCheckElevations has either already visited this layer or failed '
+        'to remove the added picture from it.',
+      );
+      double accumulatedElevation = physicalModelLayer.elevation;
+      Layer ancestor = physicalModelLayer.parent;
+      while (ancestor != null) {
+        if (ancestor is PhysicalModelLayer) {
+          accumulatedElevation += ancestor.elevation;
+        }
+        ancestor = ancestor.parent;
+      }
+      for (int j = 0; j <= i; j++) {
+        final PhysicalModelLayer predecessor = physicalModelLayers[j];
+        double predecessorAccumulatedElevation = predecessor.elevation;
+        ancestor = predecessor.parent;
+        while (ancestor != null) {
+          if (ancestor == predecessor) {
+            continue;
+          }
+          if (ancestor is PhysicalModelLayer) {
+            predecessorAccumulatedElevation += ancestor.elevation;
+          }
+          ancestor = ancestor.parent;
+        }
+        if (predecessorAccumulatedElevation <= accumulatedElevation) {
+          continue;
+        }
+        final Path intersection = Path.combine(
+          PathOperation.intersect,
+          predecessor._debugTransformedClipPath,
+          physicalModelLayer._debugTransformedClipPath,
+        );
+        if (intersection != null && intersection.computeMetrics().any((ui.PathMetric metric) => metric.length > 0)) {
+          addedLayers.addAll(_processConflictingPhysicalLayers(predecessor, physicalModelLayer));
+        }
+      }
+    }
+    return addedLayers;
+  }
+
   @override
   void updateSubtreeNeedsAddToScene() {
     super.updateSubtreeNeedsAddToScene();
@@ -679,6 +774,23 @@
     assert(transform != null);
   }
 
+  /// Returns the descendants of this layer in depth first order.
+  @visibleForTesting
+  List<Layer> depthFirstIterateChildren() {
+    if (firstChild == null)
+      return <Layer>[];
+    final List<Layer> children = <Layer>[];
+    Layer child = firstChild;
+    while(child != null) {
+      children.add(child);
+      if (child is ContainerLayer) {
+        children.addAll(child.depthFirstIterateChildren());
+      }
+      child = child.nextSibling;
+    }
+    return children;
+  }
+
   @override
   List<DiagnosticsNode> debugDescribeChildren() {
     final List<DiagnosticsNode> children = <DiagnosticsNode>[];
@@ -744,9 +856,29 @@
   /// Consider this layer as the root and build a scene (a tree of layers)
   /// in the engine.
   ui.Scene buildScene(ui.SceneBuilder builder) {
+    List<PictureLayer> temporaryLayers;
+    assert(() {
+      if (debugCheckElevationsEnabled) {
+        temporaryLayers = _debugCheckElevations();
+      }
+      return true;
+    }());
     updateSubtreeNeedsAddToScene();
     addToScene(builder);
-    return builder.build();
+    final ui.Scene scene = builder.build();
+    assert(() {
+      // We should remove any layers that got added to highlight the incorrect
+      // PhysicalModelLayers. If we don't, we'll end up adding duplicate layers
+      // or potentially leaving a physical model that is now correct highlighted
+      // in red.
+      if (temporaryLayers != null) {
+        for (PictureLayer temporaryLayer in temporaryLayers) {
+          temporaryLayer.remove();
+        }
+      }
+      return true;
+    }());
+    return scene;
   }
 
   @override
@@ -1090,7 +1222,12 @@
   void applyTransform(Layer child, Matrix4 transform) {
     assert(child != null);
     assert(transform != null);
-    transform.multiply(_lastEffectiveTransform);
+    assert(_lastEffectiveTransform != null || this.transform != null);
+    if (_lastEffectiveTransform == null) {
+      transform.multiply(this.transform);
+    } else {
+      transform.multiply(_lastEffectiveTransform);
+    }
   }
 
   @override
@@ -1309,6 +1446,16 @@
     }
   }
 
+  Path get _debugTransformedClipPath {
+    ContainerLayer ancestor = parent;
+    final Matrix4 matrix = Matrix4.identity();
+    while (ancestor != null && ancestor.parent != null) {
+      ancestor.applyTransform(this, matrix);
+      ancestor = ancestor.parent;
+    }
+    return clipPath.transform(matrix.storage);
+  }
+
   /// {@macro flutter.widgets.Clip}
   Clip get clipBehavior => _clipBehavior;
   Clip _clipBehavior;
diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart
index 4062eb6..f0489d7 100644
--- a/packages/flutter/lib/src/rendering/proxy_box.dart
+++ b/packages/flutter/lib/src/rendering/proxy_box.dart
@@ -1709,6 +1709,10 @@
         color: color,
         shadowColor: shadowColor,
       );
+      assert(() {
+        physicalModel.debugCreator = debugCreator;
+        return true;
+      }());
       context.pushLayer(physicalModel, super.paint, offset, childPaintBounds: offsetBounds);
     }
   }
@@ -1799,6 +1803,10 @@
         color: color,
         shadowColor: shadowColor,
       );
+      assert(() {
+        physicalModel.debugCreator = debugCreator;
+        return true;
+      }());
       context.pushLayer(physicalModel, super.paint, offset, childPaintBounds: offsetBounds);
     }
   }
diff --git a/packages/flutter/test/foundation/service_extensions_test.dart b/packages/flutter/test/foundation/service_extensions_test.dart
index 5fac145..9edf792 100644
--- a/packages/flutter/test/foundation/service_extensions_test.dart
+++ b/packages/flutter/test/foundation/service_extensions_test.dart
@@ -117,7 +117,7 @@
 void main() {
   final List<String> console = <String>[];
 
-  test('Service extensions - pretest', () async {
+  setUpAll(() async {
     binding = TestServiceExtensionsBinding();
     expect(binding.frameScheduled, isTrue);
 
@@ -142,10 +142,25 @@
     };
   });
 
+  tearDownAll(() async {
+    // See widget_inspector_test.dart for tests of the ext.flutter.inspector
+    // service extensions included in this count.
+    int widgetInspectorExtensionCount = 15;
+    if (WidgetInspectorService.instance.isWidgetCreationTracked()) {
+      // Some inspector extensions are only exposed if widget creation locations
+      // are tracked.
+      widgetInspectorExtensionCount += 2;
+    }
+
+    // If you add a service extension... TEST IT! :-)
+    // ...then increment this number.
+    expect(binding.extensions.length, 26 + widgetInspectorExtensionCount);
+
+    expect(console, isEmpty);
+    debugPrint = debugPrintThrottled;
+  });
+
   // The following list is alphabetical, one test per extension.
-  //
-  // The order doesn't really matter except that the pretest and posttest tests
-  // must be first and last respectively.
 
   test('Service extensions - debugAllowBanner', () async {
     Map<String, dynamic> result;
@@ -170,6 +185,34 @@
     expect(binding.frameScheduled, isFalse);
   });
 
+  test('Service extensions - debugCheckElevationsEnabled', () async {
+    expect(binding.frameScheduled, isFalse);
+    expect(debugCheckElevationsEnabled, false);
+
+    bool lastValue = false;
+    Future<void> _updateAndCheck(bool newValue) async {
+      Map<String, dynamic> result;
+      binding.testExtension(
+        'debugCheckElevationsEnabled',
+        <String, String>{'enabled': '$newValue'}
+      ).then((Map<String, dynamic> answer) => result = answer);
+      await binding.flushMicrotasks();
+      expect(binding.frameScheduled, lastValue != newValue);
+      await binding.doFrame();
+      await binding.flushMicrotasks();
+      expect(result, <String, String>{'enabled': '$newValue'});
+      expect(debugCheckElevationsEnabled, newValue);
+      lastValue = newValue;
+    }
+
+    await _updateAndCheck(false);
+    await _updateAndCheck(true);
+    await _updateAndCheck(true);
+    await _updateAndCheck(false);
+    await _updateAndCheck(false);
+    expect(binding.frameScheduled, isFalse);
+  });
+
   test('Service extensions - debugDumpApp', () async {
     Map<String, dynamic> result;
 
@@ -617,22 +660,4 @@
     expect(trace, contains('package:test_api/test_api.dart,::,test\n'));
     expect(trace, contains('service_extensions_test.dart,::,main\n'));
   });
-
-  test('Service extensions - posttest', () async {
-    // See widget_inspector_test.dart for tests of the ext.flutter.inspector
-    // service extensions included in this count.
-    int widgetInspectorExtensionCount = 15;
-    if (WidgetInspectorService.instance.isWidgetCreationTracked()) {
-      // Some inspector extensions are only exposed if widget creation locations
-      // are tracked.
-      widgetInspectorExtensionCount += 2;
-    }
-
-    // If you add a service extension... TEST IT! :-)
-    // ...then increment this number.
-    expect(binding.extensions.length, 25 + widgetInspectorExtensionCount);
-
-    expect(console, isEmpty);
-    debugPrint = debugPrintThrottled;
-  });
 }
diff --git a/packages/flutter/test/rendering/layers_test.dart b/packages/flutter/test/rendering/layers_test.dart
index 84f34bb..72a94f1 100644
--- a/packages/flutter/test/rendering/layers_test.dart
+++ b/packages/flutter/test/rendering/layers_test.dart
@@ -4,6 +4,7 @@
 
 import 'dart:ui';
 
+import 'package:flutter/foundation.dart';
 import 'package:flutter/rendering.dart';
 import 'package:flutter_test/flutter_test.dart';
 
@@ -112,6 +113,56 @@
     expect(followerLayer.debugSubtreeNeedsAddToScene, true);
   });
 
+  test('depthFirstIterateChildren', () {
+    final ContainerLayer a = ContainerLayer();
+    final ContainerLayer b = ContainerLayer();
+    final ContainerLayer c = ContainerLayer();
+    final ContainerLayer d = ContainerLayer();
+    final ContainerLayer e = ContainerLayer();
+    final ContainerLayer f = ContainerLayer();
+    final ContainerLayer g = ContainerLayer();
+
+    final PictureLayer h = PictureLayer(Rect.zero);
+    final PictureLayer i = PictureLayer(Rect.zero);
+    final PictureLayer j = PictureLayer(Rect.zero);
+
+    // The tree is like the following:
+    //        a____
+    //       /     \
+    //      b___    c
+    //     / \  \   |
+    //    d   e  f  g
+    //   / \        |
+    //  h   i       j
+    a.append(b);
+    a.append(c);
+    b.append(d);
+    b.append(e);
+    b.append(f);
+    d.append(h);
+    d.append(i);
+    c.append(g);
+    g.append(j);
+
+    expect(
+      a.depthFirstIterateChildren(),
+      <Layer>[b, d, h, i, e, f, c, g, j],
+    );
+
+    d.remove();
+    //        a____
+    //       /     \
+    //      b___    c
+    //       \  \   |
+    //        e  f  g
+    //              |
+    //              j
+    expect(
+      a.depthFirstIterateChildren(),
+      <Layer>[b, e, f, c, g, j],
+    );
+  });
+
   void checkNeedsAddToScene(Layer layer, void mutateCallback()) {
     layer.debugMarkClean();
     layer.updateSubtreeNeedsAddToScene();
@@ -239,4 +290,170 @@
       layer.shadowColor = const Color(1);
     });
   });
+
+  group('PhysicalModelLayer checks elevations', () {
+    /// Adds the layers to a container where A paints before B.
+    ///
+    /// Expects there to be `expectedErrorCount` errors.  Checking elevations is
+    /// enabled by default.
+    void _testConflicts(
+      PhysicalModelLayer layerA,
+      PhysicalModelLayer layerB, {
+      @required int expectedErrorCount,
+      bool enableCheck = true,
+    }) {
+      assert(expectedErrorCount != null);
+      assert(enableCheck || expectedErrorCount == 0, 'Cannot disable check and expect non-zero error count.');
+      final OffsetLayer container = OffsetLayer();
+      container.append(layerA);
+      container.append(layerB);
+      debugCheckElevationsEnabled = enableCheck;
+      debugDisableShadows = false;
+      int errors = 0;
+      if (enableCheck) {
+        FlutterError.onError = (FlutterErrorDetails details) {
+          errors++;
+        };
+      }
+      container.buildScene(SceneBuilder());
+      expect(errors, expectedErrorCount);
+      debugCheckElevationsEnabled = false;
+    }
+
+    // Tests:
+    //
+    //  ─────────────                    (LayerA, paints first)
+    //      │     ─────────────          (LayerB, paints second)
+    //      │          │
+    // ───────────────────────────
+    test('Overlapping layers at wrong elevation', () {
+      final PhysicalModelLayer layerA = PhysicalModelLayer(
+        clipPath: Path()..addRect(Rect.fromLTWH(0, 0, 20, 20)),
+        elevation: 3.0,
+        color: const Color(0),
+        shadowColor: const Color(0),
+      );
+      final PhysicalModelLayer layerB =PhysicalModelLayer(
+        clipPath: Path()..addRect(Rect.fromLTWH(10, 10, 20, 20)),
+        elevation: 2.0,
+        color: const Color(0),
+        shadowColor: const Color(0),
+      );
+      _testConflicts(layerA, layerB, expectedErrorCount: 1);
+    });
+
+    // Tests:
+    //
+    //  ─────────────                    (LayerA, paints first)
+    //      │     ─────────────          (LayerB, paints second)
+    //      │         │
+    // ───────────────────────────
+    //
+    // Causes no error if check is disabled.
+    test('Overlapping layers at wrong elevation, check disabled', () {
+      final PhysicalModelLayer layerA = PhysicalModelLayer(
+        clipPath: Path()..addRect(Rect.fromLTWH(0, 0, 20, 20)),
+        elevation: 3.0,
+        color: const Color(0),
+        shadowColor: const Color(0),
+      );
+      final PhysicalModelLayer layerB =PhysicalModelLayer(
+        clipPath: Path()..addRect(Rect.fromLTWH(10, 10, 20, 20)),
+        elevation: 2.0,
+        color: const Color(0),
+        shadowColor: const Color(0),
+      );
+      _testConflicts(layerA, layerB, expectedErrorCount: 0, enableCheck: false);
+    });
+
+    // Tests:
+    //
+    //   ──────────                      (LayerA, paints first)
+    //        │       ───────────        (LayerB, paints second)
+    //        │            │
+    // ────────────────────────────
+    test('Non-overlapping layers at wrong elevation', () {
+      final PhysicalModelLayer layerA = PhysicalModelLayer(
+        clipPath: Path()..addRect(Rect.fromLTWH(0, 0, 20, 20)),
+        elevation: 3.0,
+        color: const Color(0),
+        shadowColor: const Color(0),
+      );
+      final PhysicalModelLayer layerB =PhysicalModelLayer(
+        clipPath: Path()..addRect(Rect.fromLTWH(20, 20, 20, 20)),
+        elevation: 2.0,
+        color: const Color(0),
+        shadowColor: const Color(0),
+      );
+      _testConflicts(layerA, layerB, expectedErrorCount: 0);
+    });
+
+    // Tests:
+    //
+    //     ───────                       (Child of A, paints second)
+    //        │
+    //   ───────────                     (LayerA, paints first)
+    //        │       ────────────       (LayerB, paints third)
+    //        │             │
+    // ────────────────────────────
+    test('Non-overlapping layers at wrong elevation, child at lower elevation', () {
+      final PhysicalModelLayer layerA = PhysicalModelLayer(
+        clipPath: Path()..addRect(Rect.fromLTWH(0, 0, 20, 20)),
+        elevation: 3.0,
+        color: const Color(0),
+        shadowColor: const Color(0),
+      );
+
+      layerA.append(PhysicalModelLayer(
+        clipPath: Path()..addRect(Rect.fromLTWH(2, 2, 10, 10)),
+        elevation: 1.0,
+        color: const Color(0),
+        shadowColor: const Color(0),
+      ));
+
+      final PhysicalModelLayer layerB =PhysicalModelLayer(
+        clipPath: Path()..addRect(Rect.fromLTWH(20, 20, 20, 20)),
+        elevation: 2.0,
+        color: const Color(0),
+        shadowColor: const Color(0),
+      );
+      _testConflicts(layerA, layerB, expectedErrorCount: 0);
+    });
+
+    // Tests:
+    //
+    //        ───────────                (Child of A, paints second, overflows)
+    //           │    ────────────       (LayerB, paints third)
+    //   ───────────       │             (LayerA, paints first)
+    //         │           │
+    //         │           │
+    // ────────────────────────────
+    //
+    // Which fails because the overflowing child overlaps something that paints
+    // after it at a lower elevation.
+    test('Child overflows parent and overlaps another physical layer', () {
+      final PhysicalModelLayer layerA = PhysicalModelLayer(
+        clipPath: Path()..addRect(Rect.fromLTWH(0, 0, 20, 20)),
+        elevation: 3.0,
+        color: const Color(0),
+        shadowColor: const Color(0),
+      );
+
+      layerA.append(PhysicalModelLayer(
+        clipPath: Path()..addRect(Rect.fromLTWH(15, 15, 25, 25)),
+        elevation: 2.0,
+        color: const Color(0),
+        shadowColor: const Color(0),
+      ));
+
+      final PhysicalModelLayer layerB =PhysicalModelLayer(
+        clipPath: Path()..addRect(Rect.fromLTWH(20, 20, 20, 20)),
+        elevation: 4.0,
+        color: const Color(0),
+        shadowColor: const Color(0),
+      );
+
+      _testConflicts(layerA, layerB, expectedErrorCount: 1);
+    });
+  });
 }
diff --git a/packages/flutter/test/widgets/physical_model_test.dart b/packages/flutter/test/widgets/physical_model_test.dart
index 5fb7711..2465d2e 100644
--- a/packages/flutter/test/widgets/physical_model_test.dart
+++ b/packages/flutter/test/widgets/physical_model_test.dart
@@ -3,6 +3,7 @@
 // found in the LICENSE file.
 
 import 'dart:io' show Platform;
+import 'dart:math' as math show pi;
 
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
@@ -69,4 +70,418 @@
       skip: !Platform.isLinux,
     );
   });
+
+  group('PhysicalModelLayer checks elevation', () {
+    Future<void> _testStackChildren(
+      WidgetTester tester,
+      List<Widget> children, {
+      @required int expectedErrorCount,
+      bool enableCheck = true,
+    }) async {
+      assert(expectedErrorCount != null);
+      if (enableCheck) {
+        debugCheckElevationsEnabled = true;
+      } else {
+        assert(expectedErrorCount == 0, 'Cannot expect errors if check is disabled.');
+      }
+      debugDisableShadows = false;
+      int count = 0;
+      final Function oldOnError = FlutterError.onError;
+      FlutterError.onError = (FlutterErrorDetails details) {
+        count++;
+      };
+      await tester.pumpWidget(Directionality(
+        textDirection: TextDirection.ltr,
+        child: Stack(
+            children: children,
+          ),
+        ),
+      );
+      FlutterError.onError = oldOnError;
+      expect(count, expectedErrorCount);
+      if (enableCheck) {
+        debugCheckElevationsEnabled = false;
+      }
+      debugDisableShadows = true;
+    }
+
+    // Tests:
+    //
+    //        ───────────             (red rect, paints second, child)
+    //              │
+    //        ───────────             (green rect, paints first)
+    //            │
+    // ────────────────────────────
+    testWidgets('entirely overlapping, direct child', (WidgetTester tester) async {
+      final List<Widget> children = <Widget>[
+        Container(
+          width: 300,
+          height: 300,
+          child: const Material(
+            elevation: 1.0,
+            color: Colors.green,
+            child: Material(
+              elevation: 2.0,
+              color: Colors.red,
+            )
+          ),
+        ),
+      ];
+
+      await _testStackChildren(tester, children, expectedErrorCount: 0);
+      expect(find.byType(Material), findsNWidgets(2));
+    });
+
+
+    // Tests:
+    //
+    //        ───────────────          (green rect, paints second)
+    //        ─────────── │            (blue rect, paints first)
+    //         │          │
+    //         │          │
+    // ────────────────────────────
+    testWidgets('entirely overlapping, correct painting order', (WidgetTester tester) async {
+      final List<Widget> children = <Widget>[
+        Container(
+          width: 300,
+          height: 300,
+          child: const Material(
+            elevation: 1.0,
+            color: Colors.green,
+          ),
+        ),
+        Container(
+          width: 300,
+          height: 300,
+          child: const Material(
+            elevation: 2.0,
+            color: Colors.blue,
+          ),
+        ),
+      ];
+
+      await _testStackChildren(tester, children, expectedErrorCount: 0);
+      expect(find.byType(Material), findsNWidgets(2));
+    });
+
+
+    // Tests:
+    //
+    //        ───────────────          (green rect, paints first)
+    //         │  ───────────          (blue rect, paints second)
+    //         │        │
+    //         │        │
+    // ────────────────────────────
+    testWidgets('entirely overlapping, wrong painting order', (WidgetTester tester) async {
+      final List<Widget> children = <Widget>[
+        Container(
+          width: 300,
+          height: 300,
+          child: const Material(
+            elevation: 2.0,
+            color: Colors.green,
+          ),
+        ),
+        Container(
+          width: 300,
+          height: 300,
+          child: const Material(
+            elevation: 1.0,
+            color: Colors.blue,
+          ),
+        ),
+      ];
+
+      await _testStackChildren(tester, children, expectedErrorCount: 1);
+      expect(find.byType(Material), findsNWidgets(2));
+    });
+
+
+    // Tests:
+    //
+    //  ───────────────                      (brown rect, paints first)
+    //         │        ───────────          (red circle, paints second)
+    //         │            │
+    //         │            │
+    // ────────────────────────────
+    testWidgets('not non-rect not overlapping, wrong painting order', (WidgetTester tester) async {
+      // These would be overlapping if we only took the rectangular bounds of the circle.
+      final List<Widget> children = <Widget>[
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(150, 150, 150, 150),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: const Material(
+              elevation: 3.0,
+              color: Colors.brown,
+            ),
+          ),
+        ),
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(20, 20, 140, 150),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: const Material(
+              elevation: 2.0,
+              color: Colors.red,
+              shape: CircleBorder()
+            ),
+          ),
+        ),
+      ];
+
+      await _testStackChildren(tester, children, expectedErrorCount: 0);
+      expect(find.byType(Material), findsNWidgets(2));
+    });
+
+    // Tests:
+    //
+    //        ───────────────          (brown rect, paints first)
+    //         │  ───────────          (red circle, paints second)
+    //         │        │
+    //         │        │
+    // ────────────────────────────
+    testWidgets('not non-rect entirely overlapping, wrong painting order', (WidgetTester tester) async {
+      final List<Widget> children = <Widget>[
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(20, 20, 140, 150),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: const Material(
+              elevation: 3.0,
+              color: Colors.brown,
+            ),
+          ),
+        ),
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(50, 50, 100, 100),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: const Material(
+              elevation: 2.0,
+              color: Colors.red,
+              shape: CircleBorder()
+            ),
+          ),
+        ),
+      ];
+
+      await _testStackChildren(tester, children, expectedErrorCount: 1);
+      expect(find.byType(Material), findsNWidgets(2));
+    });
+
+    // Tests:
+    //
+    //   ───────────────                 (brown rect, paints first)
+    //         │      ────────────       (red circle, paints second)
+    //         │           │
+    //         │           │
+    // ────────────────────────────
+    testWidgets('non-rect partially overlapping, wrong painting order', (WidgetTester tester) async {
+      final List<Widget> children = <Widget>[
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(150, 150, 150, 150),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: const Material(
+              elevation: 3.0,
+              color: Colors.brown,
+            ),
+          ),
+        ),
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(30, 20, 150, 150),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: const Material(
+              elevation: 2.0,
+              color: Colors.red,
+              shape: CircleBorder()
+            ),
+          ),
+        ),
+      ];
+
+      await _testStackChildren(tester, children, expectedErrorCount: 1);
+      expect(find.byType(Material), findsNWidgets(2));
+    });
+
+    // Tests:
+    //
+    //   ───────────────                 (green rect, paints second, overlaps red rect)
+    //         │
+    //         │
+    //   ──────────────────────────      (brown and red rects, overlapping but same elevation, paint first and third)
+    //         │           │
+    // ────────────────────────────
+    //
+    // Fails because the green rect overlaps the
+    testWidgets('child partially overlapping, wrong painting order', (WidgetTester tester) async {
+      final List<Widget> children = <Widget>[
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(150, 150, 150, 150),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: const Material(
+              elevation: 1.0,
+              color: Colors.brown,
+              child: Padding(
+                padding: EdgeInsets.all(30.0),
+                child: Material(
+                  elevation: 2.0,
+                  color: Colors.green,
+                ),
+              ),
+            ),
+          ),
+        ),
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(30, 20, 180, 180),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: const Material(
+              elevation: 1.0,
+              color: Colors.red,
+            ),
+          ),
+        ),
+      ];
+
+      await _testStackChildren(tester, children, expectedErrorCount: 1);
+      expect(find.byType(Material), findsNWidgets(3));
+    });
+
+    // Tests:
+    //
+    //   ───────────────                 (brown rect, paints first)
+    //         │      ────────────       (red circle, paints second)
+    //         │           │
+    //         │           │
+    // ────────────────────────────
+    testWidgets('non-rect partially overlapping, wrong painting order, check disabled', (WidgetTester tester) async {
+       final List<Widget> children = <Widget>[
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(150, 150, 150, 150),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: const Material(
+              elevation: 3.0,
+              color: Colors.brown,
+            ),
+          ),
+        ),
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(30, 20, 150, 150),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: const Material(
+              elevation: 2.0,
+              color: Colors.red,
+              shape: CircleBorder()
+            ),
+          ),
+        ),
+      ];
+
+      await _testStackChildren(
+        tester,
+        children,
+        expectedErrorCount: 0,
+        enableCheck: false,
+      );
+      expect(find.byType(Material), findsNWidgets(2));
+    });
+
+    // Tests:
+    //
+    //   ────────────                    (brown rect, paints first, rotated but doesn't overlap)
+    //         │      ────────────       (red circle, paints second)
+    //         │           │
+    //         │           │
+    // ────────────────────────────
+    testWidgets('with a RenderTransform, non-overlapping', (WidgetTester tester) async {
+
+      final List<Widget> children = <Widget>[
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(140, 100, 140, 150),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: Transform.rotate(
+              angle: math.pi / 180 * 15,
+              child: const Material(
+                elevation: 3.0,
+                color: Colors.brown,
+              ),
+            ),
+          ),
+        ),
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(50, 50, 100, 100),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: const Material(
+                elevation: 2.0,
+                color: Colors.red,
+                shape: CircleBorder()),
+          ),
+        ),
+      ];
+
+      await _testStackChildren(tester, children, expectedErrorCount: 0);
+      expect(find.byType(Material), findsNWidgets(2));
+    });
+
+    // Tests:
+    //
+    //   ──────────────                  (brown rect, paints first, rotated so it overlaps)
+    //         │      ────────────       (red circle, paints second)
+    //         │           │
+    //         │           │
+    // ────────────────────────────
+    // This would be fine without the rotation.
+    testWidgets('with a RenderTransform, overlapping', (WidgetTester tester) async {
+      final List<Widget> children = <Widget>[
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(140, 100, 140, 150),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: Transform.rotate(
+              angle: math.pi / 180 * 8,
+              child: const Material(
+                elevation: 3.0,
+                color: Colors.brown,
+              ),
+            ),
+          ),
+        ),
+        Positioned.fromRect(
+          rect: Rect.fromLTWH(50, 50, 100, 100),
+          child: Container(
+            width: 300,
+            height: 300,
+            child: const Material(
+                elevation: 2.0,
+                color: Colors.red,
+                shape: CircleBorder()),
+          ),
+        ),
+      ];
+
+      await _testStackChildren(tester, children, expectedErrorCount: 1);
+      expect(find.byType(Material), findsNWidgets(2));
+    });
+  });
 }
diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart
index 170f563..69491b8 100644
--- a/packages/flutter_tools/lib/src/resident_runner.dart
+++ b/packages/flutter_tools/lib/src/resident_runner.dart
@@ -272,6 +272,11 @@
       await view.uiIsolate.flutterToggleDebugPaintSizeEnabled();
   }
 
+  Future<void> toggleDebugCheckElevationsEnabled() async {
+    for (FlutterView view in views)
+      await view.uiIsolate.flutterToggleDebugCheckElevationsEnabled();
+  }
+
   Future<void> debugTogglePerformanceOverlayOverride() async {
     for (FlutterView view in views)
       await view.uiIsolate.flutterTogglePerformanceOverlayOverride();
@@ -620,6 +625,12 @@
       await device.toggleDebugPaintSizeEnabled();
   }
 
+  Future<void> _debugToggleDebugCheckElevationsEnabled() async {
+    await refreshViews();
+    for (FlutterDevice device in flutterDevices)
+      await device.toggleDebugCheckElevationsEnabled();
+  }
+
   Future<void> _debugTogglePerformanceOverlayOverride() async {
     await refreshViews();
     for (FlutterDevice device in flutterDevices)
@@ -871,6 +882,9 @@
     } else if (lower == 'd') {
       await detach();
       return true;
+    } else if (lower == 'z') {
+      await _debugToggleDebugCheckElevationsEnabled();
+      return true;
     }
 
     return false;
@@ -962,6 +976,7 @@
         printStatus('To toggle the widget inspector (WidgetsApp.showWidgetInspectorOverride), press "i".');
         printStatus('To toggle the display of construction lines (debugPaintSizeEnabled), press "p".');
         printStatus('To simulate different operating systems, (defaultTargetPlatform), press "o".');
+        printStatus('To toggle the elevation checker, press "z".');
       } else {
         printStatus('To dump the accessibility tree (debugDumpSemantics), press "S" (for traversal order) or "U" (for inverse hit test order).');
       }
diff --git a/packages/flutter_tools/lib/src/vmservice.dart b/packages/flutter_tools/lib/src/vmservice.dart
index 7adcd72..6096480 100644
--- a/packages/flutter_tools/lib/src/vmservice.dart
+++ b/packages/flutter_tools/lib/src/vmservice.dart
@@ -1283,6 +1283,8 @@
 
   Future<Map<String, dynamic>> flutterToggleDebugPaintSizeEnabled() => _flutterToggle('debugPaint');
 
+  Future<Map<String, dynamic>> flutterToggleDebugCheckElevationsEnabled() => _flutterToggle('debugCheckElevationsEnabled');
+
   Future<Map<String, dynamic>> flutterTogglePerformanceOverlayOverride() => _flutterToggle('showPerformanceOverlay');
 
   Future<Map<String, dynamic>> flutterToggleWidgetInspector() => _flutterToggle('inspector.show');