From fccfa978a97633800aff123b776937d28262c04f Mon Sep 17 00:00:00 2001 From: Qun Cheng <36861262+QuncCccccc@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:35:40 -0800 Subject: [PATCH] Reland "Refactor OverlayPortal semantics (#173005)" (#178095) Reverts flutter/flutter#178007 This PR is to reland https://github.com/flutter/flutter/pull/173005 and add a fix to avoid infinite loop. The fix doesn't contain engine changes. --- .../src/flutter/lib/ui/fixtures/ui_test.dart | 39 ++ engine/src/flutter/lib/ui/semantics.dart | 25 + .../flutter/lib/ui/semantics/semantics_node.h | 2 + .../ui/semantics/semantics_update_builder.cc | 8 + .../ui/semantics/semantics_update_builder.h | 2 + .../src/flutter/lib/web_ui/lib/semantics.dart | 4 + .../lib/src/engine/semantics/semantics.dart | 67 ++- .../test/engine/semantics/semantics_test.dart | 154 ++++++ .../engine/semantics/semantics_tester.dart | 6 + .../runtime/fixtures/runtime_test.dart | 2 + .../io/flutter/view/AccessibilityBridge.java | 36 +- .../platform_view_android_delegate.cc | 5 +- .../platform_view_android_delegate.h | 2 +- ...latform_view_android_delegate_unittests.cc | 13 + .../flutter/view/AccessibilityBridgeTest.java | 15 + .../framework/Source/accessibility_bridge.mm | 12 +- .../platform/embedder/fixtures/main.dart | 12 + .../platform/linux/fl_view_accessible.cc | 4 +- .../shell/platform/windows/fixtures/main.dart | 3 + .../lib/src/locale_initialization.dart | 4 + .../lib/src/rendering/custom_paint.dart | 6 + .../flutter/lib/src/rendering/object.dart | 31 +- .../flutter/lib/src/semantics/semantics.dart | 475 +++++++++++++++++- packages/flutter/lib/src/widgets/basic.dart | 8 + packages/flutter/lib/src/widgets/overlay.dart | 54 +- .../lib/src/widgets/semantics_debugger.dart | 3 + .../test/material/autocomplete_test.dart | 40 ++ .../test/material/menu_anchor_test.dart | 135 ++--- .../test/material/range_slider_test.dart | 282 ++++++----- .../flutter/test/material/slider_test.dart | 325 ++++++------ .../flutter/test/material/tooltip_test.dart | 30 +- .../test/semantics/semantics_test.dart | 4 + .../test/semantics/semantics_update_test.dart | 2 + .../test/widgets/overlay_portal_test.dart | 117 ++--- .../flutter/test/widgets/semantics_test.dart | 195 +++++++ .../test/widgets/semantics_tester.dart | 47 +- packages/flutter_test/lib/src/matchers.dart | 12 + packages/flutter_test/test/matchers_test.dart | 12 + 38 files changed, 1678 insertions(+), 515 deletions(-) diff --git a/engine/src/flutter/lib/ui/fixtures/ui_test.dart b/engine/src/flutter/lib/ui/fixtures/ui_test.dart index 7fd112c4400..24da51e7bee 100644 --- a/engine/src/flutter/lib/ui/fixtures/ui_test.dart +++ b/engine/src/flutter/lib/ui/fixtures/ui_test.dart @@ -176,6 +176,7 @@ void sendSemanticsUpdate() { String tooltip = "tooltip"; final Float64List transform = Float64List(16); + final Float64List hitTestTransform = Float64List(16); final Int32List childrenInTraversalOrder = Int32List(0); final Int32List childrenInHitTestOrder = Int32List(0); final Int32List additionalActions = Int32List(0); @@ -198,6 +199,26 @@ void sendSemanticsUpdate() { transform[13] = 0; transform[14] = 0; transform[15] = 0; + + hitTestTransform[0] = 1; + hitTestTransform[1] = 0; + hitTestTransform[2] = 0; + hitTestTransform[3] = 0; + + hitTestTransform[4] = 0; + hitTestTransform[5] = 1; + hitTestTransform[6] = 0; + hitTestTransform[7] = 0; + + hitTestTransform[8] = 0; + hitTestTransform[9] = 0; + hitTestTransform[10] = 1; + hitTestTransform[11] = 0; + + hitTestTransform[12] = 0; + hitTestTransform[13] = 0; + hitTestTransform[14] = 0; + hitTestTransform[15] = 0; builder.updateNode( id: 0, flags: SemanticsFlags.none, @@ -209,6 +230,7 @@ void sendSemanticsUpdate() { platformViewId: -1, scrollChildren: 0, scrollIndex: 0, + traversalParent: 0, scrollPosition: 0, scrollExtentMax: 0, scrollExtentMin: 0, @@ -227,6 +249,7 @@ void sendSemanticsUpdate() { tooltip: tooltip, textDirection: TextDirection.ltr, transform: transform, + hitTestTransform: hitTestTransform, childrenInTraversalOrder: childrenInTraversalOrder, childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: additionalActions, @@ -244,6 +267,7 @@ void sendSemanticsUpdateWithRole() { final SemanticsUpdateBuilder builder = SemanticsUpdateBuilder(); final Float64List transform = Float64List(16); + final Float64List hitTestTransform = Float64List(16); final Int32List childrenInTraversalOrder = Int32List(0); final Int32List childrenInHitTestOrder = Int32List(0); final Int32List additionalActions = Int32List(0); @@ -251,6 +275,10 @@ void sendSemanticsUpdateWithRole() { transform[0] = 1; transform[5] = 1; transform[10] = 1; + + hitTestTransform[0] = 1; + hitTestTransform[5] = 1; + hitTestTransform[10] = 1; builder.updateNode( id: 0, flags: SemanticsFlags.none, @@ -262,6 +290,7 @@ void sendSemanticsUpdateWithRole() { platformViewId: -1, scrollChildren: 0, scrollIndex: 0, + traversalParent: 0, scrollPosition: 0, scrollExtentMax: 0, scrollExtentMin: 0, @@ -280,6 +309,7 @@ void sendSemanticsUpdateWithRole() { tooltip: "tooltip", textDirection: TextDirection.ltr, transform: transform, + hitTestTransform: hitTestTransform, childrenInTraversalOrder: childrenInTraversalOrder, childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: additionalActions, @@ -298,6 +328,7 @@ void sendSemanticsUpdateWithLocale() { final SemanticsUpdateBuilder builder = SemanticsUpdateBuilder(); final Float64List transform = Float64List(16); + final Float64List hitTestTransform = Float64List(16); final Int32List childrenInTraversalOrder = Int32List(0); final Int32List childrenInHitTestOrder = Int32List(0); final Int32List additionalActions = Int32List(0); @@ -305,6 +336,10 @@ void sendSemanticsUpdateWithLocale() { transform[0] = 1; transform[5] = 1; transform[10] = 1; + + hitTestTransform[0] = 1; + hitTestTransform[5] = 1; + hitTestTransform[10] = 1; builder.updateNode( id: 0, flags: SemanticsFlags.none, @@ -319,6 +354,7 @@ void sendSemanticsUpdateWithLocale() { scrollPosition: 0, scrollExtentMax: 0, scrollExtentMin: 0, + traversalParent: 0, rect: Rect.fromLTRB(0, 0, 10, 10), identifier: "identifier", label: "label", @@ -334,6 +370,7 @@ void sendSemanticsUpdateWithLocale() { tooltip: "tooltip", textDirection: TextDirection.ltr, transform: transform, + hitTestTransform: hitTestTransform, childrenInTraversalOrder: childrenInTraversalOrder, childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: additionalActions, @@ -370,6 +407,7 @@ void sendSemanticsUpdateWithIsLink() { platformViewId: -1, scrollChildren: 0, scrollIndex: 0, + traversalParent: 0, scrollPosition: 0, scrollExtentMax: 0, scrollExtentMin: 0, @@ -388,6 +426,7 @@ void sendSemanticsUpdateWithIsLink() { tooltip: "tooltip", textDirection: TextDirection.ltr, transform: transform, + hitTestTransform: transform, childrenInTraversalOrder: childrenInTraversalOrder, childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: additionalActions, diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart index 3a0b9f8bcda..8d496fa664c 100644 --- a/engine/src/flutter/lib/ui/semantics.dart +++ b/engine/src/flutter/lib/ui/semantics.dart @@ -1871,6 +1871,21 @@ abstract class SemanticsUpdateBuilder { /// total number of child nodes that contribute semantics and `scrollIndex` /// is the index of the first visible child node that contributes semantics. /// + /// The `traversalParent` specifies the ID of the semantics node that serves as + /// the logical parent of this node for accessibility traversal. This + /// parameter is only used by the web engine to establish parent-child + /// relationships between nodes that are not directly connected in paint order. + /// To ensure correct accessibility traversal, `traversalParent` should be set + /// to the logical traversal parent node ID. This parameter is web-specific + /// because other platforms can complete grafting when generating the + /// semantics tree in traversal order. After grafting, the traversal order and + /// hit-test order will be different, which is acceptable for other platforms. + /// However, the web engine assumes these two orders are exactly the same, so + /// grafting cannot be performed ahead of time on web. Instead, the traversal + /// order is updated in the web engine by setting the `aria-owns` attribute + /// through this parameter. A value of -1 indicates no special traversal + /// parent. This parameter has no effect on other platforms. + /// /// The `rect` is the region occupied by this node in its own coordinate /// system. /// @@ -1929,6 +1944,7 @@ abstract class SemanticsUpdateBuilder { required int platformViewId, required int scrollChildren, required int scrollIndex, + required int traversalParent, required double scrollPosition, required double scrollExtentMax, required double scrollExtentMin, @@ -1947,6 +1963,7 @@ abstract class SemanticsUpdateBuilder { required String tooltip, required TextDirection? textDirection, required Float64List transform, + required Float64List hitTestTransform, required Int32List childrenInTraversalOrder, required Int32List childrenInHitTestOrder, required Int32List additionalActions, @@ -2008,6 +2025,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 required int platformViewId, required int scrollChildren, required int scrollIndex, + required int traversalParent, required double scrollPosition, required double scrollExtentMax, required double scrollExtentMin, @@ -2026,6 +2044,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 required String tooltip, required TextDirection? textDirection, required Float64List transform, + required Float64List hitTestTransform, required Int32List childrenInTraversalOrder, required Int32List childrenInHitTestOrder, required Int32List additionalActions, @@ -2054,6 +2073,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 platformViewId, scrollChildren, scrollIndex, + traversalParent, scrollPosition, scrollExtentMax, scrollExtentMin, @@ -2075,6 +2095,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 tooltip, textDirection != null ? textDirection.index + 1 : 0, transform, + hitTestTransform, childrenInTraversalOrder, childrenInHitTestOrder, additionalActions, @@ -2102,6 +2123,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 Int32, Int32, Int32, + Int32, Double, Double, Double, @@ -2126,6 +2148,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 Handle, Handle, Handle, + Handle, Int32, Handle, Int32, @@ -2147,6 +2170,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 int platformViewId, int scrollChildren, int scrollIndex, + int traversalParent, double scrollPosition, double scrollExtentMax, double scrollExtentMin, @@ -2168,6 +2192,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 String tooltip, int textDirection, Float64List transform, + Float64List hitTestTransform, Int32List childrenInTraversalOrder, Int32List childrenInHitTestOrder, Int32List additionalActions, diff --git a/engine/src/flutter/lib/ui/semantics/semantics_node.h b/engine/src/flutter/lib/ui/semantics/semantics_node.h index 7ca763f3078..6bbf7e0eb5c 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_node.h +++ b/engine/src/flutter/lib/ui/semantics/semantics_node.h @@ -141,6 +141,7 @@ struct SemanticsNode { int32_t platformViewId = -1; int32_t scrollChildren = 0; int32_t scrollIndex = 0; + int32_t traversalParent = 0; double scrollPosition = std::nan(""); double scrollExtentMax = std::nan(""); double scrollExtentMin = std::nan(""); @@ -160,6 +161,7 @@ struct SemanticsNode { SkRect rect = SkRect::MakeEmpty(); // Local space, relative to parent. SkM44 transform = SkM44{}; // Identity + SkM44 hitTestTransform = SkM44{}; // Identity std::vector childrenInTraversalOrder; std::vector childrenInHitTestOrder; std::vector customAccessibilityActions; diff --git a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc index 84b0545c773..a2cc6d65043 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc +++ b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc @@ -41,6 +41,7 @@ void SemanticsUpdateBuilder::updateNode( int platformViewId, int scrollChildren, int scrollIndex, + int traversalParent, double scrollPosition, double scrollExtentMax, double scrollExtentMin, @@ -62,6 +63,7 @@ void SemanticsUpdateBuilder::updateNode( std::string tooltip, int textDirection, const tonic::Float64List& transform, + const tonic::Float64List& hitTestTransform, const tonic::Int32List& childrenInTraversalOrder, const tonic::Int32List& childrenInHitTestOrder, const tonic::Int32List& localContextActions, @@ -90,6 +92,7 @@ void SemanticsUpdateBuilder::updateNode( node.platformViewId = platformViewId; node.scrollChildren = scrollChildren; node.scrollIndex = scrollIndex; + node.traversalParent = traversalParent; node.scrollPosition = scrollPosition; node.scrollExtentMax = scrollExtentMax; node.scrollExtentMin = scrollExtentMin; @@ -113,6 +116,11 @@ void SemanticsUpdateBuilder::updateNode( scalarTransform[i] = SafeNarrow(transform.data()[i]); } node.transform = SkM44::ColMajor(scalarTransform); + SkScalar scalarHitTestTransform[16]; + for (int i = 0; i < 16; ++i) { + scalarHitTestTransform[i] = SafeNarrow(hitTestTransform.data()[i]); + } + node.hitTestTransform = SkM44::ColMajor(scalarHitTestTransform); node.childrenInTraversalOrder = std::vector(childrenInTraversalOrder.data(), childrenInTraversalOrder.data() + diff --git a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h index 842c221a781..14dfccaf7bd 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h +++ b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h @@ -40,6 +40,7 @@ class SemanticsUpdateBuilder int platformViewId, int scrollChildren, int scrollIndex, + int traversalParent, double scrollPosition, double scrollExtentMax, double scrollExtentMin, @@ -61,6 +62,7 @@ class SemanticsUpdateBuilder std::string tooltip, int textDirection, const tonic::Float64List& transform, + const tonic::Float64List& hitTestTransform, const tonic::Int32List& childrenInTraversalOrder, const tonic::Int32List& childrenInHitTestOrder, const tonic::Int32List& customAccessibilityActions, diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart index 23870181652..74352902430 100644 --- a/engine/src/flutter/lib/web_ui/lib/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart @@ -722,6 +722,7 @@ class SemanticsUpdateBuilder { required int platformViewId, required int scrollChildren, required int scrollIndex, + required int? traversalParent, required double scrollPosition, required double scrollExtentMax, required double scrollExtentMin, @@ -740,6 +741,7 @@ class SemanticsUpdateBuilder { String? tooltip, TextDirection? textDirection, required Float64List transform, + required Float64List hitTestTransform, required Int32List childrenInTraversalOrder, required Int32List childrenInHitTestOrder, required Int32List additionalActions, @@ -766,6 +768,7 @@ class SemanticsUpdateBuilder { textSelectionExtent: textSelectionExtent, scrollChildren: scrollChildren, scrollIndex: scrollIndex, + traversalParent: traversalParent, scrollPosition: scrollPosition, scrollExtentMax: scrollExtentMax, scrollExtentMin: scrollExtentMin, @@ -784,6 +787,7 @@ class SemanticsUpdateBuilder { tooltip: tooltip, textDirection: textDirection, transform: engine.toMatrix32(transform), + hitTestTransform: engine.toMatrix32(hitTestTransform), childrenInTraversalOrder: childrenInTraversalOrder, childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: additionalActions, diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart index 7bd87c80ce7..e68c41436d6 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart @@ -244,6 +244,7 @@ class SemanticsNodeUpdate { required this.platformViewId, required this.scrollChildren, required this.scrollIndex, + required this.traversalParent, required this.scrollPosition, required this.scrollExtentMax, required this.scrollExtentMin, @@ -262,6 +263,7 @@ class SemanticsNodeUpdate { this.tooltip, this.textDirection, required this.transform, + required this.hitTestTransform, required this.childrenInTraversalOrder, required this.childrenInHitTestOrder, required this.additionalActions, @@ -305,6 +307,9 @@ class SemanticsNodeUpdate { /// See [ui.SemanticsUpdateBuilder.updateNode]. final int scrollIndex; + /// See [ui.SemanticsUpdateBuilder.updateNode]. + final int? traversalParent; + /// See [ui.SemanticsUpdateBuilder.updateNode]. final double scrollPosition; @@ -359,6 +364,9 @@ class SemanticsNodeUpdate { /// See [ui.SemanticsUpdateBuilder.updateNode]. final Float32List transform; + /// See [ui.SemanticsUpdateBuilder.updateNode]. + final Float32List hitTestTransform; + /// See [ui.SemanticsUpdateBuilder.updateNode]. final Int32List childrenInTraversalOrder; @@ -640,7 +648,7 @@ abstract class SemanticRole { element.style ..position = 'absolute' ..overflow = 'visible'; - element.setAttribute('id', '$kFlutterSemanticNodePrefix${semanticsObject.id}'); + element.setAttribute('id', getIdAttribute(semanticsObject.id)); // The root node has some properties that other nodes do not. if (semanticsObject.id == 0 && !configuration.debugShowSemanticsNodes) { @@ -716,6 +724,11 @@ abstract class SemanticRole { Focusable? get focusable => _focusable; Focusable? _focusable; + /// Convenience method to get the node id with prefix. + static String getIdAttribute(int semanticsId) { + return '$kFlutterSemanticNodePrefix$semanticsId'; + } + /// Adds generic focus management features. void addFocusManagement() { addSemanticBehavior(_focusable = Focusable(semanticsObject, this)); @@ -824,6 +837,10 @@ abstract class SemanticRole { if (semanticsObject.isLocaleDirty) { semanticsObject.owner.addOneTimePostUpdateCallback(_updateLocale); } + + if (semanticsObject.isTraversalParentDirty) { + semanticsObject.owner.addOneTimePostUpdateCallback(_updateTraversalParent); + } } void _updateIdentifier() { @@ -843,7 +860,7 @@ abstract class SemanticRole { if (semanticNodeId == null) { continue; } - elementIds.add('$kFlutterSemanticNodePrefix$semanticNodeId'); + elementIds.add(getIdAttribute(semanticNodeId)); } if (elementIds.isNotEmpty) { setAttribute('aria-controls', elementIds.join(' ')); @@ -864,6 +881,32 @@ abstract class SemanticRole { setAttribute('lang', locale); } + void _updateTraversalParent() { + // Set up aria-owns relationship for traversal order. + if (semanticsObject.traversalParent != -1) { + final SemanticsObject? parent = + semanticsObject.owner._semanticsTree[semanticsObject.traversalParent!]; + if (parent != null && parent.semanticRole != null) { + final List children = parent.element.getAttribute('aria-owns')?.split(' ') ?? []; + children.add(getIdAttribute(semanticsObject.id)); + parent.element.setAttribute('aria-owns', children.join(' ')); + } + } + // Clean up aria-owns relationship. + else if (semanticsObject._previousTraversalParent != null && + semanticsObject._previousTraversalParent != -1) { + final SemanticsObject? parent = + semanticsObject.owner._semanticsTree[semanticsObject._previousTraversalParent!]; + if (parent != null) { + final List? children = parent.element.getAttribute('aria-owns')?.split(' '); + if (children != null) { + children.removeWhere((String child) => child == getIdAttribute(semanticsObject.id)); + parent.element.setAttribute('aria-owns', children.join(' ')); + } + } + } + } + /// Applies the current [SemanticsObject.validationResult] to the DOM managed /// by this role. /// @@ -1547,6 +1590,20 @@ class SemanticsObject { _dirtyFields |= _localeIndex; } + /// See [ui.SemanticsUpdateBuilder.updateNode]. + int? get traversalParent => _traversalParent; + int? _traversalParent; + int? _previousTraversalParent; + + static const int _traversalParentIndex = 1 << 29; + + /// Whether the [traversalParent] field has been updated but has not been + /// applied to the DOM yet. + bool get isTraversalParentDirty => _isDirty(_traversalParentIndex); + void _markTraversalParentDirty() { + _dirtyFields |= _traversalParentIndex; + } + /// Bitfield showing which fields have been updated but have not yet been /// applied to the DOM. /// @@ -1734,6 +1791,12 @@ class SemanticsObject { _markScrollIndexDirty(); } + if (_traversalParent != update.traversalParent) { + _previousTraversalParent = _traversalParent; + _traversalParent = update.traversalParent; + _markTraversalParentDirty(); + } + if (_scrollExtentMax != update.scrollExtentMax) { _scrollExtentMax = update.scrollExtentMax; _markScrollExtentMaxDirty(); diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart index 8fa8f10db0b..704da949962 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_test.dart @@ -145,6 +145,9 @@ void runSemanticsTests() { group('requirable', () { _testRequirable(); }); + group('traversalOrder', () { + _testSemanticsTraversalOrder(); + }); group('SemanticsValidationResult', () { _testSemanticsValidationResult(); }); @@ -5528,6 +5531,152 @@ void _testRequirable() { }); } +void _testSemanticsTraversalOrder() { + test('aria-owns is correctly set', () async { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + final tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + rect: const ui.Rect.fromLTRB(0, 0, 100, 60), + children: [ + tester.updateNode( + id: 1, + rect: const ui.Rect.fromLTRB(0, 0, 100, 20), + children: [ + tester.updateNode( + id: 4, + children: [tester.updateNode(id: 6), tester.updateNode(id: 7)], + ), + tester.updateNode(id: 5), + ], + ), + tester.updateNode(id: 2, rect: const ui.Rect.fromLTRB(0, 20, 100, 40), traversalParent: 4), + tester.updateNode(id: 3, rect: const ui.Rect.fromLTRB(0, 40, 100, 60)), + ], + ); + tester.apply(); + + expectSemanticsTree(owner(), ''' + + + + + + + + + + + +'''); + + final SemanticsObject node4 = tester.getSemanticsObject(4); + expect(node4.element.getAttribute('aria-owns'), 'flt-semantic-node-2'); + }); + + test('aria-owns is correctly set with nested traversalParent relationship', () async { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + final tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + rect: const ui.Rect.fromLTRB(0, 0, 100, 60), + children: [ + tester.updateNode( + id: 1, + rect: const ui.Rect.fromLTRB(0, 0, 100, 20), + children: [ + tester.updateNode( + id: 4, + children: [tester.updateNode(id: 6), tester.updateNode(id: 7)], + ), + tester.updateNode(id: 5), + ], + ), + tester.updateNode(id: 2, rect: const ui.Rect.fromLTRB(0, 20, 100, 40), traversalParent: 4), + tester.updateNode(id: 3, rect: const ui.Rect.fromLTRB(0, 40, 100, 60)), + tester.updateNode(id: 8, rect: const ui.Rect.fromLTRB(0, 40, 100, 60), traversalParent: 6), + ], + ); + tester.apply(); + + expectSemanticsTree(owner(), ''' + + + + + + + + + + + + +'''); + + final SemanticsObject node4 = tester.getSemanticsObject(4); + expect(node4.element.getAttribute('aria-owns'), 'flt-semantic-node-2'); + final SemanticsObject node6 = tester.getSemanticsObject(6); + expect(node6.element.getAttribute('aria-owns'), 'flt-semantic-node-8'); + }); + + test('aria-owns is correctly set when one traversalParent has multiple children', () async { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + final tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + rect: const ui.Rect.fromLTRB(0, 0, 100, 60), + children: [ + tester.updateNode( + id: 1, + rect: const ui.Rect.fromLTRB(0, 0, 100, 20), + children: [ + tester.updateNode( + id: 4, + children: [tester.updateNode(id: 6), tester.updateNode(id: 7)], + ), + tester.updateNode(id: 5), + ], + ), + tester.updateNode(id: 2, rect: const ui.Rect.fromLTRB(0, 20, 100, 40), traversalParent: 4), + tester.updateNode(id: 3, rect: const ui.Rect.fromLTRB(0, 40, 100, 60), traversalParent: 4), + tester.updateNode(id: 8, rect: const ui.Rect.fromLTRB(0, 40, 100, 60), traversalParent: 4), + ], + ); + tester.apply(); + + expectSemanticsTree(owner(), ''' + + + + + + + + + + + + +'''); + + final SemanticsObject node4 = tester.getSemanticsObject(4); + expect( + node4.element.getAttribute('aria-owns'), + 'flt-semantic-node-2 flt-semantic-node-3 flt-semantic-node-8', + ); + }); +} + void _testSemanticsValidationResult() { test('renders validation result', () { semantics() @@ -6018,6 +6167,7 @@ void updateNode( int platformViewId = -1, // -1 means not a platform view int scrollChildren = 0, int scrollIndex = 0, + int traversalParent = -1, double scrollPosition = 0.0, double scrollExtentMax = 0.0, double scrollExtentMin = 0.0, @@ -6036,6 +6186,7 @@ void updateNode( String tooltip = '', ui.TextDirection textDirection = ui.TextDirection.ltr, Float64List? transform, + Float64List? hitTestTransform, Int32List? childrenInTraversalOrder, Int32List? childrenInHitTestOrder, Int32List? additionalActions, @@ -6048,6 +6199,7 @@ void updateNode( ui.Locale? locale, }) { transform ??= Float64List.fromList(Matrix4.identity().storage); + hitTestTransform ??= Float64List.fromList(Matrix4.identity().storage); childrenInTraversalOrder ??= Int32List(0); childrenInHitTestOrder ??= Int32List(0); additionalActions ??= Int32List(0); @@ -6063,6 +6215,7 @@ void updateNode( platformViewId: platformViewId, scrollChildren: scrollChildren, scrollIndex: scrollIndex, + traversalParent: traversalParent, scrollPosition: scrollPosition, scrollExtentMax: scrollExtentMax, scrollExtentMin: scrollExtentMin, @@ -6081,6 +6234,7 @@ void updateNode( tooltip: tooltip, textDirection: textDirection, transform: transform, + hitTestTransform: hitTestTransform, childrenInTraversalOrder: childrenInTraversalOrder, childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: additionalActions, diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart index 0716b4b8f42..ddc190ef039 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_tester.dart @@ -62,6 +62,7 @@ class SemanticsTester { int? platformViewId, int? scrollChildren, int? scrollIndex, + int? traversalParent, double? scrollPosition, double? scrollExtentMax, double? scrollExtentMin, @@ -80,6 +81,7 @@ class SemanticsTester { String? tooltip, ui.TextDirection? textDirection, Float64List? transform, + Float64List? hitTestTransform, Int32List? additionalActions, List? children, int? headingLevel, @@ -194,6 +196,7 @@ class SemanticsTester { platformViewId: platformViewId ?? -1, scrollChildren: scrollChildren ?? 0, scrollIndex: scrollIndex ?? 0, + traversalParent: traversalParent ?? -1, scrollPosition: scrollPosition ?? 0, scrollExtentMax: scrollExtentMax ?? 0, scrollExtentMin: scrollExtentMin ?? 0, @@ -211,6 +214,9 @@ class SemanticsTester { decreasedValueAttributes: decreasedValueAttributes ?? const [], tooltip: tooltip ?? '', transform: transform != null ? toMatrix32(transform) : Matrix4.identity().storage, + hitTestTransform: hitTestTransform != null + ? toMatrix32(hitTestTransform) + : Matrix4.identity().storage, childrenInTraversalOrder: childIds, childrenInHitTestOrder: childIds, additionalActions: additionalActions ?? Int32List(0), diff --git a/engine/src/flutter/runtime/fixtures/runtime_test.dart b/engine/src/flutter/runtime/fixtures/runtime_test.dart index e322adcfaca..f56183540de 100644 --- a/engine/src/flutter/runtime/fixtures/runtime_test.dart +++ b/engine/src/flutter/runtime/fixtures/runtime_test.dart @@ -290,6 +290,7 @@ void sendSemanticsUpdate() { platformViewId: -1, scrollChildren: 0, scrollIndex: 0, + traversalParent: 0, scrollPosition: 0, scrollExtentMax: 0, scrollExtentMin: 0, @@ -308,6 +309,7 @@ void sendSemanticsUpdate() { tooltip: tooltip, textDirection: TextDirection.ltr, transform: transform, + hitTestTransform: transform, childrenInTraversalOrder: childrenInTraversalOrder, childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: additionalActions, diff --git a/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java index f5a502281ec..5df60b743cd 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -575,6 +575,16 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { return stringIndex == EMPTY_STRING_INDEX ? null : strings[stringIndex]; } + private static float[] getMatrix4FromBuffer(@NonNull ByteBuffer buffer, float[] transform) { + if (transform == null) { + transform = new float[16]; + } + for (int i = 0; i < 16; ++i) { + transform[i] = buffer.getFloat(); + } + return transform; + } + /** * Disconnects any listeners and/or delegates that were initialized in {@code * AccessibilityBridge}'s constructor, or added after. @@ -1622,6 +1632,7 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { * View#onHoverEvent(MotionEvent)}. */ public boolean onAccessibilityHoverEvent(MotionEvent event, boolean ignorePlatformViews) { + if (!accessibilityManager.isTouchExplorationEnabled()) { return false; } @@ -1681,6 +1692,7 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { if (flutterSemanticsTree.isEmpty()) { return; } + SemanticsNode semanticsNodeUnderCursor = getRootSemanticsNode().hitTest(new float[] {x, y, 0, 1}, ignorePlatformViews); if (semanticsNodeUnderCursor != hoveredObject) { @@ -2382,6 +2394,7 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { private int platformViewId; private int scrollChildren; private int scrollIndex; + private int traversalParent; private float scrollPosition; private float scrollExtentMax; private float scrollExtentMin; @@ -2444,6 +2457,7 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { private float right; private float bottom; private float[] transform; + private float[] hitTestTransform; private SemanticsNode parent; private List childrenInTraversalOrder = new ArrayList<>(); @@ -2603,6 +2617,7 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { platformViewId = buffer.getInt(); scrollChildren = buffer.getInt(); scrollIndex = buffer.getInt(); + traversalParent = buffer.getInt(); scrollPosition = buffer.getFloat(); scrollExtentMax = buffer.getFloat(); scrollExtentMin = buffer.getFloat(); @@ -2636,24 +2651,23 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { right = buffer.getFloat(); bottom = buffer.getFloat(); - if (transform == null) { - transform = new float[16]; - } - for (int i = 0; i < 16; ++i) { - transform[i] = buffer.getFloat(); - } + transform = getMatrix4FromBuffer(buffer, transform); + hitTestTransform = getMatrix4FromBuffer(buffer, hitTestTransform); + inverseTransformDirty = true; globalGeometryDirty = true; - final int childCount = buffer.getInt(); + final int traversalOrderChildCount = buffer.getInt(); childrenInTraversalOrder.clear(); - childrenInHitTestOrder.clear(); - for (int i = 0; i < childCount; ++i) { + for (int i = 0; i < traversalOrderChildCount; ++i) { SemanticsNode child = accessibilityBridge.getOrCreateSemanticsNode(buffer.getInt()); child.parent = this; childrenInTraversalOrder.add(child); } - for (int i = 0; i < childCount; ++i) { + + final int hitTestOrderChildCount = buffer.getInt(); + childrenInHitTestOrder.clear(); + for (int i = 0; i < hitTestOrderChildCount; ++i) { SemanticsNode child = accessibilityBridge.getOrCreateSemanticsNode(buffer.getInt()); child.parent = this; childrenInHitTestOrder.add(child); @@ -2737,7 +2751,7 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { if (inverseTransform == null) { inverseTransform = new float[16]; } - if (!Matrix.invertM(inverseTransform, 0, transform, 0)) { + if (!Matrix.invertM(inverseTransform, 0, hitTestTransform, 0)) { Arrays.fill(inverseTransform, 0); } } diff --git a/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate.cc b/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate.cc index d87804055a7..6108b019483 100644 --- a/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate.cc +++ b/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate.cc @@ -210,6 +210,7 @@ void PlatformViewAndroidDelegate::UpdateSemantics( buffer_int32[position++] = node.platformViewId; buffer_int32[position++] = node.scrollChildren; buffer_int32[position++] = node.scrollIndex; + buffer_int32[position++] = node.traversalParent; buffer_float32[position++] = static_cast(node.scrollPosition); buffer_float32[position++] = static_cast(node.scrollExtentMax); buffer_float32[position++] = static_cast(node.scrollExtentMin); @@ -250,12 +251,14 @@ void PlatformViewAndroidDelegate::UpdateSemantics( buffer_float32[position++] = node.rect.bottom(); node.transform.getColMajor(&buffer_float32[position]); position += 16; - + node.hitTestTransform.getColMajor(&buffer_float32[position]); + position += 16; buffer_int32[position++] = node.childrenInTraversalOrder.size(); for (int32_t child : node.childrenInTraversalOrder) { buffer_int32[position++] = child; } + buffer_int32[position++] = node.childrenInHitTestOrder.size(); for (int32_t child : node.childrenInHitTestOrder) { buffer_int32[position++] = child; } diff --git a/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate.h b/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate.h index b0a5a30966a..05c3b59b03f 100644 --- a/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate.h +++ b/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate.h @@ -17,7 +17,7 @@ namespace flutter { class PlatformViewAndroidDelegate { public: static constexpr size_t kBytesPerNode = - 52 * sizeof(int32_t); // The # fields in SemanticsNode + 70 * sizeof(int32_t); // The # fields in SemanticsNode static constexpr size_t kBytesPerChild = sizeof(int32_t); static constexpr size_t kBytesPerCustomAction = sizeof(int32_t); static constexpr size_t kBytesPerAction = 4 * sizeof(int32_t); diff --git a/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate_unittests.cc b/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate_unittests.cc index 2aef4a03874..2a8c614a11a 100644 --- a/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate_unittests.cc +++ b/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate_unittests.cc @@ -41,6 +41,7 @@ TEST(PlatformViewShell, UpdateSemanticsDoesFlutterViewUpdateSemantics) { buffer_int32[position++] = node0.platformViewId; buffer_int32[position++] = node0.scrollChildren; buffer_int32[position++] = node0.scrollIndex; + buffer_int32[position++] = node0.traversalParent; buffer_float32[position++] = static_cast(node0.scrollPosition); buffer_float32[position++] = static_cast(node0.scrollExtentMax); buffer_float32[position++] = static_cast(node0.scrollExtentMin); @@ -69,6 +70,8 @@ TEST(PlatformViewShell, UpdateSemanticsDoesFlutterViewUpdateSemantics) { buffer_float32[position++] = node0.rect.bottom(); node0.transform.getColMajor(&buffer_float32[position]); position += 16; + node0.hitTestTransform.getColMajor(&buffer_float32[position]); + position += 16; buffer_int32[position++] = 0; // node0.childrenInTraversalOrder.size(); buffer_int32[position++] = 0; // node0.customAccessibilityActions.size(); EXPECT_CALL(*jni_mock, @@ -109,6 +112,7 @@ TEST(PlatformViewShell, UpdateSemanticsDoesUpdateLinkUrl) { buffer_int32[position++] = node0.platformViewId; buffer_int32[position++] = node0.scrollChildren; buffer_int32[position++] = node0.scrollIndex; + buffer_int32[position++] = node0.traversalParent; buffer_float32[position++] = static_cast(node0.scrollPosition); buffer_float32[position++] = static_cast(node0.scrollExtentMax); buffer_float32[position++] = static_cast(node0.scrollExtentMin); @@ -137,6 +141,8 @@ TEST(PlatformViewShell, UpdateSemanticsDoesUpdateLinkUrl) { buffer_float32[position++] = node0.rect.bottom(); node0.transform.getColMajor(&buffer_float32[position]); position += 16; + node0.hitTestTransform.getColMajor(&buffer_float32[position]); + position += 16; buffer_int32[position++] = 0; // node0.childrenInTraversalOrder.size(); buffer_int32[position++] = 0; // node0.customAccessibilityActions.size(); EXPECT_CALL(*jni_mock, @@ -157,6 +163,7 @@ TEST(PlatformViewShell, UpdateSemanticsDoesUpdateLocale) { node0.identifier = "identifier"; node0.label = "label"; node0.locale = "es-MX"; + node0.traversalParent = -1; update.insert(std::make_pair(0, node0)); std::vector expected_buffer( @@ -177,6 +184,7 @@ TEST(PlatformViewShell, UpdateSemanticsDoesUpdateLocale) { buffer_int32[position++] = node0.platformViewId; buffer_int32[position++] = node0.scrollChildren; buffer_int32[position++] = node0.scrollIndex; + buffer_int32[position++] = node0.traversalParent; buffer_float32[position++] = static_cast(node0.scrollPosition); buffer_float32[position++] = static_cast(node0.scrollExtentMax); buffer_float32[position++] = static_cast(node0.scrollExtentMin); @@ -205,6 +213,8 @@ TEST(PlatformViewShell, UpdateSemanticsDoesUpdateLocale) { buffer_float32[position++] = node0.rect.bottom(); node0.transform.getColMajor(&buffer_float32[position]); position += 16; + node0.hitTestTransform.getColMajor(&buffer_float32[position]); + position += 16; buffer_int32[position++] = 0; // node0.childrenInTraversalOrder.size(); buffer_int32[position++] = 0; // node0.customAccessibilityActions.size(); EXPECT_CALL(*jni_mock, @@ -261,6 +271,7 @@ TEST(PlatformViewShell, buffer_int32[position++] = node0.platformViewId; buffer_int32[position++] = node0.scrollChildren; buffer_int32[position++] = node0.scrollIndex; + buffer_int32[position++] = node0.traversalParent; buffer_float32[position++] = static_cast(node0.scrollPosition); buffer_float32[position++] = static_cast(node0.scrollExtentMax); buffer_float32[position++] = static_cast(node0.scrollExtentMin); @@ -300,6 +311,8 @@ TEST(PlatformViewShell, buffer_float32[position++] = node0.rect.bottom(); node0.transform.getColMajor(&buffer_float32[position]); position += 16; + node0.hitTestTransform.getColMajor(&buffer_float32[position]); + position += 16; buffer_int32[position++] = 0; // node0.childrenInTraversalOrder.size(); buffer_int32[position++] = 0; // node0.customAccessibilityActions.size(); EXPECT_CALL(*jni_mock, diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java index dafdaa429e0..53244c07dff 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java @@ -2513,6 +2513,7 @@ public class AccessibilityBridgeTest { int platformViewId = -1; int scrollChildren = 0; int scrollIndex = 0; + int traversalParent = -1; float scrollPosition = 0.0f; float scrollExtentMax = 0.0f; float scrollExtentMin = 0.0f; @@ -2543,6 +2544,14 @@ public class AccessibilityBridgeTest { 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f }; + float[] hitTestTransform = + new float[] { + 1.0f, 0.0f, 0.0f, 0.0f, + 0.0f, 1.0f, 0.0f, 0.0f, + 0.0f, 0.0f, 1.0f, 0.0f, + 0.0f, 0.0f, 0.0f, 1.0f + }; + final List children = new ArrayList(); public void addChild(TestSemanticsNode child) { @@ -2574,6 +2583,7 @@ public class AccessibilityBridgeTest { bytes.putInt(platformViewId); bytes.putInt(scrollChildren); bytes.putInt(scrollIndex); + bytes.putInt(traversalParent); bytes.putFloat(scrollPosition); bytes.putFloat(scrollExtentMax); bytes.putFloat(scrollExtentMin); @@ -2616,12 +2626,17 @@ public class AccessibilityBridgeTest { for (int i = 0; i < 16; i++) { bytes.putFloat(transform[i]); } + // hitTestTransform. + for (int i = 0; i < 16; i++) { + bytes.putFloat(hitTestTransform[i]); + } // children in traversal order. bytes.putInt(children.size()); for (TestSemanticsNode node : children) { bytes.putInt(node.id); } // children in hit test order. + bytes.putInt(children.size()); for (TestSemanticsNode node : children) { bytes.putInt(node.id); } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm index db5100fbd16..202d54de040 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm @@ -98,15 +98,17 @@ void AccessibilityBridge::UpdateSemantics( scrollOccured = scrollOccured || [object nodeWillCauseScroll:&node]; needsAnnouncement = [object nodeShouldTriggerAnnouncement:&node]; [object setSemanticsNode:&node]; - NSUInteger newChildCount = node.childrenInTraversalOrder.size(); - NSMutableArray* newChildren = [[NSMutableArray alloc] initWithCapacity:newChildCount]; - for (NSUInteger i = 0; i < newChildCount; ++i) { + NSUInteger newChildCountInTraversalOrder = node.childrenInTraversalOrder.size(); + NSMutableArray* newChildren = + [[NSMutableArray alloc] initWithCapacity:newChildCountInTraversalOrder]; + for (NSUInteger i = 0; i < newChildCountInTraversalOrder; ++i) { SemanticsObject* child = GetOrCreateObject(node.childrenInTraversalOrder[i], nodes); [newChildren addObject:child]; } + NSUInteger newChildCountInHitTestOrder = node.childrenInHitTestOrder.size(); NSMutableArray* newChildrenInHitTestOrder = - [[NSMutableArray alloc] initWithCapacity:newChildCount]; - for (NSUInteger i = 0; i < newChildCount; ++i) { + [[NSMutableArray alloc] initWithCapacity:newChildCountInHitTestOrder]; + for (NSUInteger i = 0; i < newChildCountInHitTestOrder; ++i) { SemanticsObject* child = GetOrCreateObject(node.childrenInHitTestOrder[i], nodes); [newChildrenInHitTestOrder addObject:child]; } diff --git a/engine/src/flutter/shell/platform/embedder/fixtures/main.dart b/engine/src/flutter/shell/platform/embedder/fixtures/main.dart index d8054555fad..276df9dc68e 100644 --- a/engine/src/flutter/shell/platform/embedder/fixtures/main.dart +++ b/engine/src/flutter/shell/platform/embedder/fixtures/main.dart @@ -170,6 +170,8 @@ Future a11y_main() async { labelAttributes: [], rect: const Rect.fromLTRB(0.0, 0.0, 10.0, 10.0), transform: kTestTransform, + hitTestTransform: kTestTransform, + traversalParent: 0, childrenInTraversalOrder: Int32List.fromList([84, 96]), childrenInHitTestOrder: Int32List.fromList([96, 84]), actions: 0, @@ -206,6 +208,8 @@ Future a11y_main() async { labelAttributes: [], rect: const Rect.fromLTRB(40.0, 40.0, 80.0, 80.0), transform: kTestTransform, + hitTestTransform: kTestTransform, + traversalParent: 0, actions: 0, flags: SemanticsFlags.none, maxValueLength: 0, @@ -242,6 +246,8 @@ Future a11y_main() async { labelAttributes: [], rect: const Rect.fromLTRB(40.0, 40.0, 80.0, 80.0), transform: kTestTransform, + hitTestTransform: kTestTransform, + traversalParent: 0, childrenInTraversalOrder: Int32List.fromList([128]), childrenInHitTestOrder: Int32List.fromList([128]), actions: 0, @@ -278,6 +284,8 @@ Future a11y_main() async { labelAttributes: [], rect: const Rect.fromLTRB(40.0, 40.0, 80.0, 80.0), transform: kTestTransform, + hitTestTransform: kTestTransform, + traversalParent: 0, additionalActions: Int32List.fromList([21]), platformViewId: 0x3f3, actions: 0, @@ -350,6 +358,8 @@ Future a11y_string_attributes() async { ], rect: const Rect.fromLTRB(0.0, 0.0, 10.0, 10.0), transform: kTestTransform, + hitTestTransform: kTestTransform, + traversalParent: 0, childrenInTraversalOrder: Int32List.fromList([84, 96]), childrenInHitTestOrder: Int32List.fromList([96, 84]), actions: 0, @@ -1650,6 +1660,8 @@ Future a11y_main_multi_view() async { labelAttributes: [], rect: const Rect.fromLTRB(0.0, 0.0, 10.0, 10.0), transform: kTestTransform, + hitTestTransform: kTestTransform, + traversalParent: 0, childrenInTraversalOrder: Int32List.fromList([84, 96]), childrenInHitTestOrder: Int32List.fromList([96, 84]), actions: 0, diff --git a/engine/src/flutter/shell/platform/linux/fl_view_accessible.cc b/engine/src/flutter/shell/platform/linux/fl_view_accessible.cc index d942723d180..7eaa1414fb6 100644 --- a/engine/src/flutter/shell/platform/linux/fl_view_accessible.cc +++ b/engine/src/flutter/shell/platform/linux/fl_view_accessible.cc @@ -184,7 +184,9 @@ void fl_view_accessible_handle_update_semantics( for (size_t i = 0; i < child_count; i++) { FlAccessibleNode* child = lookup_node(self, children_in_traversal_order[i]); - g_assert(child != nullptr); + if (child == nullptr) { + continue; + } fl_accessible_node_set_parent(child, ATK_OBJECT(parent), i); g_ptr_array_add(children, child); } diff --git a/engine/src/flutter/shell/platform/windows/fixtures/main.dart b/engine/src/flutter/shell/platform/windows/fixtures/main.dart index 26f1efe1822..39a80db41ce 100644 --- a/engine/src/flutter/shell/platform/windows/fixtures/main.dart +++ b/engine/src/flutter/shell/platform/windows/fixtures/main.dart @@ -432,6 +432,7 @@ Future sendSemanticsTreeInfo() async { ui.SemanticsUpdate createSemanticsUpdate(int nodeId) { final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder(); final Float64List transform = Float64List(16); + final Float64List hitTestTransform = Float64List(16); final Int32List childrenInTraversalOrder = Int32List(0); final Int32List childrenInHitTestOrder = Int32List(0); final Int32List additionalActions = Int32List(0); @@ -450,6 +451,7 @@ Future sendSemanticsTreeInfo() async { platformViewId: -1, scrollChildren: 0, scrollIndex: 0, + traversalParent: -1, scrollPosition: 0, scrollExtentMax: 0, scrollExtentMin: 0, @@ -468,6 +470,7 @@ Future sendSemanticsTreeInfo() async { tooltip: 'tooltip', textDirection: ui.TextDirection.ltr, transform: transform, + hitTestTransform: hitTestTransform, childrenInTraversalOrder: childrenInTraversalOrder, childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: additionalActions, diff --git a/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart b/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart index 4306a3a828c..607a0f61bd6 100644 --- a/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart +++ b/engine/src/flutter/testing/ios_scenario_app/lib/src/locale_initialization.dart @@ -56,10 +56,12 @@ class LocaleInitialization extends Scenario { currentValueLength: 0, scrollChildren: 0, scrollIndex: 0, + traversalParent: -1, scrollPosition: 0.0, scrollExtentMax: 0.0, scrollExtentMin: 0.0, transform: Matrix4.identity().storage, + hitTestTransform: Matrix4.identity().storage, hint: '', hintAttributes: [], value: '', @@ -116,10 +118,12 @@ class LocaleInitialization extends Scenario { currentValueLength: 0, scrollChildren: 0, scrollIndex: 0, + traversalParent: -1, scrollPosition: 0.0, scrollExtentMax: 0.0, scrollExtentMin: 0.0, transform: Matrix4.identity().storage, + hitTestTransform: Matrix4.identity().storage, hint: '', hintAttributes: [], value: '', diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart index cf246568ea2..a3d5a07efb9 100644 --- a/packages/flutter/lib/src/rendering/custom_paint.dart +++ b/packages/flutter/lib/src/rendering/custom_paint.dart @@ -1021,6 +1021,12 @@ class RenderCustomPaint extends RenderProxyBox { if (properties.identifier != null) { config.identifier = properties.identifier!; } + if (properties.traversalParentIdentifier != null) { + config.traversalParentIdentifier = properties.traversalParentIdentifier; + } + if (properties.traversalChildIdentifier != null) { + config.traversalChildIdentifier = properties.traversalChildIdentifier; + } if (properties.tooltip != null) { config.tooltip = properties.tooltip!; } diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 3c6a6e8632e..32669a38cf7 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -1428,7 +1428,7 @@ base class PipelineOwner with DiagnosticableTreeMixin { /// /// See [RendererBinding] for an example of how this function is used. // See [_RenderObjectSemantics]'s documentation for detailed explanations on - // what this method does + // what this method does. void flushSemantics() { if (_semanticsOwner == null) { return; @@ -1461,7 +1461,7 @@ base class PipelineOwner with DiagnosticableTreeMixin { // (via SemanticsConfiguration.isBlockingSemanticsOfPreviouslyPaintedNodes) // or is hidden by parent through visitChildrenForSemantics. Otherwise, // the parent node would have updated this node's parent data and it - // the not be dirty. + // would not be dirty. // // Updating the parent data now may create a gap of render object with // dirty parent data when this branch later rejoin the rendering tree. @@ -2012,15 +2012,6 @@ abstract class RenderObject with DiagnosticableTreeMixin implements HitTestTarge RenderObject? get parent => _parent; RenderObject? _parent; - /// The semantics parent of this render object in the semantics tree. - /// - /// This is typically the same as [parent]. - /// - /// [OverlayPortal] overrides this field to change how it forms its - /// semantics sub-tree. - @visibleForOverriding - RenderObject? get semanticsParent => _parent; - /// Called by subclasses when they decide a render object is a child. /// /// Only for use by subclasses when changing their child lists. Calling this @@ -4883,6 +4874,12 @@ mixin SemanticsAnnotationsMixin on RenderObject { if (_properties.identifier != null) { config.identifier = _properties.identifier!; } + if (_properties.traversalParentIdentifier != null) { + config.traversalParentIdentifier = _properties.traversalParentIdentifier; + } + if (_properties.traversalChildIdentifier != null) { + config.traversalChildIdentifier = _properties.traversalChildIdentifier; + } if (_attributedLabel != null) { config.attributedLabel = _attributedLabel!; } @@ -5453,7 +5450,7 @@ class _RenderObjectSemantics extends _SemanticsFragment with DiagnosticableTreeM isRoot; } - bool get isRoot => renderObject.semanticsParent == null; + bool get isRoot => renderObject.parent == null; bool get shouldFormSemanticsNode { if (configProvider.effective.isSemanticBoundary) { @@ -6168,8 +6165,7 @@ class _RenderObjectSemantics extends _SemanticsFragment with DiagnosticableTreeM // node, thus marking this semantics boundary dirty is not enough, it needs // to find the first parent semantics boundary that does not have any // possible sibling node. - while (node.semanticsParent != null && - (mayProduceSiblingNodes || !isEffectiveSemanticsBoundary)) { + while (node.parent != null && (mayProduceSiblingNodes || !isEffectiveSemanticsBoundary)) { if (node != renderObject && node._semantics.parentDataDirty && !mayProduceSiblingNodes) { break; } @@ -6185,7 +6181,7 @@ class _RenderObjectSemantics extends _SemanticsFragment with DiagnosticableTreeM mayProduceSiblingNodes |= node._semantics.configProvider.effective.childConfigurationsDelegate != null; - node = node.semanticsParent!; + node = node.parent!; // If node._semantics.built is false, this branch is currently blocked. // In that case, it should continue dirty upward until it reach a // unblocked semantics boundary because blocked branch will not rebuild @@ -6211,10 +6207,7 @@ class _RenderObjectSemantics extends _SemanticsFragment with DiagnosticableTreeM } if (!node._semantics.parentDataDirty) { if (renderObject.owner != null) { - assert( - node._semantics.configProvider.effective.isSemanticBoundary || - node.semanticsParent == null, - ); + assert(node._semantics.configProvider.effective.isSemanticBoundary || node.parent == null); if (renderObject.owner!._nodesNeedingSemantics.add(node)) { renderObject.owner!.requestVisualUpdate(); } diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 0d9fc48a57a..b45ea0f6e9d 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -996,6 +996,8 @@ class SemanticsData with Diagnosticable { required this.flagsCollection, required this.actions, required this.identifier, + required this.traversalParentIdentifier, + required this.traversalChildIdentifier, required this.attributedLabel, required this.attributedValue, required this.attributedIncreasedValue, @@ -1069,6 +1071,12 @@ class SemanticsData with Diagnosticable { /// {@macro flutter.semantics.SemanticsProperties.identifier} final String identifier; + /// {@macro flutter.semantics.SemanticsProperties.traversalParentIdentifier} + final Object? traversalParentIdentifier; + + /// {@macro flutter.semantics.SemanticsProperties.traversalChildIdentifier} + final Object? traversalChildIdentifier; + /// A textual description for the current label of the node. /// /// The reading direction is given by [textDirection]. @@ -1322,6 +1330,20 @@ class SemanticsData with Diagnosticable { final List flagSummary = flagsCollection.toStrings(); properties.add(IterableProperty('flags', flagSummary, ifEmpty: null)); properties.add(StringProperty('identifier', identifier, defaultValue: '')); + properties.add( + DiagnosticsProperty( + 'traversalParentIdentifier', + traversalParentIdentifier, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'traversalChildIdentifier', + traversalChildIdentifier, + defaultValue: null, + ), + ); properties.add(AttributedStringProperty('label', attributedLabel)); properties.add(AttributedStringProperty('value', attributedValue)); properties.add(AttributedStringProperty('increasedValue', attributedIncreasedValue)); @@ -1367,6 +1389,8 @@ class SemanticsData with Diagnosticable { other.flags == flags && other.actions == actions && other.identifier == identifier && + other.traversalParentIdentifier == traversalParentIdentifier && + other.traversalChildIdentifier == traversalChildIdentifier && other.attributedLabel == attributedLabel && other.attributedValue == attributedValue && other.attributedIncreasedValue == attributedIncreasedValue && @@ -1427,6 +1451,8 @@ class SemanticsData with Diagnosticable { validationResult, controlsNodes == null ? null : Object.hashAll(controlsNodes!), inputType, + traversalParentIdentifier, + traversalChildIdentifier, ), ); @@ -1564,6 +1590,8 @@ class SemanticsProperties extends DiagnosticableTree { this.maxValueLength, this.currentValueLength, this.identifier, + this.traversalParentIdentifier, + this.traversalChildIdentifier, this.label, this.attributedLabel, this.value, @@ -1904,6 +1932,51 @@ class SemanticsProperties extends DiagnosticableTree { /// {@endtemplate} final String? identifier; + /// {@template flutter.semantics.SemanticsProperties.traversalParentIdentifier} + /// Provides an identifier for establishing parent-child relationships in the semantics + /// traversal tree. + /// + /// This property is used to create a logical parent-child relationship between + /// semantics nodes that may not be directly connected in the widget tree. It's + /// primarily used with [OverlayPortal] to ensure proper accessibility traversal + /// order when overlay content needs to be semantically connected to its parent + /// widget. + /// + /// When a semantics node has a [traversalParentIdentifier], it indicates that + /// this node can act as a parent for other nodes that reference this identifier + /// in their [traversalChildIdentifier]. This allows assistive technologies + /// to navigate the UI in the correct logical order. + /// + /// The `traversalParentIdentifier` must be unique in the semantics. No two + /// semantics node can have the same `traversalParentIdentifier`. This unique + /// identifier serves as the only reference for its traversal children. To + /// graft other nodes as the traversal children of this node, assign this same + /// value to their `traversalChildIdentifier`. + /// {@endtemplate} + final Object? traversalParentIdentifier; + + /// {@template flutter.semantics.SemanticsProperties.traversalChildIdentifier} + /// Provides an identifier for establishing parent-child relationships in the semantics + /// traversal tree. + /// + /// This property is used to create a logical parent-child relationship between + /// semantics nodes that may not be directly connected in the widget tree. It's + /// primarily used with [OverlayPortal] to ensure proper accessibility traversal + /// order when overlay content needs to be semantically connected to its parent + /// widget. + /// + /// When a semantics node has a [traversalChildIdentifier], it indicates that + /// this node should be treated as a child of another node that has this same + /// identifier as its [traversalParentIdentifier]. This allows assistive technologies + /// to navigate the UI in the correct logical order. + /// + /// The `traversalChildIdentifier` value may be duplicated across multiple + /// semantics nodes. To establish one or more nodes as the traversal children + /// of a parent node, assign this identifier the same value as the parent's + /// `traversalParentIdentifier`. + /// {@endtemplate} + final Object? traversalChildIdentifier; + /// Provides a textual description of the widget. /// /// If a label is provided, there must either by an ambient [Directionality] @@ -2502,6 +2575,20 @@ class SemanticsProperties extends DiagnosticableTree { properties.add(DiagnosticsProperty('selected', selected, defaultValue: null)); properties.add(DiagnosticsProperty('isRequired', isRequired, defaultValue: null)); properties.add(StringProperty('identifier', identifier, defaultValue: null)); + properties.add( + DiagnosticsProperty( + 'traversalParentIdentifier', + traversalParentIdentifier, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'traversalChildIdentifier', + traversalChildIdentifier, + defaultValue: null, + ), + ); properties.add(StringProperty('label', label, defaultValue: null)); properties.add( AttributedStringProperty('attributedLabel', attributedLabel, defaultValue: null), @@ -2877,9 +2964,12 @@ class SemanticsNode with DiagnosticableTreeMixin { bool get hasChildren => _children?.isNotEmpty ?? false; bool _dead = false; - /// The number of children this node has. + /// The number of children this node has in hit-test(paint) order. int get childrenCount => hasChildren ? _children!.length : 0; + /// The number of children this node has in traversal order. + int get childrenCountInTraversalOrder => _childrenInTraversalOrder().length; + /// Visits the immediate children of this node. /// /// This function calls visitor for each immediate child until visitor returns @@ -2929,6 +3019,24 @@ class SemanticsNode with DiagnosticableTreeMixin { SemanticsNode? get parent => _parent; SemanticsNode? _parent; + /// The real parent of this node in traversal order. + /// + /// This is useful for an [OverlayPortal] or a similar scenario where the node's + /// hit-test parent (i.e., [parent]) and its traversal parent (i.e., [traversalParent]) + /// are different. If this node indicates an overlay portal child, + /// [traversalParent] is its overlay portal parent node in traversal order. + /// Otherwise, it is the same as [parent]. The [traversalParent] is used when + /// the transform of this node needs to be updated in traversal order. + SemanticsNode? get traversalParent => _traversalParent ?? parent; + SemanticsNode? _traversalParent; + set traversalParent(SemanticsNode? value) { + if (_traversalParent == value) { + return; + } + _traversalParent = value; + _markDirty(); + } + /// The depth of this node in the semantics tree. /// /// The depth of nodes in a tree monotonically increases as you traverse down @@ -3040,6 +3148,14 @@ class SemanticsNode with DiagnosticableTreeMixin { assert(!owner!._detachedNodes.contains(this)); owner!._nodes.remove(id); owner!._detachedNodes.add(this); + + // Clean up the according entry in owner._traversalParentNodes map. + owner!._traversalParentNodes.removeWhere((Object key, SemanticsNode node) => node == this); + // Clean up this node from the value set in owner._traversalChildNodes map. + for (final Set childSet in owner!._traversalChildNodes.values) { + childSet.removeWhere((SemanticsNode node) => node == this); + } + _owner = null; assert(parent == null || attached == parent!.attached); if (_children != null) { @@ -3151,6 +3267,17 @@ class SemanticsNode with DiagnosticableTreeMixin { String get identifier => _identifier; String _identifier = _kEmptyConfig.identifier; + /// {@macro flutter.semantics.SemanticsProperties.traversalParentIdentifier} + Object? get traversalParentIdentifier => _traversalParentIdentifier; + Object? _traversalParentIdentifier; + + /// {@macro flutter.semantics.SemanticsProperties.traversalChildIdentifier} + Object? get traversalChildIdentifier => _traversalChildIdentifier; + Object? _traversalChildIdentifier; + + bool get _isTraversalParent => _traversalParentIdentifier != null; + bool get _isTraversalChild => _traversalChildIdentifier != null; + /// A textual description of this node. /// /// The reading direction is given by [textDirection]. @@ -3439,6 +3566,8 @@ class SemanticsNode with DiagnosticableTreeMixin { _mergeAllDescendantsIntoThisNode != config.isMergingSemanticsOfDescendants; _identifier = config.identifier; + _traversalParentIdentifier = config.traversalParentIdentifier; + _traversalChildIdentifier = config.traversalChildIdentifier; _attributedLabel = config.attributedLabel; _attributedValue = config.attributedValue; _attributedIncreasedValue = config.attributedIncreasedValue; @@ -3502,6 +3631,8 @@ class SemanticsNode with DiagnosticableTreeMixin { // must be done after the merging the its descendants. int actions = _actionsAsBits; String identifier = _identifier; + Object? traversalParentIdentifier = _traversalParentIdentifier; + Object? traversalChildIdentifier = _traversalChildIdentifier; AttributedString attributedLabel = _attributedLabel; AttributedString attributedValue = _attributedValue; AttributedString attributedIncreasedValue = _attributedIncreasedValue; @@ -3571,6 +3702,8 @@ class SemanticsNode with DiagnosticableTreeMixin { if (identifier == '') { identifier = node._identifier; } + traversalParentIdentifier ??= node.traversalParentIdentifier; + traversalChildIdentifier ??= node.traversalChildIdentifier; if (attributedValue.string == '') { attributedValue = node._attributedValue; } @@ -3650,6 +3783,8 @@ class SemanticsNode with DiagnosticableTreeMixin { flagsCollection: flags, actions: _areUserActionsBlocked ? actions & _kUnblockedUserActions : actions, identifier: identifier, + traversalParentIdentifier: traversalParentIdentifier, + traversalChildIdentifier: traversalChildIdentifier, attributedLabel: attributedLabel, attributedValue: attributedValue, attributedIncreasedValue: attributedIncreasedValue, @@ -3688,8 +3823,64 @@ class SemanticsNode with DiagnosticableTreeMixin { static final Int32List _kEmptyCustomSemanticsActionsList = Int32List(0); static final Float64List _kIdentityTransform = _initIdentityTransform(); + static Matrix4 _computeChildTransform({ + required Matrix4? parentTransform, + required Rect? parentPaintClipRect, + required Rect? parentSemanticsClipRect, + required SemanticsNode parent, + required SemanticsNode child, + }) { + final Matrix4 transform = parentTransform?.clone() ?? Matrix4.identity(); + Matrix4? parentToCommonAncestorTransform; + Matrix4? childToCommonAncestorTransform; + SemanticsNode childSemanticsNode = child; + SemanticsNode parentSemanticsNode = parent; + + // Find the common ancestor. + while (!identical(childSemanticsNode, parentSemanticsNode)) { + final int fromDepth = childSemanticsNode.depth; + final int toDepth = parentSemanticsNode.depth; + + if (fromDepth >= toDepth) { + childToCommonAncestorTransform ??= Matrix4.identity(); + childToCommonAncestorTransform.multiply(childSemanticsNode.transform ?? Matrix4.identity()); + childSemanticsNode = childSemanticsNode.traversalParent!; + } + if (fromDepth <= toDepth) { + parentToCommonAncestorTransform ??= Matrix4.identity(); + parentToCommonAncestorTransform.multiply( + parentSemanticsNode.transform ?? Matrix4.identity(), + ); + if (parentSemanticsNode.traversalParent == null) { + break; + } + parentSemanticsNode = parentSemanticsNode.traversalParent!; + } + } + + if (parentToCommonAncestorTransform != null) { + if (parentToCommonAncestorTransform.invert() != 0) { + transform.multiply(parentToCommonAncestorTransform); + } else { + transform.setZero(); + } + } + + return transform; + } + + Int32List _childrenIdInTraversalOrder() { + final List sortedChildren = _childrenInTraversalOrder(); + + final Int32List childrenInTraversalOrder = Int32List(sortedChildren.length); + for (int i = 0; i < sortedChildren.length; i += 1) { + childrenInTraversalOrder[i] = sortedChildren[i].id; + } + return childrenInTraversalOrder; + } + void _addToUpdate(SemanticsUpdateBuilder builder, Set customSemanticsActionIdsUpdate) { - assert(_dirty); + assert(_dirty || _isTraversalParent); final SemanticsData data = getSemanticsData(); assert(() { final FlutterError? error = _DebugSemanticsRoleChecks._checkSemanticsData(this); @@ -3701,15 +3892,33 @@ class SemanticsNode with DiagnosticableTreeMixin { final Int32List childrenInTraversalOrder; final Int32List childrenInHitTestOrder; if (!hasChildren || mergeAllDescendantsIntoThisNode) { - childrenInTraversalOrder = _kEmptyChildList; - childrenInHitTestOrder = _kEmptyChildList; - } else { - final int childCount = _children!.length; - final List sortedChildren = _childrenInTraversalOrder(); - childrenInTraversalOrder = Int32List(childCount); - for (int i = 0; i < childCount; i += 1) { - childrenInTraversalOrder[i] = sortedChildren[i].id; + if (_isTraversalParent && !kIsWeb) { + // If the current node is a traversal parent node but it has no + // children in hit-test order, it means childrenIntraversalOrder will + // only contain its traversalChildren in _traversalChildNodes map. + if (owner != null && owner!._traversalChildNodes.containsKey(traversalParentIdentifier)) { + final Set traversalChildren = + owner!._traversalChildNodes[traversalParentIdentifier]!; + int index = 0; + childrenInTraversalOrder = Int32List(traversalChildren.length); + for (final SemanticsNode node in traversalChildren) { + if (node.attached) { + childrenInTraversalOrder[index] = node.id; + index += 1; + } + } + } else { + childrenInTraversalOrder = _kEmptyChildList; + } + childrenInHitTestOrder = _kEmptyChildList; + } else { + childrenInTraversalOrder = _kEmptyChildList; + childrenInHitTestOrder = _kEmptyChildList; } + } else { + childrenInTraversalOrder = _childrenIdInTraversalOrder(); + + final int childCount = _children!.length; // _children is sorted in paint order, so we invert it to get the hit test // order. childrenInHitTestOrder = Int32List(childCount); @@ -3717,6 +3926,7 @@ class SemanticsNode with DiagnosticableTreeMixin { childrenInHitTestOrder[i] = _children![childCount - i - 1].id; } } + Int32List? customSemanticsActionIds; if (data.customSemanticsActionIds?.isNotEmpty ?? false) { customSemanticsActionIds = Int32List(data.customSemanticsActionIds!.length); @@ -3725,6 +3935,33 @@ class SemanticsNode with DiagnosticableTreeMixin { customSemanticsActionIdsUpdate.add(data.customSemanticsActionIds![i]); } } + + if (_isTraversalChild) { + traversalParent = owner!._traversalParentNodes[traversalChildIdentifier]; + transform = _computeChildTransform( + parentPaintClipRect: traversalParent!.parentPaintClipRect, + parentSemanticsClipRect: traversalParent!.parentSemanticsClipRect, + parentTransform: null, + parent: traversalParent!, + child: this, + ); + } + + int traversalParentId = -1; + if (data.traversalChildIdentifier != null) { + final Object identifier = data.traversalChildIdentifier!; + if (owner!._traversalParentNodes.containsKey(identifier)) { + traversalParentId = owner!._traversalParentNodes[identifier]!.id; + } + } + + final Float64List updatedTransform; + if (kIsWeb) { + updatedTransform = data.transform?.storage ?? _kIdentityTransform; + } else { + updatedTransform = transform?.storage ?? data.transform?.storage ?? _kIdentityTransform; + } + builder.updateNode( id: id, flags: data.flagsCollection, @@ -3753,7 +3990,9 @@ class SemanticsNode with DiagnosticableTreeMixin { scrollPosition: data.scrollPosition ?? double.nan, scrollExtentMax: data.scrollExtentMax ?? double.nan, scrollExtentMin: data.scrollExtentMin ?? double.nan, - transform: data.transform?.storage ?? _kIdentityTransform, + transform: updatedTransform, + traversalParent: traversalParentId, + hitTestTransform: data.transform?.storage ?? _kIdentityTransform, childrenInTraversalOrder: childrenInTraversalOrder, childrenInHitTestOrder: childrenInHitTestOrder, additionalActions: customSemanticsActionIds ?? _kEmptyCustomSemanticsActionsList, @@ -3768,8 +4007,86 @@ class SemanticsNode with DiagnosticableTreeMixin { _dirty = false; } + // Generate a children list in traversal order. Add tree grafting when needed + // so that all overlay portal child nodes have correct overlay portal parent + // in traversal order. On web, the childrenInTraversalOrder is kept the same + // as the _children(paint-order) list because of the assumption that requires both + // childrenInTraversalOrder and childrenInHitTestOrder have the same length. + // To ensure the correctness of the childrenInTraversalOrder, ARIA-owns is used + // on the web side. + List? _updateChildrenInTraversalOrder() { + if (kIsWeb) { + return _children; + } + + final List updatedChildren = []; + for (final SemanticsNode child in _children!) { + if (child._isTraversalChild && !_isTraversalParent) { + // If the child node is a traversal child, but the current node is + // not a traversal parent, it means the child node should be + // grafted to be a child of a traversal parent node that has the + // same identifier as the child. So this child should be removed from + // the current node's children list; i.e., we don't add it to + // updatedChildren list. + // + // A corner case is the traversal parent of the traversal child, in paint + // order, is the child of the traversal child. In this case, no grafting + // needed, otherwise, it will cause infinite loop. + SemanticsNode? traversalParent = + owner!._traversalParentNodes[child.getSemanticsData().traversalChildIdentifier]; + final int? traversalParentId = traversalParent?.id; + while (traversalParent != null) { + if (traversalParent == child) { + throw FlutterError( + 'The traversalParent $traversalParentId cannot be the child of the traversalChild ${child.id} in hit-test order', + ); + } + traversalParent = traversalParent.parent; + } + + continue; + } + + updatedChildren.add(child); + } + + // If the current node is a traversal parent node, it means that part of its + // traversal children might be on other branches of the hit-test tree and + // need to be grafted. To fix the traversal order, get the according traversal + // children from _traversalChildNodes and add them to the children list + // of this current node. + if (_isTraversalParent) { + if (owner != null && owner!._traversalChildNodes.containsKey(traversalParentIdentifier)) { + final Set traversalChildren = + owner!._traversalChildNodes[traversalParentIdentifier]!; + + // When traversal children are grafted from other branches, make sure + // these children are not ancestors of the traversal parent. Otherwise, + // it will cause infinite loop. + SemanticsNode currentNode = this; + while (currentNode.parent != null) { + currentNode = currentNode.parent!; + if (traversalChildren.contains(currentNode)) { + throw FlutterError( + 'The traversalParent $id cannot be the child of the traversalChild ${currentNode.id} in hit-test order', + ); + } + } + for (final SemanticsNode node in traversalChildren) { + if (node.attached) { + updatedChildren.add(node); + } + } + } + } + + return updatedChildren; + } + /// Builds a new list made of [_children] sorted in semantic traversal order. List _childrenInTraversalOrder() { + final List? updatedChildren = _updateChildrenInTraversalOrder(); + TextDirection? inheritedTextDirection = textDirection; SemanticsNode? ancestor = parent; while (inheritedTextDirection == null && ancestor != null) { @@ -3779,12 +4096,11 @@ class SemanticsNode with DiagnosticableTreeMixin { List? childrenInDefaultOrder; if (inheritedTextDirection != null) { - childrenInDefaultOrder = _childrenInDefaultOrder(_children!, inheritedTextDirection); + childrenInDefaultOrder = _childrenInDefaultOrder(updatedChildren!, inheritedTextDirection); } else { // In the absence of text direction default to paint order. - childrenInDefaultOrder = _children; + childrenInDefaultOrder = updatedChildren; } - // List.sort does not guarantee stable sort order. Therefore, children are // first partitioned into groups that have compatible sort keys, i.e. keys // in the same group can be compared to each other. These groups stay in @@ -3819,7 +4135,6 @@ class SemanticsNode with DiagnosticableTreeMixin { sortNodes.sort(); } everythingSorted.addAll(sortNodes); - return everythingSorted .map((_TraversalSortNode sortNode) => sortNode.node) .toList(); @@ -3927,6 +4242,20 @@ class SemanticsNode with DiagnosticableTreeMixin { properties.add(FlagProperty('isInvisible', value: isInvisible, ifTrue: 'invisible')); properties.add(FlagProperty('isHidden', value: flagsCollection.isHidden, ifTrue: 'HIDDEN')); properties.add(StringProperty('identifier', _identifier, defaultValue: '')); + properties.add( + DiagnosticsProperty( + 'traversalParentIdentifier', + traversalParentIdentifier, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'traversalChildIdentifier', + traversalChildIdentifier, + defaultValue: null, + ), + ); properties.add(AttributedStringProperty('label', _attributedLabel)); properties.add(AttributedStringProperty('value', _attributedValue)); properties.add(AttributedStringProperty('increasedValue', _attributedIncreasedValue)); @@ -4005,7 +4334,7 @@ class SemanticsNode with DiagnosticableTreeMixin { @override List debugDescribeChildren({ - DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.inverseHitTest, + DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.traversalOrder, }) { return debugListChildrenInOrder(childOrder) .map( @@ -4343,6 +4672,8 @@ class SemanticsOwner extends ChangeNotifier { final Set _dirtyNodes = {}; final Map _nodes = {}; final Set _detachedNodes = {}; + final Map _traversalParentNodes = {}; + final Map> _traversalChildNodes = >{}; /// The root node of the semantics tree, if any. /// @@ -4355,6 +4686,8 @@ class SemanticsOwner extends ChangeNotifier { _dirtyNodes.clear(); _nodes.clear(); _detachedNodes.clear(); + _traversalChildNodes.clear(); + _traversalParentNodes.clear(); super.dispose(); } @@ -4446,12 +4779,77 @@ class SemanticsOwner extends ChangeNotifier { node._dirty = false; // Do not send update for this node, as it's now part of its parent } } + + // Clean up the dirty entry in owner._traversalParentNodes map because it + // will be updated later. + _traversalParentNodes.removeWhere((Object key, SemanticsNode oldNode) => node == oldNode); + // Clean up the node from the value set in owner._traversalChildNodes. + for (final Set childSet in _traversalChildNodes.values) { + childSet.removeWhere((SemanticsNode oldNode) => node == oldNode); + } } } + visitedNodes.sort((SemanticsNode a, SemanticsNode b) => a.depth - b.depth); final SemanticsUpdateBuilder builder = SemanticsBinding.instance.createSemanticsUpdateBuilder(); + + final List updatedVisitedNodes = []; + for (final SemanticsNode node in visitedNodes) { - assert(node.parent?._dirty != true); // could be null (no parent) or false (not dirty) + final bool isTraversalParent = node._isTraversalParent; + final bool isTraversalChild = node._isTraversalChild; + + if (kIsWeb) { + updatedVisitedNodes.add(node); + } else { + if (!isTraversalParent && !isTraversalChild) { + updatedVisitedNodes.add(node); + continue; + } + + if (isTraversalChild) { + // If the node has a non-null `_traversalChildIdentifier`, it indicates + // that its hit-test parent and traversal parent are different, and + // its traversal parent should update its children to include this node. + // Therefore, its traversal parent node should be added to the + // `updatedVisitedNodes` list for later grafting, in order to generate + // a correct `childrenIntraversalOrder`. This is typically used in + // `OverlayPortal` widget. + final SemanticsNode? parentNode = _traversalParentNodes[node.traversalChildIdentifier]; + if (parentNode != null && !updatedVisitedNodes.contains(parentNode)) { + updatedVisitedNodes.add(parentNode); + } + } + + updatedVisitedNodes.add(node); + } + + // If the node is a traversal parent, then add it to the + // _traversalParentNodes map for later grafting. Similarly, add the node + // to the _traversalChildNodes map if it is a traversal child. + if (isTraversalParent) { + assert( + !_traversalParentNodes.containsKey(node._traversalParentIdentifier) || + _traversalParentNodes[node.traversalParentIdentifier!] == node, + 'The traversalParentIdentifier must be unique. No two semantics nodes can share the same traversalParentIdentifier.', + ); + _traversalParentNodes[node.traversalParentIdentifier!] = node; + } else if (isTraversalChild) { + _traversalChildNodes[node.traversalChildIdentifier!] ??= {}; + _traversalChildNodes[node.traversalChildIdentifier!]!.add(node); + } + } + + for (final SemanticsNode node in updatedVisitedNodes) { + assert( + node.parent?._dirty != true || node._isTraversalParent, + ); // could be null (no parent) or false (not dirty) + + // The traversalParentNode is added to updatedVisitedNodes for later + // grafting; its traversalChildren should be grafted to its children in + // the traversal order. This grafting process is skipped on web because + // the traversal order will be handled in the web engine. + final bool needUpdateTraversalParent = !kIsWeb && node._isTraversalParent; // The _serialize() method marks the node as not dirty, and // recurses through the tree to do a deep serialization of all // contiguous dirty nodes. This means that when we return here, @@ -4462,7 +4860,7 @@ class SemanticsOwner extends ChangeNotifier { // calls reset() on its SemanticsNode if onlyChanges isn't set, // which happens e.g. when the node is no longer contributing // semantics). - if (node._dirty && node.attached) { + if ((node._dirty || needUpdateTraversalParent) && node.attached) { node._addToUpdate(builder, customSemanticsActionIds); } } @@ -5344,6 +5742,28 @@ class SemanticsConfiguration { _hasBeenAnnotated = true; } + /// {@macro flutter.semantics.SemanticsProperties.traversalParentIdentifier} + Object? get traversalParentIdentifier => _traversalParentIdentifier; + Object? _traversalParentIdentifier; + set traversalParentIdentifier(Object? value) { + if (value == traversalParentIdentifier) { + return; + } + _traversalParentIdentifier = value; + _hasBeenAnnotated = true; + } + + /// {@macro flutter.semantics.SemanticsProperties.traversalChildIdentifier} + Object? get traversalChildIdentifier => _traversalChildIdentifier; + Object? _traversalChildIdentifier; + set traversalChildIdentifier(Object? value) { + if (value == traversalChildIdentifier) { + return; + } + _traversalChildIdentifier = value; + _hasBeenAnnotated = true; + } + /// {@macro flutter.semantics.SemanticsProperties.role} SemanticsRole get role => _role; SemanticsRole _role = SemanticsRole.none; @@ -6080,7 +6500,16 @@ class SemanticsConfiguration { /// Two configurations are said to be compatible if they can be added to the /// same [SemanticsNode] without losing any semantics information. bool isCompatibleWith(SemanticsConfiguration? other) { - if (other == null || !other.hasBeenAnnotated || !hasBeenAnnotated) { + if (other == null || !other.hasBeenAnnotated) { + return true; + } + // The parent node should reject child node as long as their + // traversalChildIdentifiers are different, even if the parent node has not + // been annotated. + if (_traversalChildIdentifier != other._traversalChildIdentifier) { + return false; + } + if (!hasBeenAnnotated) { return true; } if (_actionsAsBits & other._actionsAsBits != 0) { @@ -6149,6 +6578,12 @@ class SemanticsConfiguration { _platformViewId ??= child._platformViewId; _maxValueLength ??= child._maxValueLength; _currentValueLength ??= child._currentValueLength; + // A node cannot have both `_traversalChildIdentifier` and + // `_traversalParentIdentifier` not null. + if (_traversalChildIdentifier == null) { + _traversalParentIdentifier ??= child._traversalParentIdentifier; + } + _traversalChildIdentifier ??= child._traversalChildIdentifier; _headingLevel = _mergeHeadingLevels( sourceLevel: child._headingLevel, @@ -6223,6 +6658,8 @@ class SemanticsConfiguration { .._textDirection = _textDirection .._sortKey = _sortKey .._identifier = _identifier + .._traversalParentIdentifier = _traversalParentIdentifier + .._traversalChildIdentifier = _traversalChildIdentifier .._attributedLabel = _attributedLabel .._attributedIncreasedValue = _attributedIncreasedValue .._attributedValue = _attributedValue diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index a2733c8858a..089907c1b9a 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -4005,6 +4005,8 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget { required int? maxValueLength, required int? currentValueLength, required String? identifier, + required Object? traversalParentIdentifier, + required Object? traversalChildIdentifier, required String? label, required AttributedString? attributedLabel, required String? value, @@ -4087,6 +4089,8 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget { maxValueLength: maxValueLength, currentValueLength: currentValueLength, identifier: identifier, + traversalParentIdentifier: traversalParentIdentifier, + traversalChildIdentifier: traversalChildIdentifier, label: label, attributedLabel: attributedLabel, value: value, @@ -4335,6 +4339,8 @@ class SliverSemantics extends _SemanticsBase { super.maxValueLength, super.currentValueLength, super.identifier, + super.traversalParentIdentifier, + super.traversalChildIdentifier, super.label, super.attributedLabel, super.value, @@ -7911,6 +7917,8 @@ class Semantics extends _SemanticsBase { super.maxValueLength, super.currentValueLength, super.identifier, + super.traversalParentIdentifier, + super.traversalChildIdentifier, super.label, super.attributedLabel, super.value, diff --git a/packages/flutter/lib/src/widgets/overlay.dart b/packages/flutter/lib/src/widgets/overlay.dart index 307428800f1..ee7f83b74b7 100644 --- a/packages/flutter/lib/src/widgets/overlay.dart +++ b/packages/flutter/lib/src/widgets/overlay.dart @@ -1500,6 +1500,8 @@ class _RenderTheater extends RenderBox while (child != null) { visitor(child); final _TheaterParentData childParentData = child.parentData! as _TheaterParentData; + + childParentData.visitOverlayPortalChildrenOnOverlayEntry(visitor); child = childParentData.nextSibling; } } @@ -2021,12 +2023,19 @@ class _OverlayPortalState extends State { Widget build(BuildContext context) { final int? zOrderIndex = _zOrderIndex; if (zOrderIndex == null) { - return _OverlayPortal(overlayLocation: null, overlayChild: null, child: widget.child); + return _OverlayPortal( + overlayLocation: null, + overlayChild: null, + child: Semantics(traversalParentIdentifier: this, child: widget.child), + ); } return _OverlayPortal( overlayLocation: _getLocation(zOrderIndex, widget.overlayLocation), - overlayChild: _DeferredLayout(child: Builder(builder: widget.overlayChildBuilder)), - child: widget.child, + overlayChild: _DeferredLayout( + childIdentifier: this, + child: Builder(builder: widget.overlayChildBuilder), + ), + child: Semantics(traversalParentIdentifier: this, child: widget.child), ); } } @@ -2406,8 +2415,11 @@ class _DeferredLayout extends SingleChildRenderObjectWidget { // This widget must not be given a key: we currently do not support // reparenting between the overlayChild and child. required Widget child, + this.childIdentifier, }) : super(child: child); + final Object? childIdentifier; + _RenderLayoutSurrogateProxyBox getLayoutParent(BuildContext context) { return context.findAncestorRenderObjectOfType<_RenderLayoutSurrogateProxyBox>()!; } @@ -2415,7 +2427,7 @@ class _DeferredLayout extends SingleChildRenderObjectWidget { @override _RenderDeferredLayoutBox createRenderObject(BuildContext context) { final _RenderLayoutSurrogateProxyBox parent = getLayoutParent(context); - final _RenderDeferredLayoutBox renderObject = _RenderDeferredLayoutBox(parent); + final _RenderDeferredLayoutBox renderObject = _RenderDeferredLayoutBox(parent, childIdentifier); parent._deferredLayoutChild = renderObject; return renderObject; } @@ -2424,6 +2436,7 @@ class _DeferredLayout extends SingleChildRenderObjectWidget { void updateRenderObject(BuildContext context, _RenderDeferredLayoutBox renderObject) { assert(renderObject._layoutSurrogate == getLayoutParent(context)); assert(getLayoutParent(context)._deferredLayoutChild == renderObject); + renderObject.childIdentifier = childIdentifier; } } @@ -2449,11 +2462,21 @@ class _DeferredLayout extends SingleChildRenderObjectWidget { // like an `Overlay` that has only one entry. final class _RenderDeferredLayoutBox extends RenderProxyBox with _RenderTheaterMixin, LinkedListEntry<_RenderDeferredLayoutBox> { - _RenderDeferredLayoutBox(this._layoutSurrogate); + _RenderDeferredLayoutBox(this._layoutSurrogate, Object? childIdentifier) + : _childIdentifier = childIdentifier; StackParentData get stackParentData => parentData! as StackParentData; final _RenderLayoutSurrogateProxyBox _layoutSurrogate; + Object? get childIdentifier => _childIdentifier; + Object? _childIdentifier; + set childIdentifier(Object? value) { + if (_childIdentifier == childIdentifier) { + return; + } + _childIdentifier = value; + } + @override Iterable _childrenInPaintOrder() { final RenderBox? child = this.child; @@ -2492,9 +2515,6 @@ final class _RenderDeferredLayoutBox extends RenderProxyBox super.markNeedsLayout(); } - @override - RenderObject? get semanticsParent => _layoutSurrogate; - @override double? computeDryBaseline(BoxConstraints constraints, TextBaseline baseline) { final RenderBox? child = this.child; @@ -2590,6 +2610,14 @@ final class _RenderDeferredLayoutBox extends RenderProxyBox _needsLayout = false; } + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + if (childIdentifier != null) { + config.traversalChildIdentifier = childIdentifier; + } + } + @override void applyPaintTransform(RenderBox child, Matrix4 transform) { final BoxParentData childParentData = child.parentData! as BoxParentData; @@ -2646,15 +2674,6 @@ class _RenderLayoutSurrogateProxyBox extends RenderProxyBox { deferredChild._doLayoutFrom(this, constraints: BoxConstraints.tight(boxSize)); } } - - @override - void visitChildrenForSemantics(RenderObjectVisitor visitor) { - super.visitChildrenForSemantics(visitor); - final _RenderDeferredLayoutBox? deferredChild = _deferredLayoutChild; - if (deferredChild != null) { - visitor(deferredChild); - } - } } class _OverlayChildLayoutBuilder extends AbstractLayoutBuilder { @@ -2719,6 +2738,7 @@ class _RenderLayoutBuilder extends RenderProxyBox OverlayChildLayoutInfo get layoutInfo => _layoutInfo!; // The size here is the child size of the regular child in its own parent's coordinates. OverlayChildLayoutInfo? _layoutInfo; + OverlayChildLayoutInfo _computeNewLayoutInfo() { final _RenderTheater theater = this.theater; final _RenderDeferredLayoutBox parent = this.parent! as _RenderDeferredLayoutBox; diff --git a/packages/flutter/lib/src/widgets/semantics_debugger.dart b/packages/flutter/lib/src/widgets/semantics_debugger.dart index baea3206798..99ffaca1bfc 100644 --- a/packages/flutter/lib/src/widgets/semantics_debugger.dart +++ b/packages/flutter/lib/src/widgets/semantics_debugger.dart @@ -337,6 +337,9 @@ class _SemanticsDebuggerPainter extends CustomPainter { } void _paint(Canvas canvas, SemanticsNode node, int rank, int indexInParent, int level) { + if (node.traversalChildIdentifier != null) { + return; + } canvas.save(); if (node.transform != null) { canvas.transform(node.transform!.storage); diff --git a/packages/flutter/test/material/autocomplete_test.dart b/packages/flutter/test/material/autocomplete_test.dart index 23d1e0f3b39..40c056484ab 100644 --- a/packages/flutter/test/material/autocomplete_test.dart +++ b/packages/flutter/test/material/autocomplete_test.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../widgets/semantics_tester.dart'; + class User { const User({required this.email, required this.name}); @@ -816,6 +818,44 @@ void main() { expect(field2.controller!.text, textSelection); }); + testWidgets('Autocomplete suggestions are hit-tested before ListTiles', ( + WidgetTester tester, + ) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + const List options = ['Apple', 'Banana', 'Cherry']; + return options.where( + (String option) => option.toLowerCase().contains(textEditingValue.text), + ); + }, + ), + for (int i = 0; i < 3; i++) ListTile(title: Text('Item $i'), onTap: () {}), + ], + ), + ), + ), + ); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + final Finder cherryFinder = find.text('Cherry'); + expect(cherryFinder, findsOneWidget); + + await tester.tap(cherryFinder); + await tester.pump(); + + expect(find.widgetWithText(TextField, 'Cherry'), findsOneWidget); + semantics.dispose(); + }); + testWidgets('Autocomplete renders at zero area', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( diff --git a/packages/flutter/test/material/menu_anchor_test.dart b/packages/flutter/test/material/menu_anchor_test.dart index 6918d36c8f0..5f8cf92abae 100644 --- a/packages/flutter/test/material/menu_anchor_test.dart +++ b/packages/flutter/test/material/menu_anchor_test.dart @@ -4377,13 +4377,18 @@ void main() { children: [ TestSemantics( rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), - flags: [ - if (kIsWeb) SemanticsFlag.isButton, - SemanticsFlag.hasEnabledState, - SemanticsFlag.hasExpandedState, + children: [ + TestSemantics( + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + flags: [ + if (kIsWeb) SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.hasExpandedState, + ], + label: 'ABC', + textDirection: TextDirection.ltr, + ), ], - label: 'ABC', - textDirection: TextDirection.ltr, ), ], ), @@ -4425,61 +4430,67 @@ void main() { children: [ TestSemantics( id: 1, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + textDirection: TextDirection.ltr, children: [ TestSemantics( id: 2, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), children: [ TestSemantics( id: 3, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), flags: [SemanticsFlag.scopesRoute], children: [ TestSemantics( id: 4, - flags: [ - if (kIsWeb) SemanticsFlag.isButton, - SemanticsFlag.isFocused, - SemanticsFlag.hasEnabledState, - SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable, - SemanticsFlag.hasExpandedState, - SemanticsFlag.isExpanded, - ], - actions: [ - SemanticsAction.tap, - SemanticsAction.focus, - ], - label: 'ABC', - rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), - ), - TestSemantics( - id: 6, - rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 64.0), children: [ TestSemantics( id: 7, - rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0), - flags: [SemanticsFlag.hasImplicitScrolling], children: [ TestSemantics( id: 8, - label: 'Item 0', - rect: const Rect.fromLTRB(0.0, 0.0, 120.0, 48.0), - flags: [ - if (kIsWeb) SemanticsFlag.isButton, - SemanticsFlag.hasEnabledState, - SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable, - ], - actions: [ - SemanticsAction.tap, - SemanticsAction.focus, + children: [ + TestSemantics( + id: 9, + flags: [ + if (kIsWeb) SemanticsFlag.isButton, + SemanticsFlag.hasImplicitScrolling, + ], + children: [ + TestSemantics( + id: 10, + label: 'Item 0', + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + actions: [ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), + ], + ), ], ), ], ), + TestSemantics( + id: 5, + label: 'ABC', + flags: [ + SemanticsFlag.isFocused, + if (kIsWeb) SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.hasExpandedState, + SemanticsFlag.isExpanded, + ], + actions: [ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), ], ), ], @@ -4491,12 +4502,14 @@ void main() { ], ), ignoreTransform: true, + ignoreRect: true, ), ); - // Test collapsed state. await tester.tap(find.text('ABC')); await tester.pumpAndSettle(); + + expect(find.byType(MenuItemButton), findsNothing); expect( semantics, hasSemantics( @@ -4504,33 +4517,36 @@ void main() { children: [ TestSemantics( id: 1, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + textDirection: TextDirection.ltr, children: [ TestSemantics( id: 2, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), children: [ TestSemantics( id: 3, - rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), flags: [SemanticsFlag.scopesRoute], children: [ TestSemantics( id: 4, - flags: [ - if (kIsWeb) SemanticsFlag.isButton, - SemanticsFlag.hasExpandedState, - SemanticsFlag.isFocused, - SemanticsFlag.hasEnabledState, - SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable, + children: [ + TestSemantics( + id: 5, + label: 'ABC', + textDirection: TextDirection.ltr, + flags: [ + if (kIsWeb) SemanticsFlag.isButton, + SemanticsFlag.isFocused, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.hasExpandedState, + ], + actions: [ + SemanticsAction.tap, + SemanticsAction.focus, + ], + ), ], - actions: [ - SemanticsAction.tap, - SemanticsAction.focus, - ], - label: 'ABC', - rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), ), ], ), @@ -4541,12 +4557,13 @@ void main() { ], ), ignoreTransform: true, + ignoreRect: true, ), ); semantics.dispose(); }); - }); + }, skip: kIsWeb); // [intended] the web traversal order by using ARIA-OWNS. // This is a regression test for https://github.com/flutter/flutter/issues/131676. testWidgets('Material3 - Menu uses correct text styles', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/range_slider_test.dart b/packages/flutter/test/material/range_slider_test.dart index b33d4658dc2..d7033129263 100644 --- a/packages/flutter/test/material/range_slider_test.dart +++ b/packages/flutter/test/material/range_slider_test.dart @@ -1927,28 +1927,32 @@ void main() { matchesSemantics( children: [ matchesSemantics( - isEnabled: true, - isSlider: true, - isFocusable: true, - hasEnabledState: true, - hasIncreaseAction: true, - hasDecreaseAction: true, - value: '10%', - increasedValue: '10%', - decreasedValue: '5%', - label: '', - ), - matchesSemantics( - isEnabled: true, - isSlider: true, - isFocusable: true, - hasEnabledState: true, - hasIncreaseAction: true, - hasDecreaseAction: true, - value: '12%', - increasedValue: '17%', - decreasedValue: '12%', - label: '', + children: [ + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '10%', + increasedValue: '10%', + decreasedValue: '5%', + label: '', + ), + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '12%', + increasedValue: '17%', + decreasedValue: '12%', + label: '', + ), + ], ), ], ), @@ -1987,28 +1991,32 @@ void main() { matchesSemantics( children: [ matchesSemantics( - isEnabled: true, - isSlider: true, - isFocusable: true, - hasEnabledState: true, - hasIncreaseAction: true, - hasDecreaseAction: true, - value: '10%', - increasedValue: '15%', - decreasedValue: '5%', - rect: const Rect.fromLTRB(75.2, 276.0, 123.2, 324.0), - ), - matchesSemantics( - isEnabled: true, - isSlider: true, - isFocusable: true, - hasEnabledState: true, - hasIncreaseAction: true, - hasDecreaseAction: true, - value: '30%', - increasedValue: '35%', - decreasedValue: '25%', - rect: const Rect.fromLTRB(225.6, 276.0, 273.6, 324.0), + children: [ + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '10%', + increasedValue: '15%', + decreasedValue: '5%', + rect: const Rect.fromLTRB(75.2, 276.0, 123.2, 324.0), + ), + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '30%', + increasedValue: '35%', + decreasedValue: '25%', + rect: const Rect.fromLTRB(225.6, 276.0, 273.6, 324.0), + ), + ], ), ], ), @@ -2023,15 +2031,18 @@ void main() { final List rects = []; semanticsNode.visitChildren((SemanticsNode node) { node.visitChildren((SemanticsNode node) { - // Round rect values to avoid floating point errors. - rects.add( - Rect.fromLTRB( - node.rect.left.roundToDouble(), - node.rect.top.roundToDouble(), - node.rect.right.roundToDouble(), - node.rect.bottom.roundToDouble(), - ), - ); + node.visitChildren((SemanticsNode node) { + // Round rect values to avoid floating point errors. + rects.add( + Rect.fromLTRB( + node.rect.left.roundToDouble(), + node.rect.top.roundToDouble(), + node.rect.right.roundToDouble(), + node.rect.bottom.roundToDouble(), + ), + ); + return true; + }); return true; }); return true; @@ -2073,26 +2084,30 @@ void main() { matchesSemantics( children: [ matchesSemantics( - isEnabled: true, - isSlider: true, - isFocusable: true, - hasEnabledState: true, - hasIncreaseAction: true, - hasDecreaseAction: true, - value: '10%', - increasedValue: '15%', - decreasedValue: '5%', - ), - matchesSemantics( - isEnabled: true, - isSlider: true, - isFocusable: true, - hasEnabledState: true, - hasIncreaseAction: true, - hasDecreaseAction: true, - value: '30%', - increasedValue: '35%', - decreasedValue: '25%', + children: [ + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '10%', + increasedValue: '15%', + decreasedValue: '5%', + ), + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '30%', + increasedValue: '35%', + decreasedValue: '25%', + ), + ], ), ], ), @@ -2107,15 +2122,18 @@ void main() { final List rects = []; semanticsNode.visitChildren((SemanticsNode node) { node.visitChildren((SemanticsNode node) { - // Round rect values to avoid floating point errors. - rects.add( - Rect.fromLTRB( - node.rect.left.roundToDouble(), - node.rect.top.roundToDouble(), - node.rect.right.roundToDouble(), - node.rect.bottom.roundToDouble(), - ), - ); + node.visitChildren((SemanticsNode node) { + // Round rect values to avoid floating point errors. + rects.add( + Rect.fromLTRB( + node.rect.left.roundToDouble(), + node.rect.top.roundToDouble(), + node.rect.right.roundToDouble(), + node.rect.bottom.roundToDouble(), + ), + ); + return true; + }); return true; }); return true; @@ -2621,29 +2639,33 @@ void main() { matchesSemantics( children: [ matchesSemantics( - isEnabled: true, - isSlider: true, - isFocusable: true, - isFocused: true, - hasEnabledState: true, - hasIncreaseAction: true, - hasDecreaseAction: true, - value: '10%', - increasedValue: '15%', - decreasedValue: '5%', - rect: const Rect.fromLTRB(75.2, 276.0, 123.2, 324.0), - ), - matchesSemantics( - isEnabled: true, - isSlider: true, - isFocusable: true, - hasEnabledState: true, - hasIncreaseAction: true, - hasDecreaseAction: true, - value: '30%', - increasedValue: '35%', - decreasedValue: '25%', - rect: const Rect.fromLTRB(225.6, 276.0, 273.6, 324.0), + children: [ + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + isFocused: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '10%', + increasedValue: '15%', + decreasedValue: '5%', + rect: const Rect.fromLTRB(75.2, 276.0, 123.2, 324.0), + ), + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '30%', + increasedValue: '35%', + decreasedValue: '25%', + rect: const Rect.fromLTRB(225.6, 276.0, 273.6, 324.0), + ), + ], ), ], ), @@ -2664,29 +2686,33 @@ void main() { matchesSemantics( children: [ matchesSemantics( - isEnabled: true, - isSlider: true, - isFocusable: true, - hasEnabledState: true, - hasIncreaseAction: true, - hasDecreaseAction: true, - value: '10%', - increasedValue: '15%', - decreasedValue: '5%', - rect: const Rect.fromLTRB(75.2, 276.0, 123.2, 324.0), - ), - matchesSemantics( - isEnabled: true, - isSlider: true, - isFocusable: true, - isFocused: true, - hasEnabledState: true, - hasIncreaseAction: true, - hasDecreaseAction: true, - value: '30%', - increasedValue: '35%', - decreasedValue: '25%', - rect: const Rect.fromLTRB(225.6, 276.0, 273.6, 324.0), + children: [ + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '10%', + increasedValue: '15%', + decreasedValue: '5%', + rect: const Rect.fromLTRB(75.2, 276.0, 123.2, 324.0), + ), + matchesSemantics( + isEnabled: true, + isSlider: true, + isFocusable: true, + isFocused: true, + hasEnabledState: true, + hasIncreaseAction: true, + hasDecreaseAction: true, + value: '30%', + increasedValue: '35%', + decreasedValue: '25%', + rect: const Rect.fromLTRB(225.6, 276.0, 273.6, 324.0), + ), + ], ), ], ), diff --git a/packages/flutter/test/material/slider_test.dart b/packages/flutter/test/material/slider_test.dart index 20cb25ab1cb..3b0130ad29c 100644 --- a/packages/flutter/test/material/slider_test.dart +++ b/packages/flutter/test/material/slider_test.dart @@ -1352,6 +1352,7 @@ void main() { increasedValue: '55%', decreasedValue: '45%', textDirection: TextDirection.ltr, + children: [TestSemantics(id: 5)], ), ], ), @@ -1405,6 +1406,7 @@ void main() { increasedValue: '55%', decreasedValue: '45%', textDirection: TextDirection.ltr, + children: [TestSemantics(id: 5)], ), ], ), @@ -1446,6 +1448,7 @@ void main() { increasedValue: '55%', decreasedValue: '45%', textDirection: TextDirection.ltr, + children: [TestSemantics(id: 5)], ), ], ), @@ -1467,6 +1470,7 @@ void main() { TargetPlatform.fuchsia, TargetPlatform.linux, }), + skip: kIsWeb, // [intended] the web traversal order by using ARIA-OWNS. ); testWidgets( @@ -1520,6 +1524,7 @@ void main() { increasedValue: '60%', decreasedValue: '40%', textDirection: TextDirection.ltr, + children: [TestSemantics(id: 5)], ), ], ), @@ -1561,7 +1566,7 @@ void main() { flags: [SemanticsFlag.scopesRoute], children: [ TestSemantics( - id: 5, + id: 6, flags: [ SemanticsFlag.hasEnabledState, SemanticsFlag.isSlider, @@ -1570,6 +1575,7 @@ void main() { increasedValue: '60%', decreasedValue: '40%', textDirection: TextDirection.ltr, + children: [TestSemantics(id: 7)], ), ], ), @@ -1589,170 +1595,179 @@ void main() { TargetPlatform.iOS, TargetPlatform.macOS, }), + skip: kIsWeb, // [intended] the web traversal order by using ARIA-OWNS. ); - testWidgets('Slider Semantics', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); + testWidgets( + 'Slider Semantics', + (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget( - MaterialApp( - home: Directionality( - textDirection: TextDirection.ltr, - child: Material(child: Slider(value: 0.5, onChanged: (double v) {})), + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: Slider(value: 0.5, onChanged: (double v) {})), + ), ), - ), - ); + ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); - expect( - semantics, - hasSemantics( - TestSemantics.root( - children: [ - TestSemantics( - id: 1, - textDirection: TextDirection.ltr, - children: [ - TestSemantics( - id: 2, - children: [ - TestSemantics( - id: 3, - flags: [SemanticsFlag.scopesRoute], - children: [ - TestSemantics( - id: 4, - flags: [ - SemanticsFlag.hasEnabledState, - SemanticsFlag.isEnabled, - SemanticsFlag.isFocusable, - SemanticsFlag.isSlider, - ], - actions: [ - SemanticsAction.focus, - SemanticsAction.increase, - SemanticsAction.decrease, - SemanticsAction.didGainAccessibilityFocus, - ], - value: '50%', - increasedValue: '55%', - decreasedValue: '45%', - textDirection: TextDirection.ltr, - ), - ], - ), - ], - ), - ], - ), - ], + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: [ + TestSemantics( + id: 2, + children: [ + TestSemantics( + id: 3, + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 4, + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + actions: [ + SemanticsAction.focus, + SemanticsAction.increase, + SemanticsAction.decrease, + SemanticsAction.didGainAccessibilityFocus, + ], + value: '50%', + increasedValue: '55%', + decreasedValue: '45%', + textDirection: TextDirection.ltr, + children: [TestSemantics(id: 5)], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, ), - ignoreRect: true, - ignoreTransform: true, - ), - ); + ); - // Disable slider - await tester.pumpWidget( - const MaterialApp( - home: Directionality( - textDirection: TextDirection.ltr, - child: Material(child: Slider(value: 0.5, onChanged: null)), + // Disable slider + await tester.pumpWidget( + const MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material(child: Slider(value: 0.5, onChanged: null)), + ), ), - ), - ); + ); - expect( - semantics, - hasSemantics( - TestSemantics.root( - children: [ - TestSemantics( - id: 1, - textDirection: TextDirection.ltr, - children: [ - TestSemantics( - id: 2, - children: [ - TestSemantics( - id: 3, - flags: [SemanticsFlag.scopesRoute], - children: [ - TestSemantics( - id: 4, - flags: [ - SemanticsFlag.hasEnabledState, - // isFocusable is delayed by 1 frame. - SemanticsFlag.isFocusable, - SemanticsFlag.isSlider, - ], - actions: [ - SemanticsAction.focus, - SemanticsAction.didGainAccessibilityFocus, - ], - value: '50%', - increasedValue: '55%', - decreasedValue: '45%', - textDirection: TextDirection.ltr, - ), - ], - ), - ], - ), - ], - ), - ], + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: [ + TestSemantics( + id: 2, + children: [ + TestSemantics( + id: 3, + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 4, + flags: [ + SemanticsFlag.hasEnabledState, + // isFocusable is delayed by 1 frame. + SemanticsFlag.isFocusable, + SemanticsFlag.isSlider, + ], + actions: [ + SemanticsAction.focus, + SemanticsAction.didGainAccessibilityFocus, + ], + value: '50%', + increasedValue: '55%', + decreasedValue: '45%', + textDirection: TextDirection.ltr, + children: [TestSemantics(id: 5)], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, ), - ignoreRect: true, - ignoreTransform: true, - ), - ); + ); - await tester.pump(); - expect( - semantics, - hasSemantics( - TestSemantics.root( - children: [ - TestSemantics( - id: 1, - textDirection: TextDirection.ltr, - children: [ - TestSemantics( - id: 2, - children: [ - TestSemantics( - id: 3, - flags: [SemanticsFlag.scopesRoute], - children: [ - TestSemantics( - id: 4, - flags: [ - SemanticsFlag.hasEnabledState, - SemanticsFlag.isSlider, - ], - actions: [SemanticsAction.didGainAccessibilityFocus], - value: '50%', - increasedValue: '55%', - decreasedValue: '45%', - textDirection: TextDirection.ltr, - ), - ], - ), - ], - ), - ], - ), - ], + await tester.pump(); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + textDirection: TextDirection.ltr, + children: [ + TestSemantics( + id: 2, + children: [ + TestSemantics( + id: 3, + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 4, + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isSlider, + ], + actions: [SemanticsAction.didGainAccessibilityFocus], + value: '50%', + increasedValue: '55%', + decreasedValue: '45%', + textDirection: TextDirection.ltr, + children: [TestSemantics(id: 5)], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, ), - ignoreRect: true, - ignoreTransform: true, - ), - ); + ); - semantics.dispose(); - }, variant: const TargetPlatformVariant({TargetPlatform.windows})); + semantics.dispose(); + }, + variant: const TargetPlatformVariant({TargetPlatform.windows}), + skip: kIsWeb, // [intended] the web traversal order by using ARIA-OWNS. + ); testWidgets('Slider semantics with custom formatter', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); @@ -1807,6 +1822,7 @@ void main() { increasedValue: '60', decreasedValue: '20', textDirection: TextDirection.ltr, + children: [TestSemantics(id: 5)], ), ], ), @@ -1821,7 +1837,7 @@ void main() { ), ); semantics.dispose(); - }); + }, skip: kIsWeb); // [intended] the web traversal order by using ARIA-OWNS. // Regression test for https://github.com/flutter/flutter/issues/101868 testWidgets('Slider.label info should not write to semantic node', (WidgetTester tester) async { @@ -1880,6 +1896,7 @@ void main() { decreasedValue: '20', textDirection: TextDirection.ltr, label: label, + children: [TestSemantics(id: 5)], ), ], ), @@ -1894,7 +1911,7 @@ void main() { ), ); semantics.dispose(); - }); + }, skip: kIsWeb); // [intended] the web traversal order by using ARIA-OWNS. testWidgets('Material3 - Slider is focusable and has correct focus color', ( WidgetTester tester, @@ -2828,6 +2845,7 @@ void main() { increasedValue: '55%', decreasedValue: '45%', textDirection: TextDirection.ltr, + children: [TestSemantics(id: 5)], ), ], ), @@ -2849,6 +2867,7 @@ void main() { semantics.dispose(); }, variant: const TargetPlatformVariant({TargetPlatform.windows}), + skip: kIsWeb, // [intended] the web traversal order by using ARIA-OWNS. ); testWidgets('Value indicator appears when it should', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/tooltip_test.dart b/packages/flutter/test/material/tooltip_test.dart index ecb40b26df6..cd092d54da9 100644 --- a/packages/flutter/test/material/tooltip_test.dart +++ b/packages/flutter/test/material/tooltip_test.dart @@ -1993,10 +1993,20 @@ void main() { await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) - expect(semantics, hasSemantics(expected, ignoreTransform: true, ignoreRect: true)); + final TestSemantics expected1 = TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + tooltip: 'TIP', + textDirection: TextDirection.ltr, + children: [TestSemantics(id: 2)], + ), + ], + ); + expect(semantics, hasSemantics(expected1, ignoreTransform: true, ignoreRect: true)); semantics.dispose(); - }); + }, skip: kIsWeb); // [intended] the web traversal order by using ARIA-OWNS. testWidgets('Tooltip semantics does not merge into child', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); @@ -2047,17 +2057,25 @@ void main() { TestSemantics.root( children: [ TestSemantics( + id: 1, children: [ TestSemantics( + id: 5, flags: [SemanticsFlag.hasImplicitScrolling], children: [ - TestSemantics(label: 'before'), + TestSemantics(id: 2, label: 'before'), TestSemantics( + id: 3, label: 'child', tooltip: 'B', - children: [TestSemantics(label: 'B')], + children: [ + TestSemantics( + id: 6, + children: [TestSemantics(id: 7, label: 'B')], + ), + ], ), - TestSemantics(label: 'after'), + TestSemantics(id: 4, label: 'after'), ], ), ], @@ -2071,7 +2089,7 @@ void main() { ); semantics.dispose(); - }); + }, skip: kIsWeb); // [intended] the web traversal order by using ARIA-OWNS. testWidgets('Material2 - Tooltip text scales with textScaler', (WidgetTester tester) async { Widget buildApp(String text, {required TextScaler textScaler}) { diff --git a/packages/flutter/test/semantics/semantics_test.dart b/packages/flutter/test/semantics/semantics_test.dart index b9daee365f4..2d70a039528 100644 --- a/packages/flutter/test/semantics/semantics_test.dart +++ b/packages/flutter/test/semantics/semantics_test.dart @@ -704,6 +704,8 @@ void main() { ' invisible\n' ' isHidden: false\n' ' identifier: ""\n' + ' traversalParentIdentifier: null\n' + ' traversalChildIdentifier: null\n' ' label: ""\n' ' value: ""\n' ' increasedValue: ""\n' @@ -850,6 +852,8 @@ void main() { ' invisible\n' ' isHidden: false\n' ' identifier: ""\n' + ' traversalParentIdentifier: null\n' + ' traversalChildIdentifier: null\n' ' label: ""\n' ' value: ""\n' ' increasedValue: ""\n' diff --git a/packages/flutter/test/semantics/semantics_update_test.dart b/packages/flutter/test/semantics/semantics_update_test.dart index 476d4c1c9d9..7ba38a0c31b 100644 --- a/packages/flutter/test/semantics/semantics_update_test.dart +++ b/packages/flutter/test/semantics/semantics_update_test.dart @@ -203,6 +203,7 @@ class SemanticsUpdateBuilderSpy extends Fake implements ui.SemanticsUpdateBuilde required int platformViewId, required int scrollChildren, required int scrollIndex, + required int? traversalParent, required double scrollPosition, required double scrollExtentMax, required double scrollExtentMin, @@ -221,6 +222,7 @@ class SemanticsUpdateBuilderSpy extends Fake implements ui.SemanticsUpdateBuilde String? tooltip, TextDirection? textDirection, required Float64List transform, + required Float64List hitTestTransform, required Int32List childrenInTraversalOrder, required Int32List childrenInHitTestOrder, required Int32List additionalActions, diff --git a/packages/flutter/test/widgets/overlay_portal_test.dart b/packages/flutter/test/widgets/overlay_portal_test.dart index 8534966f3f6..6d3ed94e131 100644 --- a/packages/flutter/test/widgets/overlay_portal_test.dart +++ b/packages/flutter/test/widgets/overlay_portal_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/src/foundation/constants.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; @@ -212,7 +213,7 @@ void main() { }); // stop before updating semantics. await tester.pump(null, EnginePhase.composite); - expect(renderObject.debugNeedsSemanticsUpdate, isFalse); + expect(renderObject.debugNeedsSemanticsUpdate, isTrue); }); testWidgets('Safe to deactivate and re-activate OverlayPortal', (WidgetTester tester) async { @@ -2031,79 +2032,6 @@ void main() { verifyTreeIsClean(); }); - testWidgets('Nested overlay children: swap inner and outer', (WidgetTester tester) async { - final GlobalKey outerKey = GlobalKey(debugLabel: 'Original Outer Widget'); - final GlobalKey innerKey = GlobalKey(debugLabel: 'Original Inner Widget'); - - final RenderBox child1Box = RenderConstrainedBox( - additionalConstraints: const BoxConstraints(), - ); - final RenderBox child2Box = RenderConstrainedBox( - additionalConstraints: const BoxConstraints(), - ); - final RenderBox overlayChildBox = RenderConstrainedBox( - additionalConstraints: const BoxConstraints(), - ); - addTearDown(overlayChildBox.dispose); - - late StateSetter setState; - bool swapped = false; - - // WidgetToRenderBoxAdapter has its own builtin GlobalKey. - final Widget child1 = WidgetToRenderBoxAdapter(renderBox: child1Box); - final Widget child2 = WidgetToRenderBoxAdapter(renderBox: child2Box); - final Widget child3 = WidgetToRenderBoxAdapter(renderBox: overlayChildBox); - - late final OverlayEntry entry; - addTearDown(() { - entry.remove(); - entry.dispose(); - }); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: Overlay( - initialEntries: [ - entry = OverlayEntry( - builder: (BuildContext context) { - return StatefulBuilder( - builder: (BuildContext context, StateSetter stateSetter) { - setState = stateSetter; - return OverlayPortal( - key: swapped ? outerKey : innerKey, - controller: swapped ? controller2 : controller1, - overlayChildBuilder: (BuildContext context) { - return OverlayPortal( - key: swapped ? innerKey : outerKey, - controller: swapped ? controller1 : controller2, - overlayChildBuilder: (BuildContext context) { - return OverlayPortal( - controller: OverlayPortalController(), - overlayChildBuilder: (BuildContext context) => child3, - ); - }, - child: child2, - ); - }, - child: child1, - ); - }, - ); - }, - ), - ], - ), - ), - ); - - setState(() { - swapped = true; - }); - await tester.pump(); - verifyTreeIsClean(); - }); - testWidgets('Paint order', (WidgetTester tester) async { final GlobalKey outerKey = GlobalKey(debugLabel: 'Original Outer Widget'); final GlobalKey innerKey = GlobalKey(debugLabel: 'Original Inner Widget'); @@ -2866,7 +2794,7 @@ void main() { final Matrix4 node1Transform = Matrix4.identity() ..scale(3.0, 3.0, 1.0) ..translate(0.0, TestSemantics.fullScreen.height - 10.0); - final Matrix4 node4Transform = node1Transform.clone()..translate(10.0); + final Matrix4 node3Transform = node1Transform.clone()..translate(10.0); final TestSemantics expected = TestSemantics.root( children: [ @@ -2875,28 +2803,42 @@ void main() { rect: Offset.zero & const Size(10, 10), transform: node1Transform, children: [ - TestSemantics(id: 2, label: 'A', rect: Offset.zero & const Size(10, 10)), - // The crossAxisAlignment is set to `end`. The size of node 1 is 30 x 10. TestSemantics( - id: 3, - label: 'BBBB', - rect: Offset.zero & const Size(40, 10), - transform: Matrix4.translationValues(0, -rowOriginY, 0), + id: 2, + label: 'A', + rect: Offset.zero & const Size(10, 10), + children: [ + // The crossAxisAlignment is set to `end`. The size of node 1 is 30 x 10. + TestSemantics( + id: 4, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + transform: Matrix4.identity() + ..scale(1 / 3, 1 / 3, 1) + ..setTranslationRaw(0, -rowOriginY, 0), + children: [ + TestSemantics( + id: 5, + label: 'BBBB', + rect: const Rect.fromLTRB(0.0, 0.0, 40.0, 10.0), + ), + ], + ), + ], ), ], ), TestSemantics( - id: 4, + id: 3, label: 'CC', rect: Offset.zero & const Size(20, 10), - transform: node4Transform, + transform: node3Transform, ), ], ); expect(semantics, hasSemantics(expected)); semantics.dispose(); - }); + }, skip: kIsWeb); // [intended] the web traversal order by using ARIA-OWNS. testWidgets('OverlayPortal overlay child clipping', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); @@ -3010,11 +2952,10 @@ void main() { final SemanticsNode clippedOverlayChild = semantics.nodesWith(label: 'B').single; expect(clippedOverlayPortal.rect, Offset.zero & const Size(800, 10)); - expect(clippedOverlayChild.rect, Offset.zero & const Size(10, 10)); + expect(clippedOverlayChild.rect, Offset.zero & const Size(10.0, 10.0)); expect(clippedOverlayPortal.transform, isNull); - // The parent SemanticsNode is created by OverlayPortal. - expect(clippedOverlayChild.transform, Matrix4.translationValues(0.0, -600.0, 0.0)); + expect(clippedOverlayChild.transform, isNull); semantics.dispose(); }); @@ -3080,7 +3021,7 @@ void main() { await tester.pump(); expect(semantics.nodesWith(label: 'A'), isEmpty); - expect(semantics.nodesWith(label: 'B'), isEmpty); + expect(semantics.nodesWith(label: 'B'), isNotEmpty); semantics.dispose(); final RenderObject overlayRenderObject = tester.renderObject(find.byType(Overlay)); diff --git a/packages/flutter/test/widgets/semantics_test.dart b/packages/flutter/test/widgets/semantics_test.dart index 30b99e6330c..da161b0d3b9 100644 --- a/packages/flutter/test/widgets/semantics_test.dart +++ b/packages/flutter/test/widgets/semantics_test.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'dart:ui'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -2187,6 +2188,200 @@ void main() { semantics.dispose(); }); + + testWidgets('semantics grafting in traversal order', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + const String identifier = '111'; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + body: Column( + children: [ + Semantics( + traversalParentIdentifier: identifier, + child: const SizedBox.square(dimension: 10), + ), + Semantics( + traversalChildIdentifier: identifier, + child: const SizedBox.square(dimension: 10), + ), + const SizedBox.square(dimension: 10), + ], + ), + ), + ), + ); + + // Semantics tree in traversal order. On web, no grafting. + expect( + semantics, + kIsWeb + ? hasSemantics( + TestSemantics.root( + children: [ + TestSemantics(id: 1, traversalParentIdentifier: identifier), + TestSemantics(id: 2, traversalChildIdentifier: identifier), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ) + : hasSemantics( + TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + traversalParentIdentifier: identifier, + children: [ + TestSemantics(id: 2, traversalChildIdentifier: identifier), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + // Semantics tree in hit-test order. + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics(id: 1, traversalParentIdentifier: identifier), + TestSemantics(id: 2, traversalChildIdentifier: identifier), + ], + ), + ignoreRect: true, + ignoreTransform: true, + childOrder: DebugSemanticsDumpOrder.inverseHitTest, + ), + ); + + semantics.dispose(); + }); + + testWidgets('semantics grafting in traversal order with multiple same traversalChildIdentifier', ( + WidgetTester tester, + ) async { + final SemanticsTester semantics = SemanticsTester(tester); + const String identifier = '111'; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + body: Column( + children: [ + Semantics( + traversalParentIdentifier: identifier, + child: const SizedBox.square(dimension: 10), + ), + Semantics( + traversalChildIdentifier: identifier, + child: const SizedBox.square(dimension: 10), + ), + Semantics( + traversalChildIdentifier: identifier, + child: const SizedBox.square(dimension: 10), + ), + const SizedBox.square(dimension: 10), + ], + ), + ), + ), + ); + + // Semantics tree in traversal order. On web, no grafting. + expect( + semantics, + kIsWeb + ? hasSemantics( + TestSemantics.root( + children: [ + TestSemantics(id: 1, traversalParentIdentifier: identifier), + TestSemantics(id: 2, traversalChildIdentifier: identifier), + TestSemantics(id: 3, traversalChildIdentifier: identifier), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ) + : hasSemantics( + TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + traversalParentIdentifier: identifier, + children: [ + TestSemantics(id: 2, traversalChildIdentifier: identifier), + TestSemantics(id: 3, traversalChildIdentifier: identifier), + ], + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + // Semantics tree in hit-test order. + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics(id: 1, traversalParentIdentifier: identifier), + TestSemantics(id: 2, traversalChildIdentifier: identifier), + TestSemantics(id: 3, traversalChildIdentifier: identifier), + ], + ), + ignoreRect: true, + ignoreTransform: true, + childOrder: DebugSemanticsDumpOrder.inverseHitTest, + ), + ); + + semantics.dispose(); + }); + + testWidgets( + 'no grafting in traversal order when traversal child is the parent of its traversal parent', + (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + const String identifier = '111'; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + body: Column( + children: [ + Semantics( + traversalChildIdentifier: identifier, + child: TextButton( + onPressed: () {}, + child: Semantics(traversalParentIdentifier: identifier), + ), + ), + const SizedBox.square(dimension: 10), + ], + ), + ), + ), + ); + + final Object? exception = tester.takeException(); + expect(exception, isFlutterError); + final FlutterError error = exception! as FlutterError; + expect( + error.message, + 'The traversalParent 2 cannot be the child of the traversalChild 1 in hit-test order', + ); + semantics.dispose(); + }, + skip: kIsWeb, // [intended] the web traversal order by using ARIA-OWNS. + ); } class CustomSortKey extends OrdinalSortKey { diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart index 580e4fdfe87..14ff6c8d4d2 100644 --- a/packages/flutter/test/widgets/semantics_tester.dart +++ b/packages/flutter/test/widgets/semantics_tester.dart @@ -61,6 +61,8 @@ class TestSemantics { this.maxValueLength, this.currentValueLength, this.identifier = '', + this.traversalParentIdentifier, + this.traversalChildIdentifier, this.hintOverrides, }) : assert(flags is int || flags is List || flags is SemanticsFlags), assert(actions is int || actions is List), @@ -93,6 +95,8 @@ class TestSemantics { this.maxValueLength, this.currentValueLength, this.identifier = '', + this.traversalParentIdentifier, + this.traversalChildIdentifier, this.hintOverrides, }) : id = 0, assert(flags is int || flags is List || flags is SemanticsFlags), @@ -137,6 +141,8 @@ class TestSemantics { this.maxValueLength, this.currentValueLength, this.identifier = '', + this.traversalParentIdentifier, + this.traversalChildIdentifier, this.hintOverrides, }) : assert(flags is int || flags is List || flags is SemanticsFlags), assert(actions is int || actions is List), @@ -285,6 +291,16 @@ class TestSemantics { /// Defaults to an empty string if not set. final String identifier; + /// The expected traversalParentIdentifier for the node. + /// + /// Defaults to null if not set. + final Object? traversalParentIdentifier; + + /// The expected traversalChildIdentifier for the node. + /// + /// Defaults to null if not set. + final Object? traversalChildIdentifier; + /// The expected hint overrides for the node. /// /// Defaults to null if not set. @@ -310,6 +326,7 @@ class TestSemantics { bool ignoreRect = false, bool ignoreTransform = false, bool ignoreId = false, + bool ignoreTraversalIdentifier = false, DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.inverseHitTest, }) { bool fail(String message) { @@ -426,7 +443,14 @@ class TestSemantics { 'expected node id $id to have scrollIndex $scrollChildren but found scrollIndex ${nodeData.scrollChildCount}.', ); } - final int childrenCount = node.mergeAllDescendantsIntoThisNode ? 0 : node.childrenCount; + + final int childrenCount; + if (childOrder == DebugSemanticsDumpOrder.traversalOrder) { + childrenCount = node.mergeAllDescendantsIntoThisNode ? 0 : node.childrenCountInTraversalOrder; + } else { + childrenCount = node.mergeAllDescendantsIntoThisNode ? 0 : node.childrenCount; + } + if (children.length != childrenCount) { return fail( 'expected node id $id to have ${children.length} child${children.length == 1 ? "" : "ren"} but found $childrenCount.', @@ -474,10 +498,17 @@ class TestSemantics { 'expected node id $id to have current value length $currentValueLength but found current value length ${node.currentValueLength}', ); } - if (identifier != node.identifier) { - return fail( - 'expected node id $id to have identifier $identifier but found identifier ${node.identifier}', - ); + if (!ignoreTraversalIdentifier) { + if (traversalChildIdentifier != node.traversalChildIdentifier) { + return fail( + 'expected node id $id to have traversalChildIdentifier $traversalChildIdentifier but found identifier ${node.traversalChildIdentifier}', + ); + } + if (traversalParentIdentifier != node.traversalParentIdentifier) { + return fail( + 'expected node id $id to have traversalParentIdentifier $traversalParentIdentifier but found identifier ${node.traversalParentIdentifier}', + ); + } } if (hintOverrides != node.hintOverrides) { return fail( @@ -498,6 +529,7 @@ class TestSemantics { ignoreRect: ignoreRect, ignoreTransform: ignoreTransform, ignoreId: ignoreId, + ignoreTraversalIdentifier: ignoreTraversalIdentifier, childOrder: childOrder, ); if (!childMatches) { @@ -973,6 +1005,7 @@ class _HasSemantics extends Matcher { required this.ignoreRect, required this.ignoreTransform, required this.ignoreId, + required this.ignoreTraversalIdentifier, required this.childOrder, }); @@ -980,6 +1013,7 @@ class _HasSemantics extends Matcher { final bool ignoreRect; final bool ignoreTransform; final bool ignoreId; + final bool ignoreTraversalIdentifier; final DebugSemanticsDumpOrder childOrder; @override @@ -990,6 +1024,7 @@ class _HasSemantics extends Matcher { ignoreTransform: ignoreTransform, ignoreRect: ignoreRect, ignoreId: ignoreId, + ignoreTraversalIdentifier: ignoreTraversalIdentifier, childOrder: childOrder, ); if (!doesMatch) { @@ -1051,6 +1086,7 @@ Matcher hasSemantics( bool ignoreRect = false, bool ignoreTransform = false, bool ignoreId = false, + bool ignoreTraversalIdentifier = true, DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.traversalOrder, }) { return _HasSemantics( @@ -1058,6 +1094,7 @@ Matcher hasSemantics( ignoreRect: ignoreRect, ignoreTransform: ignoreTransform, ignoreId: ignoreId, + ignoreTraversalIdentifier: ignoreTraversalIdentifier, childOrder: childOrder, ); } diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index 8d5444c2209..0a7b3739a16 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -664,6 +664,8 @@ AsyncMatcher matchesReferenceImage(ui.Image image) { /// * [containsSemantics], a similar matcher without default values for flags or actions. Matcher matchesSemantics({ String? identifier, + String? traversalParentIdentifier, + String? traversalChildIdentifier, String? label, AttributedString? attributedLabel, String? hint, @@ -748,6 +750,8 @@ Matcher matchesSemantics({ }) { return _MatchesSemanticsData( identifier: identifier, + traversalParentIdentifier: traversalParentIdentifier, + traversalChildIdentifier: traversalChildIdentifier, label: label, attributedLabel: attributedLabel, hint: hint, @@ -860,6 +864,8 @@ Matcher matchesSemantics({ /// * [matchesSemantics], a similar matcher with default values for flags and actions. Matcher containsSemantics({ String? identifier, + String? traversalParentIdentifier, + String? traversalChildIdentifier, String? label, AttributedString? attributedLabel, String? hint, @@ -944,6 +950,8 @@ Matcher containsSemantics({ }) { return _MatchesSemanticsData( identifier: identifier, + traversalChildIdentifier: traversalChildIdentifier, + traversalParentIdentifier: traversalParentIdentifier, label: label, attributedLabel: attributedLabel, hint: hint, @@ -2375,6 +2383,8 @@ class _MatchesReferenceImage extends AsyncMatcher { class _MatchesSemanticsData extends Matcher { _MatchesSemanticsData({ required this.identifier, + required this.traversalParentIdentifier, + required this.traversalChildIdentifier, required this.label, required this.attributedLabel, required this.hint, @@ -2517,6 +2527,8 @@ class _MatchesSemanticsData extends Matcher { : SemanticsHintOverrides(onTapHint: onTapHint, onLongPressHint: onLongPressHint); final String? identifier; + final String? traversalParentIdentifier; + final String? traversalChildIdentifier; final String? label; final AttributedString? attributedLabel; final String? hint; diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index c172f95e9fb..e5d9c1fd99a 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -728,6 +728,8 @@ void main() { flagsCollection: allFlags, actions: actions, identifier: 'i', + traversalParentIdentifier: '01', + traversalChildIdentifier: '01', attributedLabel: AttributedString('a'), attributedIncreasedValue: AttributedString('b'), attributedValue: AttributedString('c'), @@ -1028,6 +1030,8 @@ void main() { flagsCollection: allFlags, actions: actions, identifier: 'i', + traversalChildIdentifier: '01', + traversalParentIdentifier: '01', attributedLabel: AttributedString('a'), attributedIncreasedValue: AttributedString('b'), attributedValue: AttributedString('c'), @@ -1129,6 +1133,8 @@ void main() { flagsCollection: SemanticsFlags.none, actions: 0, identifier: 'i', + traversalParentIdentifier: '01', + traversalChildIdentifier: '01', attributedLabel: AttributedString('a'), attributedIncreasedValue: AttributedString('b'), attributedValue: AttributedString('c'), @@ -1234,6 +1240,8 @@ void main() { flagsCollection: SemanticsFlags.none, actions: 0, identifier: 'i', + traversalChildIdentifier: '01', + traversalParentIdentifier: '01', attributedLabel: AttributedString('a'), attributedIncreasedValue: AttributedString('b'), attributedValue: AttributedString('c'), @@ -1266,6 +1274,8 @@ void main() { flagsCollection: allFlags, actions: allActions, identifier: 'i', + traversalChildIdentifier: '01', + traversalParentIdentifier: '01', attributedLabel: AttributedString('a'), attributedIncreasedValue: AttributedString('b'), attributedValue: AttributedString('c'), @@ -1398,6 +1408,8 @@ void main() { flagsCollection: SemanticsFlags.none, actions: SemanticsAction.customAction.index, identifier: 'i', + traversalChildIdentifier: '01', + traversalParentIdentifier: '01', attributedLabel: AttributedString('a'), attributedIncreasedValue: AttributedString('b'), attributedValue: AttributedString('c'),