mirror of
https://github.com/flutter/flutter
synced 2024-10-13 19:52:53 +00:00
Check for invalid elevations (#30215)
* Check for invalid elevation usage in the layer tree
This commit is contained in:
parent
83ddd98849
commit
d2790bd2bb
|
@ -87,6 +87,17 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture
|
|||
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: () {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<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 @@ class ContainerLayer extends Layer {
|
|||
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 @@ 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<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 @@ 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;
|
||||
|
|
|
@ -1709,6 +1709,10 @@ class RenderPhysicalModel extends _RenderPhysicalModelBase<RRect> {
|
|||
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<Path> {
|
|||
color: color,
|
||||
shadowColor: shadowColor,
|
||||
);
|
||||
assert(() {
|
||||
physicalModel.debugCreator = debugCreator;
|
||||
return true;
|
||||
}());
|
||||
context.pushLayer(physicalModel, super.paint, offset, childPaintBounds: offsetBounds);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -117,7 +117,7 @@ Future<Map<String, dynamic>> hasReassemble(Future<Map<String, dynamic>> pendingR
|
|||
void main() {
|
||||
final List<String> console = <String>[];
|
||||
|
||||
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<String, dynamic> 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<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 @@ 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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
<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 @@ 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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<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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -272,6 +272,11 @@ class FlutterDevice {
|
|||
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 @@ abstract class ResidentRunner {
|
|||
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 @@ 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).');
|
||||
}
|
||||
|
|
|
@ -1283,6 +1283,8 @@ class Isolate extends ServiceObjectOwner {
|
|||
|
||||
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');
|
||||
|
|
Loading…
Reference in a new issue