Adds aria-controls support (#163894)

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

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].

<!-- Links -->
[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
This commit is contained in:
chunhtai 2025-03-06 14:41:09 -08:00 committed by GitHub
parent 66e910d27e
commit e0b9869468
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 365 additions and 12 deletions

View File

@ -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());
}

View File

@ -1129,6 +1129,7 @@ abstract class SemanticsUpdateBuilder {
int headingLevel = 0,
String linkUrl = '',
SemanticsRole role = SemanticsRole.none,
required List<String>? 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<String>? 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<String>? controlsNodes,
);
@override

View File

@ -69,7 +69,8 @@ void SemanticsUpdateBuilder::updateNode(
const tonic::Int32List& localContextActions,
int headingLevel,
std::string linkUrl,
int role) {
int role,
const std::vector<std::string>& controlsNodes) {
FML_CHECK(scrollChildren == 0 ||
(scrollChildren > 0 && childrenInHitTestOrder.data()))
<< "Semantics update contained scrollChildren but did not have "

View File

@ -68,7 +68,8 @@ class SemanticsUpdateBuilder
const tonic::Int32List& customAccessibilityActions,
int headingLevel,
std::string linkUrl,
int role);
int role,
const std::vector<std::string>& controlsNodes);
void updateCustomAction(int id,
std::string label,

View File

@ -371,6 +371,7 @@ class SemanticsUpdateBuilder {
int headingLevel = 0,
String? linkUrl,
SemanticsRole role = SemanticsRole.none,
required List<String>? 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,
),
);
}

View File

