diff --git a/lib/web_ui/lib/src/engine/dom_renderer.dart b/lib/web_ui/lib/src/engine/dom_renderer.dart index ce2a3154d5c88263a211fa5c395647cd0bd4c1df..90f9ade63d1b42765acb57ebdb0f076eb7601a84 100644 --- a/lib/web_ui/lib/src/engine/dom_renderer.dart +++ b/lib/web_ui/lib/src/engine/dom_renderer.dart @@ -54,12 +54,27 @@ class DomRenderer { /// This element is created and inserted in the HTML DOM once. It is never /// removed or moved. However the [sceneElement] may be replaced inside it. /// - /// This element precedes the [glassPaneElement] so that it never receives - /// input events. All input events are processed by [glassPaneElement] and the - /// semantics tree. + /// This element is inserted after the [semanticsHostElement] so that + /// platform views take precedence in DOM event handling. html.Element? get sceneHostElement => _sceneHostElement; html.Element? _sceneHostElement; + /// The element that contains the semantics tree. + /// + /// This element is created and inserted in the HTML DOM once. It is never + /// removed or moved. + /// + /// We render semantics inside the glasspane for proper focus and event + /// handling. If semantics is behind the glasspane, the phone will disable + /// focusing by touch, only by tabbing around the UI. If semantics is in + /// front of glasspane, then DOM event won't bubble up to the glasspane so + /// it can forward events to the framework. + /// + /// This element is inserted before the [semanticsHostElement] so that + /// platform views take precedence in DOM event handling. + html.Element? get semanticsHostElement => _semanticsHostElement; + html.Element? _semanticsHostElement; + /// The last scene element rendered by the [render] method. html.Element? get sceneElement => _sceneElement; html.Element? _sceneElement; @@ -427,6 +442,14 @@ flt-glass-pane * { _sceneHostElement = createElement('flt-scene-host'); + final html.Element semanticsHostElement = createElement('flt-semantics-host'); + semanticsHostElement.style + ..position = 'absolute' + ..transformOrigin = '0 0 0'; + _semanticsHostElement = semanticsHostElement; + updateSemanticsScreenProperties(); + glassPaneElement.append(semanticsHostElement); + // Don't allow the scene to receive pointer events. _sceneHostElement!.style.pointerEvents = 'none'; @@ -443,6 +466,12 @@ flt-glass-pane * { // by the platform view. glassPaneElement.insertBefore(_accesibilityPlaceholder, _sceneHostElement); + // When debugging semantics, make the scene semi-transparent so that the + // semantics tree is visible. + if (_debugShowSemanticsNodes) { + _sceneHostElement!.style.opacity = '0.3'; + } + PointerBinding.initInstance(glassPaneElement); KeyboardBinding.initInstance(glassPaneElement); @@ -559,6 +588,13 @@ flt-glass-pane * { EnginePlatformDispatcher.instance._updateLocales(); } + /// The framework specifies semantics in physical pixels, but CSS uses + /// logical pixels. To compensate, we inject an inverse scale at the root + /// level. + void updateSemanticsScreenProperties() { + _semanticsHostElement!.style.transform = 'scale(${1 / html.window.devicePixelRatio})'; + } + /// Called immediately after browser window metrics change. /// /// When there is a text editing going on in mobile devices, do not change @@ -569,6 +605,7 @@ flt-glass-pane * { /// Note: always check for rotations for a mobile device. Update the physical /// size if the change is caused by a rotation. void _metricsDidChange(html.Event? event) { + updateSemanticsScreenProperties(); if (isMobile && !window.isRotation() && textEditing.isEditing) { window.computeOnScreenKeyboardInsets(); EnginePlatformDispatcher.instance.invokeOnMetricsChanged(); diff --git a/lib/web_ui/lib/src/engine/semantics/label_and_value.dart b/lib/web_ui/lib/src/engine/semantics/label_and_value.dart index 0784a5317b66182cdd8bc753befcd8d0a8740364..7975ba32b703eb8ed1b0897232b59d11fb7b18f7 100644 --- a/lib/web_ui/lib/src/engine/semantics/label_and_value.dart +++ b/lib/web_ui/lib/src/engine/semantics/label_and_value.dart @@ -96,7 +96,11 @@ class LabelAndValue extends RoleManager { ..width = '${semanticsObject.rect!.width}px' ..height = '${semanticsObject.rect!.height}px'; } - _auxiliaryValueElement!.style.fontSize = '6px'; + + // Normally use a small font size so that text doesn't leave the scope + // of the semantics node. When debugging semantics, use a font size + // that's reasonably visible. + _auxiliaryValueElement!.style.fontSize = _debugShowSemanticsNodes ? '12px' : '6px'; semanticsObject.element.append(_auxiliaryValueElement!); } _auxiliaryValueElement!.text = combinedValue.toString(); diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart index 36d05104066c25436c3e3010b42e8fb02471a1be..dd00fcaf0dfaf60c984692c8dcd5b2ce3ccc5615 100644 --- a/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -6,10 +6,20 @@ part of engine; /// Set this flag to `true` to cause the engine to visualize the semantics tree -/// on the screen. +/// on the screen for debugging. /// -/// This is useful for debugging. -const bool _debugShowSemanticsNodes = false; +/// This only works in profile and release modes. Debug mode does not support +/// passing compile-time constants. +/// +/// Example: +/// +/// ``` +/// flutter run -d chrome --profile --dart-define=FLUTTER_WEB_DEBUG_SHOW_SEMANTICS=true +/// ``` +const bool _debugShowSemanticsNodes = bool.fromEnvironment( + 'FLUTTER_WEB_DEBUG_SHOW_SEMANTICS', + defaultValue: false, +); /// Contains updates for the semantics tree. /// @@ -233,15 +243,17 @@ class SemanticsObject { /// Creates a semantics tree node with the given [id] and [owner]. SemanticsObject(this.id, this.owner) { // DOM nodes created for semantics objects are positioned absolutely using - // transforms. We use a transparent color instead of "visibility:hidden" or - // "display:none" so that a screen reader does not ignore these elements. + // transforms. element.style.position = 'absolute'; // The root node has some properties that other nodes do not. - if (id == 0) { + if (id == 0 && !_debugShowSemanticsNodes) { // Make all semantics transparent. We use `filter` instead of `opacity` // attribute because `filter` is stronger. `opacity` does not apply to // some elements, particularly on iOS, such as the slider thumb and track. + // + // We use transparency instead of "visibility:hidden" or "display:none" + // so that a screen reader does not ignore these elements. element.style.filter = 'opacity(0%)'; // Make text explicitly transparent to signal to the browser that no @@ -249,11 +261,11 @@ class SemanticsObject { element.style.color = 'rgba(0,0,0,0)'; } + // Make semantic elements visible for debugging by outlining them using a + // green border. We do not use `border` attribute because it affects layout + // (`outline` does not). if (_debugShowSemanticsNodes) { - element.style - ..filter = 'opacity(90%)' - ..outline = '1px solid green' - ..color = 'purple'; + element.style.outline = '1px solid green'; } } @@ -853,9 +865,9 @@ class SemanticsObject { hasIdentityTransform && verticalContainerAdjustment == 0.0 && horizontalContainerAdjustment == 0.0) { - _resetElementOffsets(element); + _clearSemanticElementTransform(element); if (containerElement != null) { - _resetElementOffsets(containerElement); + _clearSemanticElementTransform(containerElement); } return; } @@ -879,81 +891,48 @@ class SemanticsObject { effectiveTransformIsIdentity = false; } - if (!effectiveTransformIsIdentity || isMacOrIOS) { - if (effectiveTransformIsIdentity) { - effectiveTransform = Matrix4.identity(); - } - if (isDesktop) { - element.style - ..transformOrigin = '0 0 0' - ..transform = (effectiveTransformIsIdentity ? 'translate(0px 0px 0px)' - : matrix4ToCssTransform(effectiveTransform)); - } else { - // Mobile screen readers observed to have errors while calculating the - // semantics focus borders if css `transform` properties are used. - // See: https://github.com/flutter/flutter/issues/68225 - // Therefore we are calculating a bounding rectangle for the - // effective transform and use that rectangle to set TLWH css style - // properties. - // Note: Identity matrix is not using this code path. - final ui.Rect rect = - computeBoundingRectangleFromMatrix(effectiveTransform, _rect!); - element.style - ..top = '${rect.top}px' - ..left = '${rect.left}px' - ..width = '${rect.width}px' - ..height = '${rect.height}px'; - } + if (!effectiveTransformIsIdentity) { + element.style + ..transformOrigin = '0 0 0' + ..transform = matrix4ToCssTransform(effectiveTransform); } else { - _resetElementOffsets(element); - // TODO: https://github.com/flutter/flutter/issues/73347 + _clearSemanticElementTransform(element); } if (containerElement != null) { if (!hasZeroRectOffset || - isMacOrIOS || verticalContainerAdjustment != 0.0 || horizontalContainerAdjustment != 0.0) { final double translateX = -_rect!.left + horizontalContainerAdjustment; final double translateY = -_rect!.top + verticalContainerAdjustment; - if (isDesktop) { - containerElement.style - ..transformOrigin = '0 0 0' - ..transform = 'translate(${translateX}px, ${translateY}px)'; - } else { - containerElement.style - ..top = '${translateY}px' - ..left = '${translateX}px'; - } + containerElement.style + ..top = '${translateY}px' + ..left = '${translateX}px'; } else { - _resetElementOffsets(containerElement); + _clearSemanticElementTransform(containerElement); } } } - // On Mac OS and iOS, VoiceOver requires left=0 top=0 value to correctly - // handle order. See https://github.com/flutter/flutter/issues/73347. - static void _resetElementOffsets(html.Element element) { + /// Clears the transform on a semantic element as if an identity transform is + /// applied. + /// + /// On macOS and iOS, VoiceOver requires `left=0; top=0` value to correctly + /// handle traversal order. + /// + /// See https://github.com/flutter/flutter/issues/73347. + static void _clearSemanticElementTransform(html.Element element) { + element.style + ..removeProperty('transform-origin') + ..removeProperty('transform'); if (isMacOrIOS) { - if (isDesktop) { - element.style - ..transformOrigin = '0 0 0' - ..transform = 'translate(0px, 0px)'; - } else { - element.style - ..top = '0px' - ..left = '0px'; - } + element.style + ..top = '0px' + ..left = '0px'; } else { - if (isDesktop) { - element.style - ..removeProperty('transform-origin') - ..removeProperty('transform'); - } else { - element.style - ..removeProperty('top') - ..removeProperty('left'); - } + element.style + ..removeProperty('top') + ..removeProperty('left'); } } @@ -1493,7 +1472,10 @@ class EngineSemanticsOwner { /// Updates the semantics tree from data in the [uiUpdate]. void updateSemantics(ui.SemanticsUpdate uiUpdate) { if (!_semanticsEnabled) { - return; + // If we're receiving a semantics update from the framework, it means the + // developer enabled it programmatically, so we enable it in the engine + // too. + semanticsEnabled = true; } final SemanticsUpdate update = uiUpdate as SemanticsUpdate; @@ -1505,19 +1487,7 @@ class EngineSemanticsOwner { if (_rootSemanticsElement == null) { final SemanticsObject root = _semanticsTree[0]!; _rootSemanticsElement = root.element; - // We render semantics inside the glasspane for proper focus and event - // handling. If semantics is behind the glasspane, the phone will disable - // focusing by touch, only by tabbing around the UI. If semantics is in - // front of glasspane, then DOM event won't bubble up to the glasspane so - // it can forward events to the framework. - // - // We insert the semantics root before the scene host. For all widgets - // in the scene, except for platform widgets, the scene host will pass the - // pointer events through to the semantics tree. However, for platform - // views, the pointer events will not pass through, and will be handled - // by the platform view. - domRenderer.glassPaneElement! - .insertBefore(_rootSemanticsElement!, domRenderer.sceneHostElement); + domRenderer.semanticsHostElement!.append(root.element); } _finalizeTree(); diff --git a/lib/web_ui/lib/src/engine/util.dart b/lib/web_ui/lib/src/engine/util.dart index e422d6661018b8b2075ef40a50f4924fc52c3631..e0304a4962a12dc8d0cd24432090e73f11e988d3 100644 --- a/lib/web_ui/lib/src/engine/util.dart +++ b/lib/web_ui/lib/src/engine/util.dart @@ -595,47 +595,3 @@ int clampInt(int value, int min, int max) { return value; } } - -ui.Rect computeBoundingRectangleFromMatrix(Matrix4 transform, ui.Rect rect) { - final Float32List m = transform.storage; - // Apply perspective transform to all 4 corners. Can't use left,top, bottom, - // right since for example rotating 45 degrees would yield inaccurate size. - double x = rect.left; - double y = rect.top; - double wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); - double xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; - double yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; - double minX = xp, maxX = xp; - double minY = yp, maxY = yp; - x = rect.right; - y = rect.bottom; - wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); - xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; - yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; - - minX = math.min(minX, xp); - maxX = math.max(maxX, xp); - minY = math.min(minY, yp); - maxY = math.max(maxY, yp); - - x = rect.left; - y = rect.bottom; - wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); - xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; - yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; - minX = math.min(minX, xp); - maxX = math.max(maxX, xp); - minY = math.min(minY, yp); - maxY = math.max(maxY, yp); - - x = rect.right; - y = rect.top; - wp = 1.0 / ((m[3] * x) + (m[7] * y) + m[15]); - xp = ((m[0] * x) + (m[4] * y) + m[12]) * wp; - yp = ((m[1] * x) + (m[5] * y) + m[13]) * wp; - minX = math.min(minX, xp); - maxX = math.max(maxX, xp); - minY = math.min(minY, yp); - maxY = math.max(maxY, yp); - return ui.Rect.fromLTWH(minX, minY, maxX - minX, maxY - minY); -} diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart index 2199bd5fe209a990cac9200e8e63c7e5a9186cd5..45b47f1a62548c5d77bd0a9a2fb36625a92e3e3c 100644 --- a/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -338,17 +338,21 @@ void _testContainer() { final html.Element container = html.document.querySelector('flt-semantics-container'); - if (operatingSystem == OperatingSystem.macOs) { - expect(parentElement.style.transform, 'translate(0px, 0px)'); - expect(parentElement.style.transformOrigin, '0px 0px 0px'); - expect(container.style.transform, 'translate(0px, 0px)'); - expect(container.style.transformOrigin, '0px 0px 0px'); + if (isMacOrIOS) { + expect(parentElement.style.top, '0px'); + expect(parentElement.style.left, '0px'); + expect(container.style.top, '0px'); + expect(container.style.left, '0px'); } else { - expect(parentElement.style.transform, ''); - expect(parentElement.style.transformOrigin, ''); - expect(container.style.transform, ''); - expect(container.style.transformOrigin, ''); + expect(parentElement.style.top, ''); + expect(parentElement.style.left, ''); + expect(container.style.top, ''); + expect(container.style.left, ''); } + expect(parentElement.style.transform, ''); + expect(parentElement.style.transformOrigin, ''); + expect(container.style.transform, ''); + expect(container.style.transformOrigin, ''); semantics().semanticsEnabled = false; }); @@ -382,17 +386,10 @@ void _testContainer() { final html.Element container = html.document.querySelector('flt-semantics-container'); - if (isDesktop) { - expect(parentElement.style.transform, 'matrix(1, 0, 0, 1, 10, 10)'); - expect(parentElement.style.transformOrigin, '0px 0px 0px'); - expect(container.style.transform, 'translate(-10px, -10px)'); - expect(container.style.transformOrigin, '0px 0px 0px'); - } else { - expect(parentElement.style.top, '20px'); - expect(parentElement.style.left, '20px'); - expect(container.style.top, '-10px'); - expect(container.style.left, '-10px'); - } + expect(parentElement.style.transform, 'matrix(1, 0, 0, 1, 10, 10)'); + expect(parentElement.style.transformOrigin, '0px 0px 0px'); + expect(container.style.top, '-10px'); + expect(container.style.left, '-10px'); semantics().semanticsEnabled = false; }); @@ -433,20 +430,22 @@ void _testContainer() { html.document.querySelector('flt-semantics'); final html.Element container = html.document.querySelector('flt-semantics-container'); - if (operatingSystem == OperatingSystem.macOs || - operatingSystem == OperatingSystem.iOs) { - if (isDesktop) { - expect(parentElement.style.transform, 'translate(0px, 0px)'); - expect(parentElement.style.transformOrigin, '0px 0px 0px'); - expect(container.style.transform, 'translate(0px, 0px)'); - expect(container.style.transformOrigin, '0px 0px 0px'); - } else { - expect(parentElement.style.top, '0px'); - expect(parentElement.style.left, '0px'); - expect(container.style.top, '0px'); - expect(container.style.left, '0px'); - } + if (isMacOrIOS) { + expect(parentElement.style.top, '0px'); + expect(parentElement.style.left, '0px'); + expect(container.style.top, '0px'); + expect(container.style.left, '0px'); + } else { + expect(parentElement.style.top, ''); + expect(parentElement.style.left, ''); + expect(container.style.top, ''); + expect(container.style.left, ''); } + expect(parentElement.style.transform, ''); + expect(parentElement.style.transformOrigin, ''); + expect(container.style.transform, ''); + expect(container.style.transformOrigin, ''); + semantics().semanticsEnabled = false; }); }