diff --git a/packages/flutter/lib/src/rendering/binding.dart b/packages/flutter/lib/src/rendering/binding.dart index 7b8a044f181..261b5669fbc 100644 --- a/packages/flutter/lib/src/rendering/binding.dart +++ b/packages/flutter/lib/src/rendering/binding.dart @@ -87,6 +87,17 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture return Future.value(); }, ); + registerBoolServiceExtension( + name: 'debugCheckElevationsEnabled', + getter: () async => debugCheckElevationsEnabled, + setter: (bool value) { + if (debugCheckElevationsEnabled == value) { + return Future.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 ebdded69281..86a384111bd 100644 --- a/packages/flutter/lib/src/rendering/debug.dart +++ b/packages/flutter/lib/src/rendering/debug.dart @@ -50,6 +50,44 @@ bool debugRepaintRainbowEnabled = false; /// 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 c51835b396b..31cb333dd97 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 @@ class ContainerLayer extends Layer { 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 _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 [ + _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 _debugCheckElevations() { + final List physicalModelLayers = depthFirstIterateChildren().whereType().toList(); + final List addedLayers = []; + + 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 @@ class ContainerLayer extends Layer { assert(transform != null); } + /// Returns the descendants of this layer in depth first order. + @visibleForTesting + List depthFirstIterateChildren() { + if (firstChild == null) + return []; + final List children = []; + Layer child = firstChild; + while(child != null) { + children.add(child); + if (child is ContainerLayer) { + children.addAll(child.depthFirstIterateChildren()); + } + child = child.nextSibling; + } + return children; + } + @override List debugDescribeChildren() { final List children = []; @@ -744,9 +856,29 @@ class OffsetLayer extends ContainerLayer { /// Consider this layer as the root and build a scene (a tree of layers) /// in the engine. ui.Scene buildScene(ui.SceneBuilder builder) { + List 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 @@ class TransformLayer extends OffsetLayer { 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 @@ class PhysicalModelLayer extends ContainerLayer { } } + 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 4062eb6af31..f0489d77c7a 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -1709,6 +1709,10 @@ class RenderPhysicalModel extends _RenderPhysicalModelBase { color: color, shadowColor: shadowColor, ); + assert(() { + physicalModel.debugCreator = debugCreator; + return true; + }()); context.pushLayer(physicalModel, super.paint, offset, childPaintBounds: offsetBounds); } } @@ -1799,6 +1803,10 @@ class RenderPhysicalShape extends _RenderPhysicalModelBase { 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 5fac145f336..9edf792c37d 100644 --- a/packages/flutter/test/foundation/service_extensions_test.dart +++ b/packages/flutter/test/foundation/service_extensions_test.dart @@ -117,7 +117,7 @@ Future> hasReassemble(Future> pendingR void main() { final List console = []; - test('Service extensions - pretest', () async { + setUpAll(() async { binding = TestServiceExtensionsBinding(); expect(binding.frameScheduled, isTrue); @@ -142,10 +142,25 @@ void main() { }; }); + 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 result; @@ -170,6 +185,34 @@ void main() { expect(binding.frameScheduled, isFalse); }); + test('Service extensions - debugCheckElevationsEnabled', () async { + expect(binding.frameScheduled, isFalse); + expect(debugCheckElevationsEnabled, false); + + bool lastValue = false; + Future _updateAndCheck(bool newValue) async { + Map result; + binding.testExtension( + 'debugCheckElevationsEnabled', + {'enabled': '$newValue'} + ).then((Map answer) => result = answer); + await binding.flushMicrotasks(); + expect(binding.frameScheduled, lastValue != newValue); + await binding.doFrame(); + await binding.flushMicrotasks(); + expect(result, {'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 result; @@ -617,22 +660,4 @@ void main() { 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 84f34bb6dac..72a94f1755f 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 @@ void main() { 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(), + [b, d, h, i, e, f, c, g, j], + ); + + d.remove(); + // a____ + // / \ + // b___ c + // \ \ | + // e f g + // | + // j + expect( + a.depthFirstIterateChildren(), + [b, e, f, c, g, j], + ); + }); + void checkNeedsAddToScene(Layer layer, void mutateCallback()) { layer.debugMarkClean(); layer.updateSubtreeNeedsAddToScene(); @@ -239,4 +290,170 @@ void main() { 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 5fb77111275..2465d2e4e95 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 @@ void main() { skip: !Platform.isLinux, ); }); + + group('PhysicalModelLayer checks elevation', () { + Future _testStackChildren( + WidgetTester tester, + List 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 children = [ + 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 children = [ + 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 children = [ + 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 children = [ + 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 children = [ + 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 children = [ + 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 children = [ + 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 children = [ + 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 children = [ + 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 children = [ + 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 170f5639ea9..69491b8a0ba 100644 --- a/packages/flutter_tools/lib/src/resident_runner.dart +++ b/packages/flutter_tools/lib/src/resident_runner.dart @@ -272,6 +272,11 @@ class FlutterDevice { await view.uiIsolate.flutterToggleDebugPaintSizeEnabled(); } + Future toggleDebugCheckElevationsEnabled() async { + for (FlutterView view in views) + await view.uiIsolate.flutterToggleDebugCheckElevationsEnabled(); + } + Future debugTogglePerformanceOverlayOverride() async { for (FlutterView view in views) await view.uiIsolate.flutterTogglePerformanceOverlayOverride(); @@ -620,6 +625,12 @@ abstract class ResidentRunner { await device.toggleDebugPaintSizeEnabled(); } + Future _debugToggleDebugCheckElevationsEnabled() async { + await refreshViews(); + for (FlutterDevice device in flutterDevices) + await device.toggleDebugCheckElevationsEnabled(); + } + Future _debugTogglePerformanceOverlayOverride() async { await refreshViews(); for (FlutterDevice device in flutterDevices) @@ -871,6 +882,9 @@ abstract class ResidentRunner { } else if (lower == 'd') { await detach(); return true; + } else if (lower == 'z') { + await _debugToggleDebugCheckElevationsEnabled(); + return true; } return false; @@ -962,6 +976,7 @@ abstract class ResidentRunner { 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 7adcd72f4e4..6096480b539 100644 --- a/packages/flutter_tools/lib/src/vmservice.dart +++ b/packages/flutter_tools/lib/src/vmservice.dart @@ -1283,6 +1283,8 @@ class Isolate extends ServiceObjectOwner { Future> flutterToggleDebugPaintSizeEnabled() => _flutterToggle('debugPaint'); + Future> flutterToggleDebugCheckElevationsEnabled() => _flutterToggle('debugCheckElevationsEnabled'); + Future> flutterTogglePerformanceOverlayOverride() => _flutterToggle('showPerformanceOverlay'); Future> flutterToggleWidgetInspector() => _flutterToggle('inspector.show');