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:
parent
66e910d27e
commit
e0b9869468
@ -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());
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 "
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
@ -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());
|
||||
|
@ -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();
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -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));
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user