From e0b9869468e7e1c098ea03be25e98df48114e14e Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:41:09 -0800 Subject: [PATCH] Adds aria-controls support (#163894) adding a new property in semantics properties called controlsVisibilityOfNodes, where developer can assign SemanticsProperties.identifier of other nodes to indicates which nodes' visibilities this node controls fixes https://github.com/flutter/flutter/issues/162125 ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../src/flutter/lib/ui/fixtures/ui_test.dart | 2 + engine/src/flutter/lib/ui/semantics.dart | 5 + .../ui/semantics/semantics_update_builder.cc | 3 +- .../ui/semantics/semantics_update_builder.h | 3 +- .../src/flutter/lib/web_ui/lib/semantics.dart | 2 + .../lib/src/engine/semantics/semantics.dart | 73 ++++++++++++-- .../lib/web_ui/lib/src/engine/util.dart | 51 ++++++++++ .../test/engine/semantics/semantics_test.dart | 96 +++++++++++++++++++ .../engine/semantics/semantics_tester.dart | 2 + .../lib/web_ui/test/engine/util_test.dart | 18 ++++ .../platform/embedder/fixtures/main.dart | 5 + .../lib/src/locale_initialization.dart | 2 + .../flutter/lib/src/rendering/proxy_box.dart | 4 + .../flutter/lib/src/semantics/semantics.dart | 61 +++++++++++- packages/flutter/lib/src/widgets/basic.dart | 2 + .../test/semantics/semantics_update_test.dart | 1 + packages/flutter/test/widgets/basic_test.dart | 41 ++++++++ packages/flutter_test/test/matchers_test.dart | 6 ++ 18 files changed, 365 insertions(+), 12 deletions(-) diff --git a/engine/src/flutter/lib/ui/fixtures/ui_test.dart b/engine/src/flutter/lib/ui/fixtures/ui_test.dart index b35c84d41e..c59b3cc5ac 100644 --- a/engine/src/flutter/lib/ui/fixtures/ui_test.dart +++ b/engine/src/flutter/lib/ui/fixtures/ui_test.dart @@ -234,6 +234,7 @@ void sendSemanticsUpdate() { additionalActions: additionalActions, headingLevel: 0, linkUrl: '', + controlsNodes: null, ); _semanticsUpdate(builder.build()); } @@ -287,6 +288,7 @@ void sendSemanticsUpdateWithRole() { headingLevel: 0, linkUrl: '', role: SemanticsRole.tab, + controlsNodes: null, ); _semanticsUpdate(builder.build()); } diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart index d1802a511e..240c8a9fb1 100644 --- a/engine/src/flutter/lib/ui/semantics.dart +++ b/engine/src/flutter/lib/ui/semantics.dart @@ -1129,6 +1129,7 @@ abstract class SemanticsUpdateBuilder { int headingLevel = 0, String linkUrl = '', SemanticsRole role = SemanticsRole.none, + required List? controlsNodes, }); /// Update the custom semantics action associated with the given `id`. @@ -1205,6 +1206,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 int headingLevel = 0, String linkUrl = '', SemanticsRole role = SemanticsRole.none, + required List? controlsNodes, }) { assert(_matrix4IsValid(transform)); assert( @@ -1251,6 +1253,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 headingLevel, linkUrl, role.index, + controlsNodes, ); } @@ -1296,6 +1299,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 Int32, Handle, Int32, + Handle, ) >(symbol: 'SemanticsUpdateBuilder::updateNode') external void _updateNode( @@ -1338,6 +1342,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1 int headingLevel, String linkUrl, int role, + List? controlsNodes, ); @override 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 7665a07f71..5c63c9f6e6 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc +++ b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc @@ -69,7 +69,8 @@ void SemanticsUpdateBuilder::updateNode( const tonic::Int32List& localContextActions, int headingLevel, std::string linkUrl, - int role) { + int role, + const std::vector& controlsNodes) { FML_CHECK(scrollChildren == 0 || (scrollChildren > 0 && childrenInHitTestOrder.data())) << "Semantics update contained scrollChildren but did not have " 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 8d56031310..8704196e80 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h +++ b/engine/src/flutter/lib/ui/semantics/semantics_update_builder.h @@ -68,7 +68,8 @@ class SemanticsUpdateBuilder const tonic::Int32List& customAccessibilityActions, int headingLevel, std::string linkUrl, - int role); + int role, + const std::vector& controlsNodes); void updateCustomAction(int id, std::string label, diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart index 6a8644f883..7643c8e366 100644 --- a/engine/src/flutter/lib/web_ui/lib/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart @@ -371,6 +371,7 @@ class SemanticsUpdateBuilder { int headingLevel = 0, String? linkUrl, SemanticsRole role = SemanticsRole.none, + required List? controlsNodes, }) { if (transform.length != 16) { throw ArgumentError('transform argument must have 16 entries.'); @@ -413,6 +414,7 @@ class SemanticsUpdateBuilder { headingLevel: headingLevel, linkUrl: linkUrl, role: role, + controlsNodes: controlsNodes, ), ); } 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 b1531dede9..385735ff90 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 @@ -69,7 +69,7 @@ class EngineAccessibilityFeatures implements ui.AccessibilityFeatures { @override String toString() { - final List features = []; + final features = []; if (accessibleNavigation) { features.add('accessibleNavigation'); } @@ -239,6 +239,7 @@ class SemanticsNodeUpdate { required this.headingLevel, this.linkUrl, required this.role, + required this.controlsNodes, }); /// See [ui.SemanticsUpdateBuilder.updateNode]. @@ -348,6 +349,9 @@ class SemanticsNodeUpdate { /// See [ui.SemanticsUpdateBuilder.updateNode]. final ui.SemanticsRole role; + + /// See [ui.SemanticsUpdateBuilder.updateNode]. + final List? controlsNodes; } /// Identifies [SemanticRole] implementations. @@ -672,6 +676,10 @@ abstract class SemanticRole { if (semanticsObject.isIdentifierDirty) { _updateIdentifier(); } + + if (semanticsObject.isControlsNodesDirty) { + _updateControls(); + } } void _updateIdentifier() { @@ -682,6 +690,26 @@ abstract class SemanticRole { } } + void _updateControls() { + if (semanticsObject.hasControlsNodes) { + semanticsObject.owner.addOneTimePostUpdateCallback(() { + final elementIds = []; + for (final String identifier in semanticsObject.controlsNodes!) { + final int? semanticNodeId = semanticsObject.owner.identifiersToIds[identifier]; + if (semanticNodeId == null) { + continue; + } + elementIds.add('flt-semantic-node-$semanticNodeId'); + } + if (elementIds.isNotEmpty) { + setAttribute('aria-controls', elementIds.join(' ')); + return; + } + }); + } + removeAttribute('aria-controls'); + } + /// Whether this role was disposed of. bool get isDisposed => _isDisposed; bool _isDisposed = false; @@ -1277,6 +1305,23 @@ class SemanticsObject { /// The role of this node. late ui.SemanticsRole role; + /// List of nodes whose contents are controlled by this node. + /// + /// The list contains [identifier]s of those nodes. + List? controlsNodes; + + /// Whether this object controls at least one node. + bool get hasControlsNodes => controlsNodes != null && controlsNodes!.isNotEmpty; + + static const int _controlsNodesIndex = 1 << 27; + + /// Whether the [controlsNodes] field has been updated but has not been + /// applied to the DOM yet. + bool get isControlsNodesDirty => _isDirty(_controlsNodesIndex); + void _markControlsNodesDirty() { + _dirtyFields |= _controlsNodesIndex; + } + /// Bitfield showing which fields have been updated but have not yet been /// applied to the DOM. /// @@ -1423,7 +1468,13 @@ class SemanticsObject { } if (_identifier != update.identifier) { + if (_identifier?.isNotEmpty ?? false) { + owner.identifiersToIds.remove(_identifier); + } _identifier = update.identifier; + if (_identifier?.isNotEmpty ?? false) { + owner.identifiersToIds[_identifier!] = id; + } _markIdentifierDirty(); } @@ -1569,6 +1620,11 @@ class SemanticsObject { role = update.role; + if (!unorderedListEqual(controlsNodes, update.controlsNodes)) { + controlsNodes = update.controlsNodes; + _markControlsNodesDirty(); + } + // Apply updates to the DOM. _updateRole(); @@ -1635,7 +1691,7 @@ class SemanticsObject { // Always render in traversal order, because the accessibility traversal // is determined by the DOM order of elements. - final List childrenInRenderOrder = []; + final childrenInRenderOrder = []; for (int i = 0; i < childCount; i++) { childrenInRenderOrder.add(owner._semanticsTree[childrenInTraversalOrder[i]]!); } @@ -1669,7 +1725,7 @@ class SemanticsObject { } // At this point it is guaranteed to have had a non-empty previous child list. - final List previousChildrenInRenderOrder = _currentChildrenInRenderOrder!; + final previousChildrenInRenderOrder = _currentChildrenInRenderOrder!; final int previousCount = previousChildrenInRenderOrder.length; // Both non-empty case. @@ -1690,7 +1746,7 @@ class SemanticsObject { // Indices into the old child list pointing at children that also exist in // the new child list. - final List intersectionIndicesOld = []; + final intersectionIndicesOld = []; int newIndex = 0; @@ -1724,7 +1780,7 @@ class SemanticsObject { // The longest sub-sequence in the old list maximizes the number of children // that do not need to be moved. final List longestSequence = longestIncreasingSubsequence(intersectionIndicesOld); - final List stationaryIds = []; + final stationaryIds = []; for (int i = 0; i < longestSequence.length; i += 1) { stationaryIds.add( previousChildrenInRenderOrder[intersectionIndicesOld[longestSequence[i]!]].id, @@ -2551,6 +2607,7 @@ class EngineSemanticsOwner { SemanticsUpdatePhase _phase = SemanticsUpdatePhase.idle; final Map _semanticsTree = {}; + final Map identifiersToIds = {}; /// Map [SemanticsObject.id] to parent [SemanticsObject] it was attached to /// this frame. @@ -2851,8 +2908,8 @@ AFTER: $description /// Complexity: n*log(n) List longestIncreasingSubsequence(List list) { final int len = list.length; - final List predecessors = []; - final List mins = [0]; + final predecessors = []; + final mins = [0]; int longest = 0; for (int i = 0; i < len; i++) { // Binary search for the largest positive `j ≤ longest` @@ -2885,7 +2942,7 @@ List longestIncreasingSubsequence(List list) { } } // Reconstruct the longest subsequence - final List seq = List.filled(longest, 0); + final seq = List.filled(longest, 0); int k = mins[longest]; for (int i = longest - 1; i >= 0; i--) { seq[i] = k; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/util.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/util.dart index 695b26da90..d87088ccce 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/util.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/util.dart @@ -551,6 +551,57 @@ bool listEquals(List? a, List? b) { return true; } +/// Determines if lists [a] and [b] are deep equivalent, regardless of their +/// order. +/// +/// Returns true if the lists are both null, or if they are both non-null, have +/// the same length, and contain the same elements regardless of their order. +/// Returns false otherwise. +bool unorderedListEqual(List? a, List? b) { + if (a == b) { + return true; + } + if ((a?.isEmpty ?? true) && (b?.isEmpty ?? true)) { + return true; + } + + if ((a == null) != (b == null)) { + return false; + } + // They most both be non-null now, and at least one of them is not empty. + if (a!.length != b!.length) { + return false; + } + + if (a.length == 1) { + return a.first == b.first; + } + + if (a.length == 2) { + return (a.first == b.first && a.last == b.last) || (a.last == b.first && a.first == b.last); + } + + // Complex cases. + final Map wordCounts = {}; + for (final T word in a) { + final int count = wordCounts[word] ?? 0; + wordCounts[word] = count + 1; + } + + for (final T otherWord in b) { + final int? count = wordCounts[otherWord]; + if (count == null || count == 0) { + return false; + } + if (count == 1) { + wordCounts.remove(otherWord); + } else { + wordCounts[otherWord] = count - 1; + } + } + return wordCounts.isEmpty; +} + // HTML only supports a single radius, but Flutter ImageFilter supports separate // horizontal and vertical radii. The best approximation we can provide is to // average the two radii together for a single compromise value. 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 26689bab29..2b90c94dc6 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 @@ -129,6 +129,9 @@ void runSemanticsTests() { group('table', () { _testTables(); }); + group('controlsNodes', () { + _testControlsNodes(); + }); } void _testSemanticRole() { @@ -4038,6 +4041,97 @@ void _testTables() { semantics().semanticsEnabled = false; } +void _testControlsNodes() { + test('can have multiple controlled nodes', () { + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + + final SemanticsTester tester = SemanticsTester(owner()); + tester.updateNode( + id: 0, + controlsNodes: ['a'], + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + children: [ + tester.updateNode(id: 1, identifier: 'a'), + tester.updateNode(id: 2, identifier: 'b'), + ], + ); + tester.apply(); + + SemanticsObject object = tester.getSemanticsObject(0); + expect(object.element.getAttribute('aria-controls'), 'flt-semantic-node-1'); + + tester.updateNode( + id: 0, + controlsNodes: ['b'], + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + children: [ + tester.updateNode(id: 1, identifier: 'a'), + tester.updateNode(id: 2, identifier: 'b'), + ], + ); + tester.apply(); + + object = tester.getSemanticsObject(0); + expect(object.element.getAttribute('aria-controls'), 'flt-semantic-node-2'); + + tester.updateNode( + id: 0, + controlsNodes: ['a', 'b'], + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + children: [ + tester.updateNode(id: 1, identifier: 'a'), + tester.updateNode(id: 2, identifier: 'b'), + ], + ); + tester.apply(); + + object = tester.getSemanticsObject(0); + expect(object.element.getAttribute('aria-controls'), 'flt-semantic-node-1 flt-semantic-node-2'); + + tester.updateNode( + id: 0, + controlsNodes: ['a', 'b', 'c'], + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + children: [ + tester.updateNode(id: 1, identifier: 'a'), + tester.updateNode(id: 2, identifier: 'b'), + tester.updateNode(id: 3, identifier: 'c'), + tester.updateNode(id: 4, identifier: 'd'), + ], + ); + tester.apply(); + + object = tester.getSemanticsObject(0); + expect( + object.element.getAttribute('aria-controls'), + 'flt-semantic-node-1 flt-semantic-node-2 flt-semantic-node-3', + ); + + tester.updateNode( + id: 0, + controlsNodes: ['a', 'b', 'd'], + rect: const ui.Rect.fromLTRB(0, 0, 100, 50), + children: [ + tester.updateNode(id: 1, identifier: 'a'), + tester.updateNode(id: 2, identifier: 'b'), + tester.updateNode(id: 3, identifier: 'c'), + tester.updateNode(id: 4, identifier: 'd'), + ], + ); + tester.apply(); + + object = tester.getSemanticsObject(0); + expect( + object.element.getAttribute('aria-controls'), + 'flt-semantic-node-1 flt-semantic-node-2 flt-semantic-node-4', + ); + }); + + semantics().semanticsEnabled = false; +} + /// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that /// supplies default values for semantics attributes. void updateNode( @@ -4077,6 +4171,7 @@ void updateNode( Int32List? additionalActions, int headingLevel = 0, String? linkUrl, + List? controlsNodes, }) { transform ??= Float64List.fromList(Matrix4.identity().storage); childrenInTraversalOrder ??= Int32List(0); @@ -4118,6 +4213,7 @@ void updateNode( additionalActions: additionalActions, headingLevel: headingLevel, linkUrl: linkUrl, + controlsNodes: controlsNodes, ); } 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 5932dbd4bb..7147ac5331 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 @@ -118,6 +118,7 @@ class SemanticsTester { int? headingLevel, String? linkUrl, ui.SemanticsRole? role, + List? controlsNodes, }) { // Flags if (hasCheckedState ?? false) { @@ -333,6 +334,7 @@ class SemanticsTester { headingLevel: headingLevel ?? 0, linkUrl: linkUrl, role: role ?? ui.SemanticsRole.none, + controlsNodes: controlsNodes, ); _nodeUpdates.add(update); return update; diff --git a/engine/src/flutter/lib/web_ui/test/engine/util_test.dart b/engine/src/flutter/lib/web_ui/test/engine/util_test.dart index 9f93667e32..1a74e6669f 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/util_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/util_test.dart @@ -154,4 +154,22 @@ void testMain() { expect('$exception', contains('operation failed')); } }); + + test('unordered list equality', () { + expect(unorderedListEqual(null, null), isTrue); + expect(unorderedListEqual([], null), isTrue); + expect(unorderedListEqual([], []), isTrue); + expect(unorderedListEqual([1], [1]), isTrue); + expect(unorderedListEqual([1, 2], [1, 2]), isTrue); + expect(unorderedListEqual([2, 1], [1, 2]), isTrue); + expect(unorderedListEqual([2, 1, 3], [3, 1, 2]), isTrue); + + expect(unorderedListEqual([1], null), isFalse); + expect(unorderedListEqual([1], [2]), isFalse); + expect(unorderedListEqual([1, 2], [2, 2]), isFalse); + expect(unorderedListEqual([1, 2], [2, 3]), isFalse); + expect(unorderedListEqual([1, 2], [3, 4]), isFalse); + expect(unorderedListEqual([2, 1, 3], [3, 1, 1]), isFalse); + expect(unorderedListEqual([1, 1, 2], [3, 1, 1]), isFalse); + }); } diff --git a/engine/src/flutter/shell/platform/embedder/fixtures/main.dart b/engine/src/flutter/shell/platform/embedder/fixtures/main.dart index 5ced1e22fe..4cf5b8f1bf 100644 --- a/engine/src/flutter/shell/platform/embedder/fixtures/main.dart +++ b/engine/src/flutter/shell/platform/embedder/fixtures/main.dart @@ -198,6 +198,7 @@ Future a11y_main() async { tooltip: 'tooltip', textDirection: TextDirection.ltr, additionalActions: Int32List(0), + controlsNodes: null, ) ..updateNode( id: 84, @@ -233,6 +234,7 @@ Future a11y_main() async { additionalActions: Int32List(0), childrenInHitTestOrder: Int32List(0), childrenInTraversalOrder: Int32List(0), + controlsNodes: null, ) ..updateNode( id: 96, @@ -268,6 +270,7 @@ Future a11y_main() async { tooltip: 'tooltip', textDirection: TextDirection.ltr, additionalActions: Int32List(0), + controlsNodes: null, ) ..updateNode( id: 128, @@ -303,6 +306,7 @@ Future a11y_main() async { textDirection: TextDirection.ltr, childrenInHitTestOrder: Int32List(0), childrenInTraversalOrder: Int32List(0), + controlsNodes: null, ) ..updateCustomAction(id: 21, label: 'Archive', hint: 'archive message'); @@ -390,6 +394,7 @@ Future a11y_string_attributes() async { tooltip: 'tooltip', textDirection: TextDirection.ltr, additionalActions: Int32List(0), + controlsNodes: null, ); PlatformDispatcher.instance.views.first.updateSemantics(builder.build()); 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 e13dfab398..e129f8a771 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 @@ -75,6 +75,7 @@ class LocaleInitialization extends Scenario { childrenInTraversalOrder: Int32List(0), childrenInHitTestOrder: Int32List(0), additionalActions: Int32List(0), + controlsNodes: null, ); final SemanticsUpdate semanticsUpdate = semanticsUpdateBuilder.build(); @@ -135,6 +136,7 @@ class LocaleInitialization extends Scenario { childrenInTraversalOrder: Int32List(0), childrenInHitTestOrder: Int32List(0), additionalActions: Int32List(0), + controlsNodes: null, ); final SemanticsUpdate semanticsUpdate = semanticsUpdateBuilder.build(); diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 6e9707863c..c5a4d48d1e 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -4540,6 +4540,10 @@ class RenderSemanticsAnnotations extends RenderProxyBox { if (properties.role != null) { config.role = _properties.role!; } + if (_properties.controlsNodes != null) { + config.controlsNodes = _properties.controlsNodes; + } + // Registering _perform* as action handlers instead of the user provided // ones to ensure that changing a user provided handler from a non-null to // another non-null value doesn't require a semantics update. diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 0d54d5243d..f5ad4bb371 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -595,6 +595,7 @@ class SemanticsData with Diagnosticable { required this.headingLevel, required this.linkUrl, required this.role, + required this.controlsNodes, this.tags, this.transform, this.customSemanticsActionIds, @@ -855,6 +856,11 @@ class SemanticsData with Diagnosticable { /// {@macro flutter.semantics.SemanticsNode.role} final SemanticsRole role; + /// {@macro flutter.semantics.SemanticsNode.controlsNodes} + /// + /// {@macro flutter.semantics.SemanticsProperties.controlsNodes} + final Set? controlsNodes; + /// Whether [flags] contains the given flag. bool hasFlag(SemanticsFlag flag) => (flags & flag.index) != 0; @@ -912,6 +918,9 @@ class SemanticsData with Diagnosticable { properties.add(DoubleProperty('scrollExtentMax', scrollExtentMax, defaultValue: null)); properties.add(IntProperty('headingLevel', headingLevel, defaultValue: 0)); properties.add(DiagnosticsProperty('linkUrl', linkUrl, defaultValue: null)); + if (controlsNodes != null) { + properties.add(IterableProperty('controls', controlsNodes, ifEmpty: null)); + } } @override @@ -944,7 +953,8 @@ class SemanticsData with Diagnosticable { other.headingLevel == headingLevel && other.linkUrl == linkUrl && other.role == role && - _sortedListsEqual(other.customSemanticsActionIds, customSemanticsActionIds); + _sortedListsEqual(other.customSemanticsActionIds, customSemanticsActionIds) && + setEquals(controlsNodes, other.controlsNodes); } @override @@ -978,6 +988,7 @@ class SemanticsData with Diagnosticable { linkUrl, customSemanticsActionIds == null ? null : Object.hashAll(customSemanticsActionIds!), role, + controlsNodes == null ? null : Object.hashAll(controlsNodes!), ), ); @@ -1146,6 +1157,7 @@ class SemanticsProperties extends DiagnosticableTree { this.onDismiss, this.customSemanticsActions, this.role, + this.controlsNodes, }) : assert( label == null || attributedLabel == null, 'Only one of label or attributedLabel should be provided', @@ -1947,6 +1959,17 @@ class SemanticsProperties extends DiagnosticableTree { /// {@endtemplate} final SemanticsRole? role; + /// The [SemanticsNode.identifier]s of widgets controlled by this subtree. + /// + /// {@template flutter.semantics.SemanticsProperties.controlsNodes} + /// If a widget is controlling the visibility or content of another widget, + /// for example, [Tab]s control child visibilities of [TabBarView] or + /// [ExpansionTile] controls visibility of its expanded content, one must + /// assign a [SemanticsNode.identifier] to the content and also provide a set + /// of identifiers including the content's identifier to this property. + /// {@endtemplate} + final Set? controlsNodes; + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -2904,6 +2927,14 @@ class SemanticsNode with DiagnosticableTreeMixin { SemanticsRole get role => _role; SemanticsRole _role = _kEmptyConfig.role; + /// {@template flutter.semantics.SemanticsNode.controlsNodes} + /// The [SemanticsNode.identifier]s of widgets controlled by this node. + /// {@endtemplate} + /// + /// {@macro flutter.semantics.SemanticsProperties.controlsNodes} + Set? get controlsNodes => _controlsNodes; + Set? _controlsNodes = _kEmptyConfig.controlsNodes; + bool _canPerformAction(SemanticsAction action) => _actions.containsKey(action); static final SemanticsConfiguration _kEmptyConfig = SemanticsConfiguration(); @@ -2970,6 +3001,7 @@ class SemanticsNode with DiagnosticableTreeMixin { _headingLevel = config._headingLevel; _linkUrl = config._linkUrl; _role = config._role; + _controlsNodes = config._controlsNodes; _replaceChildren(childrenInInversePaintOrder ?? const []); if (mergeAllDescendantsIntoThisNodeValueChanged) { @@ -3019,6 +3051,7 @@ class SemanticsNode with DiagnosticableTreeMixin { double thickness = _thickness; Uri? linkUrl = _linkUrl; SemanticsRole role = _role; + Set? controlsNodes = _controlsNodes; final Set customSemanticsActionIds = {}; for (final CustomSemanticsAction action in _customSemanticsActions.keys) { customSemanticsActionIds.add(CustomSemanticsAction.getIdentifier(action)); @@ -3118,6 +3151,12 @@ class SemanticsNode with DiagnosticableTreeMixin { thickness = math.max(thickness, node._thickness + node._elevation); + if (controlsNodes == null) { + controlsNodes = node._controlsNodes; + } else if (node._controlsNodes != null) { + controlsNodes = {...controlsNodes!, ...node._controlsNodes!}; + } + return true; }); } @@ -3151,6 +3190,7 @@ class SemanticsNode with DiagnosticableTreeMixin { headingLevel: headingLevel, linkUrl: linkUrl, role: role, + controlsNodes: controlsNodes, ); } @@ -3236,6 +3276,7 @@ class SemanticsNode with DiagnosticableTreeMixin { headingLevel: data.headingLevel, linkUrl: data.linkUrl?.toString() ?? '', role: data.role, + controlsNodes: data.controlsNodes?.toList(), ); _dirty = false; } @@ -5379,6 +5420,15 @@ class SemanticsConfiguration { _hasBeenAnnotated = true; } + /// The [SemanticsNode.identifier]s of widgets controlled by this node. + Set? get controlsNodes => _controlsNodes; + Set? _controlsNodes; + set controlsNodes(Set? value) { + assert(value != null); + _controlsNodes = value; + _hasBeenAnnotated = true; + } + // TAGS /// The set of tags that this configuration wants to add to all child @@ -5543,6 +5593,12 @@ class SemanticsConfiguration { _thickness = math.max(_thickness, child._thickness + child._elevation); + if (_controlsNodes == null) { + _controlsNodes = child._controlsNodes; + } else if (child._controlsNodes != null) { + _controlsNodes = {..._controlsNodes!, ...child._controlsNodes!}; + } + _hasBeenAnnotated = hasBeenAnnotated || child.hasBeenAnnotated; } @@ -5584,7 +5640,8 @@ class SemanticsConfiguration { ..isBlockingUserActions = isBlockingUserActions .._headingLevel = _headingLevel .._linkUrl = _linkUrl - .._role = _role; + .._role = _role + .._controlsNodes = _controlsNodes; } } diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 9b84f03cdd..7c26ef8ec6 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -7382,6 +7382,7 @@ class Semantics extends SingleChildRenderObjectWidget { VoidCallback? onFocus, Map? customSemanticsActions, ui.SemanticsRole? role, + Set? controlsNodes, }) : this.fromProperties( key: key, child: child, @@ -7457,6 +7458,7 @@ class Semantics extends SingleChildRenderObjectWidget { ? SemanticsHintOverrides(onTapHint: onTapHint, onLongPressHint: onLongPressHint) : null, role: role, + controlsNodes: controlsNodes, ), ); diff --git a/packages/flutter/test/semantics/semantics_update_test.dart b/packages/flutter/test/semantics/semantics_update_test.dart index 9e32ba471c..1a139c105f 100644 --- a/packages/flutter/test/semantics/semantics_update_test.dart +++ b/packages/flutter/test/semantics/semantics_update_test.dart @@ -229,6 +229,7 @@ class SemanticsUpdateBuilderSpy extends Fake implements ui.SemanticsUpdateBuilde int headingLevel = 0, String? linkUrl, ui.SemanticsRole role = ui.SemanticsRole.none, + required List? controlsNodes, }) { // Makes sure we don't send the same id twice. assert(!observations.containsKey(id)); diff --git a/packages/flutter/test/widgets/basic_test.dart b/packages/flutter/test/widgets/basic_test.dart index 84719119b2..8e3685a301 100644 --- a/packages/flutter/test/widgets/basic_test.dart +++ b/packages/flutter/test/widgets/basic_test.dart @@ -380,6 +380,47 @@ void main() { expect(attributedHint.attributes[0].range, const TextRange(start: 1, end: 2)); }); + testWidgets('Semantics can set controls visibility of nodes', (WidgetTester tester) async { + final UniqueKey key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Semantics( + key: key, + controlsNodes: const {'abc'}, + child: const Placeholder(), + ), + ), + ), + ); + final SemanticsNode node = tester.getSemantics(find.byKey(key)); + final SemanticsData data = node.getSemanticsData(); + expect(data.controlsNodes!.length, 1); + expect(data.controlsNodes!.first, 'abc'); + }); + + testWidgets('Semantics can set controls visibility of nodes', (WidgetTester tester) async { + final UniqueKey key = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Semantics( + key: key, + controlsNodes: const {'abc', 'ghi'}, + child: Semantics( + controlsNodes: const {'abc', 'def'}, + child: const Placeholder(), + ), + ), + ), + ), + ); + final SemanticsNode node = tester.getSemantics(find.byKey(key)); + final SemanticsData data = node.getSemanticsData(); + expect(data.controlsNodes!.length, 3); + expect(data.controlsNodes, {'abc', 'ghi', 'def'}); + }); + testWidgets('Semantics can merge attributed strings', (WidgetTester tester) async { final UniqueKey key = UniqueKey(); await tester.pumpWidget( diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index 6054fc70a4..5bb2f681df 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -730,6 +730,7 @@ void main() { headingLevel: 0, linkUrl: Uri(path: 'l'), role: ui.SemanticsRole.none, + controlsNodes: null, ); final _FakeSemanticsNode node = _FakeSemanticsNode(data); @@ -1029,6 +1030,7 @@ void main() { headingLevel: 0, linkUrl: Uri(path: 'l'), role: ui.SemanticsRole.none, + controlsNodes: null, ); final _FakeSemanticsNode node = _FakeSemanticsNode(data); @@ -1125,6 +1127,7 @@ void main() { headingLevel: 0, linkUrl: null, role: ui.SemanticsRole.none, + controlsNodes: null, ); final _FakeSemanticsNode node = _FakeSemanticsNode(data); @@ -1228,6 +1231,7 @@ void main() { headingLevel: 0, linkUrl: null, role: ui.SemanticsRole.none, + controlsNodes: null, ); final _FakeSemanticsNode emptyNode = _FakeSemanticsNode(emptyData); @@ -1259,6 +1263,7 @@ void main() { headingLevel: 0, linkUrl: Uri(path: 'l'), role: ui.SemanticsRole.none, + controlsNodes: null, ); final _FakeSemanticsNode fullNode = _FakeSemanticsNode(fullData); @@ -1346,6 +1351,7 @@ void main() { headingLevel: 0, linkUrl: null, role: ui.SemanticsRole.none, + controlsNodes: null, ); final _FakeSemanticsNode node = _FakeSemanticsNode(data);