@ -69,7 +69,7 @@ class EngineAccessibilityFeatures implements ui.AccessibilityFeatures {
@override
String toString() {
final List<String> features = <String>[];
final features = <String>[];
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<String>? 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 = <String>[];
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<String>? 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<String>(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<SemanticsObject> childrenInRenderOrder = <SemanticsObject>[];
final childrenInRenderOrder = <SemanticsObject>[];
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<SemanticsObject> 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<int> intersectionIndicesOld = <int>[];
final intersectionIndicesOld = <int>[];
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<int?> longestSequence = longestIncreasingSubsequence(intersectionIndicesOld);
final List<int> stationaryIds = <int>[];
final stationaryIds = <int>[];
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<int, SemanticsObject> _semanticsTree = <int, SemanticsObject>{};
final Map<String, int> identifiersToIds = <String, int>{};
/// Map [SemanticsObject.id] to parent [SemanticsObject] it was attached to
/// this frame.
@ -2851,8 +2908,8 @@ AFTER: $description
/// Complexity: n*log(n)
List<int> longestIncreasingSubsequence(List<int> list) {
final int len = list.length;
final List<int> predecessors = <int>[];
final List<int> mins = <int>[0];
final predecessors = <int>[];
final mins = <int>[0];
int longest = 0;
for (int i = 0; i < len; i++) {
// Binary search for the largest positive `j longest`
@ -2885,7 +2942,7 @@ List<int> longestIncreasingSubsequence(List<int> list) {
}
}
// Reconstruct the longest subsequence
final List<int> seq = List<int>.filled(longest, 0);
final seq = List<int>.filled(longest, 0);
int k = mins[longest];
for (int i = longest - 1; i >= 0; i--) {
seq[i] = k;

View File

@ -551,6 +551,57 @@ bool listEquals<T>(List<T>? a, List<T>? 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<T>(List<T>? a, List<T>? 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<T, int> wordCounts = <T, int>{};
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.

View File

@ -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: <String>['a'],
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
children: <SemanticsNodeUpdate>[
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: <String>['b'],
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
children: <SemanticsNodeUpdate>[
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: <String>['a', 'b'],
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
children: <SemanticsNodeUpdate>[
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: <String>['a', 'b', 'c'],
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
children: <SemanticsNodeUpdate>[
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: <String>['a', 'b', 'd'],
rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
children: <SemanticsNodeUpdate>[
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<String>? controlsNodes,
}) {
transform ??= Float64List.fromList(Matrix4.identity().storage);
childrenInTraversalOrder ??= Int32List(0);
@ -4118,6 +4213,7 @@ void updateNode(
additionalActions: additionalActions,
headingLevel: headingLevel,
linkUrl: linkUrl,
controlsNodes: controlsNodes,
);
}

View File

@ -118,6 +118,7 @@ class SemanticsTester {
int? headingLevel,
String? linkUrl,
ui.SemanticsRole? role,
List<String>? 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;

View File

@ -154,4 +154,22 @@ void testMain() {
expect('$exception', contains('operation failed'));
}
});
test('unordered list equality', () {
expect(unorderedListEqual<int>(null, null), isTrue);
expect(unorderedListEqual<int>(<int>[], null), isTrue);
expect(unorderedListEqual<int>(<int>[], <int>[]), isTrue);
expect(unorderedListEqual<int>(<int>[1], <int>[1]), isTrue);
expect(unorderedListEqual<int>(<int>[1, 2], <int>[1, 2]), isTrue);
expect(unorderedListEqual<int>(<int>[2, 1], <int>[1, 2]), isTrue);
expect(unorderedListEqual<int>(<int>[2, 1, 3], <int>[3, 1, 2]), isTrue);
expect(unorderedListEqual<int>(<int>[1], null), isFalse);
expect(unorderedListEqual<int>(<int>[1], <int>[2]), isFalse);
expect(unorderedListEqual<int>(<int>[1, 2], <int>[2, 2]), isFalse);
expect(unorderedListEqual<int>(<int>[1, 2], <int>[2, 3]), isFalse);
expect(unorderedListEqual<int>(<int>[1, 2], <int>[3, 4]), isFalse);
expect(unorderedListEqual<int>(<int>[2, 1, 3], <int>[3, 1, 1]), isFalse);
expect(unorderedListEqual<int>(<int>[1, 1, 2], <int>[3, 1, 1]), isFalse);
});
}

View File

@ -198,6 +198,7 @@ Future<void> a11y_main() async {
tooltip: 'tooltip',
textDirection: TextDirection.ltr,
additionalActions: Int32List(0),
controlsNodes: null,
)
..updateNode(
id: 84,
@ -233,6 +234,7 @@ Future<void> a11y_main() async {
additionalActions: Int32List(0),
childrenInHitTestOrder: Int32List(0),
childrenInTraversalOrder: Int32List(0),
controlsNodes: null,
)
..updateNode(
id: 96,
@ -268,6 +270,7 @@ Future<void> a11y_main() async {
tooltip: 'tooltip',
textDirection: TextDirection.ltr,
additionalActions: Int32List(0),
controlsNodes: null,
)
..updateNode(
id: 128,
@ -303,6 +306,7 @@ Future<void> 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<void> a11y_string_attributes() async {
tooltip: 'tooltip',
textDirection: TextDirection.ltr,
additionalActions: Int32List(0),
controlsNodes: null,
);
PlatformDispatcher.instance.views.first.updateSemantics(builder.build());

View File

@ -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();

View File

@ -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.

View File

@ -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<String>? 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<Uri>('linkUrl', linkUrl, defaultValue: null));
if (controlsNodes != null) {
properties.add(IterableProperty<String>('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<String>(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<String>? 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<String>? get controlsNodes => _controlsNodes;
Set<String>? _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 <SemanticsNode>[]);
if (mergeAllDescendantsIntoThisNodeValueChanged) {
@ -3019,6 +3051,7 @@ class SemanticsNode with DiagnosticableTreeMixin {
double thickness = _thickness;
Uri? linkUrl = _linkUrl;
SemanticsRole role = _role;
Set<String>? controlsNodes = _controlsNodes;
final Set<int> customSemanticsActionIds = <int>{};
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 = <String>{...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<String>? get controlsNodes => _controlsNodes;
Set<String>? _controlsNodes;
set controlsNodes(Set<String>? 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 = <String>{..._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;
}
}

View File

@ -7382,6 +7382,7 @@ class Semantics extends SingleChildRenderObjectWidget {
VoidCallback? onFocus,
Map<CustomSemanticsAction, VoidCallback>? customSemanticsActions,
ui.SemanticsRole? role,
Set<String>? 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,
),
);

View File

@ -229,6 +229,7 @@ class SemanticsUpdateBuilderSpy extends Fake implements ui.SemanticsUpdateBuilde
int headingLevel = 0,
String? linkUrl,
ui.SemanticsRole role = ui.SemanticsRole.none,
required List<String>? controlsNodes,
}) {
// Makes sure we don't send the same id twice.
assert(!observations.containsKey(id));

View File

@ -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 <String>{'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 <String>{'abc', 'ghi'},
child: Semantics(
controlsNodes: const <String>{'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, <String>{'abc', 'ghi', 'def'});
});
testWidgets('Semantics can merge attributed strings', (WidgetTester tester) async {
final UniqueKey key = UniqueKey();
await tester.pumpWidget(

View File

@ -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);