diff --git a/lib/web_ui/dev/goldens_lock.yaml b/lib/web_ui/dev/goldens_lock.yaml index ac002b153ebd9..1b80d23509540 100644 --- a/lib/web_ui/dev/goldens_lock.yaml +++ b/lib/web_ui/dev/goldens_lock.yaml @@ -1,2 +1,2 @@ repository: https://github.com/flutter/goldens.git -revision: bdb442c42588b25c657779c78523822e349742d5 +revision: b85f9093e6bc6d4e7cbb7f97491667c143c4a360 diff --git a/lib/web_ui/lib/src/engine/dom_renderer.dart b/lib/web_ui/lib/src/engine/dom_renderer.dart index 891f2c84320f3..c3b90a881d362 100644 --- a/lib/web_ui/lib/src/engine/dom_renderer.dart +++ b/lib/web_ui/lib/src/engine/dom_renderer.dart @@ -182,6 +182,21 @@ class DomRenderer { } } + static void setClipPath(html.Element element, String? value) { + if (browserEngine == BrowserEngine.webkit) { + if (value == null) { + element.style.removeProperty('-webkit-clip-path'); + } else { + element.style.setProperty('-webkit-clip-path', value); + } + } + if (value == null) { + element.style.removeProperty('clip-path'); + } else { + element.style.setProperty('clip-path', value); + } + } + static void setElementTransform(html.Element element, String transformValue) { js_util.setProperty( js_util.getProperty(element, 'style'), 'transform', transformValue); diff --git a/lib/web_ui/lib/src/engine/html/clip.dart b/lib/web_ui/lib/src/engine/html/clip.dart index 127327bfcc287..f9c20fc4c6556 100644 --- a/lib/web_ui/lib/src/engine/html/clip.dart +++ b/lib/web_ui/lib/src/engine/html/clip.dart @@ -190,6 +190,7 @@ class PersistedPhysicalShape extends PersistedContainerSurface final ui.Color shadowColor; final ui.Clip clipBehavior; html.Element? _clipElement; + html.Element? _svgElement; @override void recomputeTransformAndClip() { @@ -214,10 +215,6 @@ class PersistedPhysicalShape extends PersistedContainerSurface rootElement!.style.backgroundColor = colorToCssString(color); } - void _applyShadow() { - applyCssShadow(rootElement, pathBounds, elevation, shadowColor); - } - @override html.Element createElement() { return super.createElement()..setAttribute('clip-type', 'physical-shape'); @@ -225,12 +222,11 @@ class PersistedPhysicalShape extends PersistedContainerSurface @override void apply() { - _applyColor(); - _applyShadow(); _applyShape(); } void _applyShape() { + _applyColor(); // Handle special case of round rect physical shape mapping to // rounded div. final ui.RRect? roundRect = path.toRoundedRect(); @@ -251,6 +247,7 @@ class PersistedPhysicalShape extends PersistedContainerSurface if (clipBehavior != ui.Clip.none) { style.overflow = 'hidden'; } + applyCssShadow(rootElement, pathBounds, elevation, shadowColor); return; } else { final ui.Rect? rect = path.toRect(); @@ -268,6 +265,7 @@ class PersistedPhysicalShape extends PersistedContainerSurface if (clipBehavior != ui.Clip.none) { style.overflow = 'hidden'; } + applyCssShadow(rootElement, pathBounds, elevation, shadowColor); return; } else { final ui.Rect? ovalRect = path.toCircle(); @@ -291,26 +289,64 @@ class PersistedPhysicalShape extends PersistedContainerSurface if (clipBehavior != ui.Clip.none) { style.overflow = 'hidden'; } + applyCssShadow(rootElement, pathBounds, elevation, shadowColor); return; } } } - final String svgClipPath = _pathToSvgClipPath(path, - offsetX: -pathBounds.left, - offsetY: -pathBounds.top, - scaleX: 1.0 / pathBounds.width, - scaleY: 1.0 / pathBounds.height); - // If apply is called multiple times (without update) , remove prior - // svg clip element. + /// If code reaches this point, we have a path we want to clip against and + /// potentially have a shadow due to material surface elevation. + /// + /// When there is no shadow we can simply clip a div with a background + /// color using a svg clip path. + /// + /// Otherwise we need to paint svg element for the path and clip + /// contents against same path for shadow to work since box-shadow doesn't + /// take clip-path into account. + /// + /// Webkit has a bug when applying clip-path on an element that has + /// position: absolute and transform + /// (https://bugs.webkit.org/show_bug.cgi?id=141731). + /// To place clipping rectangle correctly + /// we size the inner container to cover full pathBounds instead of sizing + /// to clipping rect bounds (which is the case for elevation == 0.0 where + /// we shift outer/inner clip area instead to position clip-path). + final String svgClipPath = elevation == 0.0 + ? _pathToSvgClipPath(path, + offsetX: -pathBounds.left, + offsetY: -pathBounds.top, + scaleX: 1.0 / pathBounds.width, + scaleY: 1.0 / pathBounds.height) + : _pathToSvgClipPath(path, + offsetX: 0.0, + offsetY: 0.0, + scaleX: 1.0 / pathBounds.right, + scaleY: 1.0 / pathBounds.bottom); + /// If apply is called multiple times (without update), remove prior + /// svg clip and render elements. _clipElement?.remove(); + _svgElement?.remove(); _clipElement = html.Element.html(svgClipPath, treeSanitizer: _NullTreeSanitizer()); domRenderer.append(rootElement!, _clipElement!); - DomRenderer.setElementStyle( - rootElement!, 'clip-path', 'url(#svgClip$_clipIdCounter)'); - DomRenderer.setElementStyle( - rootElement!, '-webkit-clip-path', 'url(#svgClip$_clipIdCounter)'); + if (elevation == 0.0) { + DomRenderer.setClipPath(rootElement!, 'url(#svgClip$_clipIdCounter)'); + final html.CssStyleDeclaration rootElementStyle = rootElement!.style; + rootElementStyle + ..overflow = '' + ..left = '${pathBounds.left}px' + ..top = '${pathBounds.top}px' + ..width = '${pathBounds.width}px' + ..height = '${pathBounds.height}px' + ..borderRadius = ''; + childContainer!.style + ..left = '-${pathBounds.left}px' + ..top = '-${pathBounds.top}px'; + return; + } + + DomRenderer.setClipPath(childContainer!, 'url(#svgClip$_clipIdCounter)'); final html.CssStyleDeclaration rootElementStyle = rootElement!.style; rootElementStyle ..overflow = '' @@ -321,28 +357,45 @@ class PersistedPhysicalShape extends PersistedContainerSurface ..borderRadius = ''; childContainer!.style ..left = '-${pathBounds.left}px' - ..top = '-${pathBounds.top}px'; + ..top = '-${pathBounds.top}px' + ..width = '${pathBounds.right}px' + ..height = '${pathBounds.bottom}px'; + + final ui.Rect pathBounds2 = path.getBounds(); + _svgElement = _pathToSvgElement( + path, SurfacePaintData()..color = color, '${pathBounds2.right}', '${pathBounds2.bottom}'); + /// Render element behind the clipped content. + rootElement!.insertBefore(_svgElement!, childContainer); + + final SurfaceShadowData shadow = computeShadow(pathBounds, elevation)!; + final ui.Color boxShadowColor = toShadowColor(shadowColor); + _svgElement!.style + ..filter = + 'drop-shadow(${shadow.offset.dx}px ${shadow.offset.dy}px ' + '${shadow.blurWidth}px ' + 'rgba(${boxShadowColor.red}, ${boxShadowColor.green}, ' + '${boxShadowColor.blue}, ${boxShadowColor.alpha / 255}))' + ..transform = 'translate(-${pathBounds2.left}px, -${pathBounds2.top}px)'; + + rootElement!.style.backgroundColor = ''; } @override void update(PersistedPhysicalShape oldSurface) { super.update(oldSurface); - if (oldSurface.color != color) { - _applyColor(); - } - if (oldSurface.elevation != elevation || - oldSurface.shadowColor != shadowColor) { - _applyShadow(); - } - if (oldSurface.path != path) { + if (oldSurface.path != path || oldSurface.elevation != elevation || + oldSurface.shadowColor != shadowColor || oldSurface.color != color) { oldSurface._clipElement?.remove(); oldSurface._clipElement = null; + oldSurface._svgElement?.remove(); + oldSurface._svgElement = null; _clipElement?.remove(); _clipElement = null; + _svgElement?.remove(); + _svgElement = null; // Reset style on prior element since we may have switched between // rect/rrect and arbitrary path. - DomRenderer.setElementStyle(rootElement!, 'clip-path', ''); - DomRenderer.setElementStyle(rootElement!, '-webkit-clip-path', ''); + DomRenderer.setClipPath(rootElement!, ''); _applyShape(); } else { // Reuse clipElement from prior surface. @@ -351,6 +404,10 @@ class PersistedPhysicalShape extends PersistedContainerSurface domRenderer.append(rootElement!, _clipElement!); } oldSurface._clipElement = null; + _svgElement = oldSurface._svgElement; + if (_svgElement != null) { + rootElement!.insertBefore(_svgElement!, childContainer); + } } } } @@ -416,10 +473,7 @@ String createSvgClipDef(html.HtmlElement element, ui.Path clipPath) { final ui.Rect pathBounds = clipPath.getBounds(); final String svgClipPath = _pathToSvgClipPath(clipPath, scaleX: 1.0 / pathBounds.right, scaleY: 1.0 / pathBounds.bottom); - DomRenderer.setElementStyle( - element, 'clip-path', 'url(#svgClip$_clipIdCounter)'); - DomRenderer.setElementStyle( - element, '-webkit-clip-path', 'url(#svgClip$_clipIdCounter)'); + DomRenderer.setClipPath(element, 'url(#svgClip$_clipIdCounter)'); // We need to set width and height for the clipElement to cover the // bounds of the path since browsers such as Safari and Edge // seem to incorrectly intersect the element bounding rect with diff --git a/lib/web_ui/test/golden_tests/engine/compositing_golden_test.dart b/lib/web_ui/test/golden_tests/engine/compositing_golden_test.dart index 18fd6c77ef647..8657c53255a82 100644 --- a/lib/web_ui/test/golden_tests/engine/compositing_golden_test.dart +++ b/lib/web_ui/test/golden_tests/engine/compositing_golden_test.dart @@ -111,6 +111,183 @@ void testMain() async { region: region); }); + test('pushPhysicalShape with path and elevation', () async { + Path cutCornersButton = Path() + ..moveTo(15, 10) + ..lineTo(60, 10) + ..lineTo(60, 60) + ..lineTo(15, 60) + ..lineTo(10, 55) + ..lineTo(10, 15); + + final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); + builder.pushPhysicalShape( + path: cutCornersButton, + clipBehavior: Clip.hardEdge, + color: const Color(0xFFA0FFFF), + elevation: 2, + ); + _drawTestPicture(builder); + builder.pop(); + + builder.pushOffset(70, 0); + builder.pushPhysicalShape( + path: cutCornersButton, + clipBehavior: Clip.hardEdge, + color: const Color(0xFFA0FFFF), + elevation: 8, + ); + _drawTestPicture(builder); + builder.pop(); + builder.pop(); + + builder.pushOffset(140, 0); + builder.pushPhysicalShape( + path: Path()..addOval(Rect.fromLTRB(10, 10, 60, 60)), + clipBehavior: Clip.hardEdge, + color: const Color(0xFFA0FFFF), + elevation: 4, + ); + _drawTestPicture(builder); + builder.pop(); + builder.pop(); + + builder.pushOffset(210, 0); + builder.pushPhysicalShape( + path: Path()..addRRect(RRect.fromRectAndRadius( + Rect.fromLTRB(10, 10, 60, 60), Radius.circular(10.0))), + clipBehavior: Clip.hardEdge, + color: const Color(0xFFA0FFFF), + elevation: 4, + ); + _drawTestPicture(builder); + builder.pop(); + builder.pop(); + + html.document.body.append(builder.build().webOnlyRootElement); + + await matchGoldenFile('compositing_physical_shape_path.png', + region: region); + }); + + test('pushPhysicalShape should update across frames', () async { + Path cutCornersButton = Path() + ..moveTo(15, 10) + ..lineTo(60, 10) + ..lineTo(60, 60) + ..lineTo(15, 60) + ..lineTo(10, 55) + ..lineTo(10, 15); + + /// Start with shape that has elevation and red color. + final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); + EngineLayer oldShapeLayer = builder.pushPhysicalShape( + path: cutCornersButton, + clipBehavior: Clip.hardEdge, + color: const Color(0xFFFF0000), + elevation: 2, + ); + _drawTestPicture(builder); + builder.pop(); + + html.Element viewElement = builder.build().webOnlyRootElement; + html.document.body.append(viewElement); + await matchGoldenFile('compositing_physical_update_1.png', + region: region); + viewElement.remove(); + + /// Update color to green. + final SurfaceSceneBuilder builder2 = SurfaceSceneBuilder(); + EngineLayer oldShapeLayer2 = builder2.pushPhysicalShape( + path: cutCornersButton, + clipBehavior: Clip.hardEdge, + color: const Color(0xFF00FF00), + elevation: 2, + oldLayer: oldShapeLayer, + ); + _drawTestPicture(builder2); + builder2.pop(); + + html.Element viewElement2 = builder2.build().webOnlyRootElement; + html.document.body.append(viewElement2); + await matchGoldenFile('compositing_physical_update_2.png', + region: region); + viewElement2.remove(); + + /// Update elevation. + final SurfaceSceneBuilder builder3 = SurfaceSceneBuilder(); + EngineLayer oldShapeLayer3 = builder3.pushPhysicalShape( + path: cutCornersButton, + clipBehavior: Clip.hardEdge, + color: const Color(0xFF00FF00), + elevation: 6, + oldLayer: oldShapeLayer2, + ); + _drawTestPicture(builder3); + builder3.pop(); + + html.Element viewElement3 = builder3.build().webOnlyRootElement; + html.document.body.append(viewElement3); + await matchGoldenFile('compositing_physical_update_3.png', + region: region, maxDiffRatePercent: 0.8); + viewElement3.remove(); + + /// Update shape from arbitrary path to rect. + final SurfaceSceneBuilder builder4 = SurfaceSceneBuilder(); + EngineLayer oldShapeLayer4 = builder4.pushPhysicalShape( + path: Path()..addOval(Rect.fromLTRB(10, 10, 60, 60)), + clipBehavior: Clip.hardEdge, + color: const Color(0xFF00FF00), + elevation: 6, + oldLayer: oldShapeLayer3, + ); + _drawTestPicture(builder4); + builder4.pop(); + + html.Element viewElement4 = builder4.build().webOnlyRootElement; + html.document.body.append(viewElement4); + await matchGoldenFile('compositing_physical_update_4.png', + region: region); + viewElement4.remove(); + + /// Update shape back to arbitrary path. + final SurfaceSceneBuilder builder5 = SurfaceSceneBuilder(); + EngineLayer oldShapeLayer5 = builder5.pushPhysicalShape( + path: cutCornersButton, + clipBehavior: Clip.hardEdge, + color: const Color(0xFF00FF00), + elevation: 6, + oldLayer: oldShapeLayer4, + ); + _drawTestPicture(builder5); + builder5.pop(); + + html.Element viewElement5 = builder5.build().webOnlyRootElement; + html.document.body.append(viewElement5); + await matchGoldenFile('compositing_physical_update_3.png', + region: region, maxDiffRatePercent: 0.4); + viewElement5.remove(); + + /// Update shadow color. + final SurfaceSceneBuilder builder6 = SurfaceSceneBuilder(); + builder6.pushPhysicalShape( + path: cutCornersButton, + clipBehavior: Clip.hardEdge, + color: const Color(0xFF00FF00), + shadowColor: const Color(0xFFFF0000), + elevation: 6, + oldLayer: oldShapeLayer5, + ); + _drawTestPicture(builder6); + builder6.pop(); + + html.Element viewElement6 = builder6.build().webOnlyRootElement; + html.document.body.append(viewElement6); + await matchGoldenFile('compositing_physical_update_5.png', + region: region); + viewElement6.remove(); + }); + test('pushImageFilter', () async { final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); builder.pushImageFilter(