Add blockAccessibilityFocus flag (#175551)

Add a new flag for a11y focusable

- Accessibility focus, which is the focus used by screen readers like
TalkBack and VoiceOver, is different from input focus.
- Our current logic use some existing flags to decide if a node is
accessibilty focusable. like "if it's a slider / has a check state / has
keyboard focus/..., then it's a11y focusable"
ecbb115ae3/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java (L98)
- but we lack the ability to explicitly set a node to be unfocusable in
a11y!
- This flag can be used to explicitly set some semantics nodes to be
unfocusable in a11y mode. if it is false, we fall back to the logic "if
it's a slider / has a check state / has keyboard focus/..., then it's
a11y focusable"

future use case 1:
user can set live region to be not focusable, so when content changes,
it will still announce, but the content can't be focused by swiping.
future use case 2:
when pushing a new route like a dialog, setting the semantics nodes in
old pages to be un focusable.




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

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- 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:
Hannah Jin 2025-11-03 14:18:33 -08:00 committed by GitHub
parent 43f7a1ea1b
commit ed19f47bec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 534 additions and 15 deletions

View File

@ -1241,6 +1241,7 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
this.isLink = false,
this.isSlider = false,
this.isKeyboardKey = false,
this.isAccessibilityFocusBlocked = false,
}) {
_initSemanticsFlags(
this,
@ -1267,6 +1268,7 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
isLink,
isSlider,
isKeyboardKey,
isAccessibilityFocusBlocked,
);
}
@ -1296,6 +1298,7 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
Bool,
Bool,
Bool,
Bool,
)
>(symbol: 'NativeSemanticsFlags::initSemanticsFlags')
external static void _initSemanticsFlags(
@ -1323,6 +1326,7 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
bool isLink,
bool isSlider,
bool isKeyboardKey,
bool isAccessibilityFocusBlocked,
);
/// The set of semantics flags with every flag set to false.
@ -1349,6 +1353,17 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
/// {@macro dart.ui.semantics.isFocused}
final Tristate isFocused;
/// whether this node's accessibility focus is blocked.
///
/// If `true`, this node is not accessibility focusable.
/// If `false`, the a11y focusability is determined based on
/// the node's role and other properties, such as whether it is a button.
///
/// This is for accessibility focus, which is the focus used by screen readers
/// like TalkBack and VoiceOver. It is different from input focus, which is
/// usually held by the element that currently responds to keyboard inputs.
final bool isAccessibilityFocusBlocked;
/// {@macro dart.ui.semantics.isButton}
final bool isButton;
@ -1423,6 +1438,7 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
isLink: isLink || other.isLink,
isSlider: isSlider || other.isSlider,
isKeyboardKey: isKeyboardKey || other.isKeyboardKey,
isAccessibilityFocusBlocked: isAccessibilityFocusBlocked || other.isAccessibilityFocusBlocked,
);
}
@ -1451,6 +1467,7 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
bool? isLink,
bool? isSlider,
bool? isKeyboardKey,
bool? isAccessibilityFocusBlocked,
}) {
return SemanticsFlags(
isChecked: isChecked ?? this.isChecked,
@ -1476,6 +1493,7 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
isKeyboardKey: isKeyboardKey ?? this.isKeyboardKey,
isExpanded: isExpanded ?? this.isExpanded,
isRequired: isRequired ?? this.isRequired,
isAccessibilityFocusBlocked: isAccessibilityFocusBlocked ?? this.isAccessibilityFocusBlocked,
);
}
@ -1506,7 +1524,8 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
isReadOnly == other.isReadOnly &&
isLink == other.isLink &&
isSlider == other.isSlider &&
isKeyboardKey == other.isKeyboardKey;
isKeyboardKey == other.isKeyboardKey &&
isAccessibilityFocusBlocked == other.isAccessibilityFocusBlocked;
@override
int get hashCode => Object.hashAll(<Object?>[
@ -1533,6 +1552,7 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
isLink,
isSlider,
isKeyboardKey,
isAccessibilityFocusBlocked,
]);
/// Convert flags to a list of string.
@ -1560,6 +1580,7 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
if (isMultiline) 'isMultiline',
if (isReadOnly) 'isReadOnly',
if (isFocused != Tristate.none) 'isFocusable',
if (isAccessibilityFocusBlocked) 'isAccessibilityFocusBlocked',
if (isLink) 'isLink',
if (isSlider) 'isSlider',
if (isKeyboardKey) 'isKeyboardKey',
@ -1572,6 +1593,10 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
];
}
@Deprecated(
'Use hasConflictingFlags instead.'
'This feature was deprecated after v3.39.0-0.0.pre',
)
/// Checks if any of the boolean semantic flags are set to true
/// in both this instance and the [other] instance.
bool hasRepeatedFlags(SemanticsFlags other) {
@ -1600,6 +1625,35 @@ class SemanticsFlags extends NativeFieldWrapperClass1 {
(isSlider && other.isSlider) ||
(isKeyboardKey && other.isKeyboardKey);
}
/// Checks if any flags are conflicted in this instance and the [other] instance.
bool hasConflictingFlags(SemanticsFlags other) {
return isChecked.hasConflict(other.isChecked) ||
isSelected.hasConflict(other.isSelected) ||
isEnabled.hasConflict(other.isEnabled) ||
isToggled.hasConflict(other.isToggled) ||
isEnabled.hasConflict(other.isEnabled) ||
isExpanded.hasConflict(other.isExpanded) ||
isRequired.hasConflict(other.isRequired) ||
isFocused.hasConflict(other.isFocused) ||
(isButton && other.isButton) ||
(isTextField && other.isTextField) ||
(isInMutuallyExclusiveGroup && other.isInMutuallyExclusiveGroup) ||
(isHeader && other.isHeader) ||
(isObscured && other.isObscured) ||
(scopesRoute && other.scopesRoute) ||
(namesRoute && other.namesRoute) ||
(isHidden && other.isHidden) ||
(isImage && other.isImage) ||
(isLiveRegion && other.isLiveRegion) ||
(hasImplicitScrolling && other.hasImplicitScrolling) ||
(isMultiline && other.isMultiline) ||
(isReadOnly && other.isReadOnly) ||
(isLink && other.isLink) ||
(isSlider && other.isSlider) ||
(isKeyboardKey && other.isKeyboardKey) ||
(isAccessibilityFocusBlocked != other.isAccessibilityFocusBlocked);
}
}
/// The validation result of a form field.

View File

@ -40,7 +40,8 @@ void NativeSemanticsFlags::initSemanticsFlags(
bool isReadOnly,
bool isLink,
bool isSlider,
bool isKeyboardKey) {
bool isKeyboardKey,
bool isAccessibilityFocusBlocked) {
UIDartState::ThrowIfUIOperationsProhibited();
auto native_semantics_flags = fml::MakeRefCounted<NativeSemanticsFlags>();
native_semantics_flags->AssociateWithDartWrapper(semantics_flags_handle);
@ -69,6 +70,7 @@ void NativeSemanticsFlags::initSemanticsFlags(
.isLink = isLink,
.isSlider = isSlider,
.isKeyboardKey = isKeyboardKey,
.isAccessibilityFocusBlocked = isAccessibilityFocusBlocked,
};
}

View File

@ -46,6 +46,7 @@ struct SemanticsFlags {
bool isLink = false;
bool isSlider = false;
bool isKeyboardKey = false;
bool isAccessibilityFocusBlocked = false;
};
//------------------------------------------------------------------------------
@ -83,7 +84,8 @@ class NativeSemanticsFlags
bool isReadOnly,
bool isLink,
bool isSlider,
bool isKeyboardKey);
bool isKeyboardKey,
bool isAccessibilityFocusBlocked);
//----------------------------------------------------------------------------
/// Returns the c++ representataion of SemanticsFlags.

View File

@ -355,6 +355,7 @@ class SemanticsFlags {
this.isLink = false,
this.isSlider = false,
this.isKeyboardKey = false,
this.isAccessibilityFocusBlocked = false,
});
static const SemanticsFlags none = SemanticsFlags();
final CheckedState isChecked;
@ -380,6 +381,7 @@ class SemanticsFlags {
final bool isLink;
final bool isSlider;
final bool isKeyboardKey;
final bool isAccessibilityFocusBlocked;
SemanticsFlags merge(SemanticsFlags other) {
return SemanticsFlags(
@ -406,6 +408,7 @@ class SemanticsFlags {
isLink: isLink || other.isLink,
isSlider: isSlider || other.isSlider,
isKeyboardKey: isKeyboardKey || other.isKeyboardKey,
isAccessibilityFocusBlocked: isAccessibilityFocusBlocked || other.isAccessibilityFocusBlocked,
);
}
@ -433,6 +436,7 @@ class SemanticsFlags {
bool? isLink,
bool? isSlider,
bool? isKeyboardKey,
bool? isAccessibilityFocusBlocked,
}) {
return SemanticsFlags(
isChecked: isChecked ?? this.isChecked,
@ -458,6 +462,7 @@ class SemanticsFlags {
isKeyboardKey: isKeyboardKey ?? this.isKeyboardKey,
isExpanded: isExpanded ?? this.isExpanded,
isRequired: isRequired ?? this.isRequired,
isAccessibilityFocusBlocked: isAccessibilityFocusBlocked ?? this.isAccessibilityFocusBlocked,
);
}
@ -488,7 +493,8 @@ class SemanticsFlags {
isReadOnly == other.isReadOnly &&
isLink == other.isLink &&
isSlider == other.isSlider &&
isKeyboardKey == other.isKeyboardKey;
isKeyboardKey == other.isKeyboardKey &&
isAccessibilityFocusBlocked == other.isAccessibilityFocusBlocked;
@override
int get hashCode => Object.hashAll([
@ -515,6 +521,7 @@ class SemanticsFlags {
isLink,
isSlider,
isKeyboardKey,
isAccessibilityFocusBlocked,
]);
List<String> toStrings() {
@ -541,6 +548,7 @@ class SemanticsFlags {
if (isMultiline) 'isMultiline',
if (isReadOnly) 'isReadOnly',
if (isFocused != Tristate.none) 'isFocusable',
if (isAccessibilityFocusBlocked) 'isAccessibilityFocusBlocked',
if (isLink) 'isLink',
if (isSlider) 'isSlider',
if (isKeyboardKey) 'isKeyboardKey',
@ -579,6 +587,34 @@ class SemanticsFlags {
(isSlider && other.isSlider) ||
(isKeyboardKey && other.isKeyboardKey);
}
bool hasConflictingFlags(SemanticsFlags other) {
return isChecked.hasConflict(other.isChecked) ||
isSelected.hasConflict(other.isSelected) ||
isEnabled.hasConflict(other.isEnabled) ||
isToggled.hasConflict(other.isToggled) ||
isEnabled.hasConflict(other.isEnabled) ||
isExpanded.hasConflict(other.isExpanded) ||
isRequired.hasConflict(other.isRequired) ||
isFocused.hasConflict(other.isFocused) ||
(isButton && other.isButton) ||
(isTextField && other.isTextField) ||
(isInMutuallyExclusiveGroup && other.isInMutuallyExclusiveGroup) ||
(isHeader && other.isHeader) ||
(isObscured && other.isObscured) ||
(scopesRoute && other.scopesRoute) ||
(namesRoute && other.namesRoute) ||
(isHidden && other.isHidden) ||
(isImage && other.isImage) ||
(isLiveRegion && other.isLiveRegion) ||
(hasImplicitScrolling && other.hasImplicitScrolling) ||
(isMultiline && other.isMultiline) ||
(isReadOnly && other.isReadOnly) ||
(isLink && other.isLink) ||
(isSlider && other.isSlider) ||
(isKeyboardKey && other.isKeyboardKey) ||
(isAccessibilityFocusBlocked != other.isAccessibilityFocusBlocked);
}
}
// Mirrors engine/src/flutter/lib/ui/semantics.dart

View File

@ -2275,7 +2275,8 @@ public class AccessibilityBridge extends AccessibilityNodeProvider {
IS_EXPANDED(1 << 27),
HAS_SELECTED_STATE(1 << 28),
HAS_REQUIRED_STATE(1 << 29),
IS_REQUIRED(1 << 30);
IS_REQUIRED(1 << 30),
IS_ACCESSIBILITY_FOCUS_BLOCKED(1 << 31);
final int value;
@ -2802,6 +2803,9 @@ public class AccessibilityBridge extends AccessibilityNodeProvider {
if (hasFlag(Flag.IS_FOCUSABLE)) {
return true;
}
if (hasFlag(Flag.IS_ACCESSIBILITY_FOCUS_BLOCKED)) {
return false;
}
// If not explicitly set as focusable, then use our legacy
// algorithm. Once all focusable widgets have a Focus widget, then
// this won't be needed.

View File

@ -144,6 +144,9 @@ int64_t flagsToInt64(flutter::SemanticsFlags flags) {
if (flags.isRequired == flutter::SemanticsTristate::kTrue) {
result |= (INT64_C(1) << 30);
}
if (flags.isAccessibilityFocusBlocked) {
result |= (INT64_C(1) << 31);
}
return result;
}
} // namespace

View File

@ -789,6 +789,10 @@ CGRect ConvertRectToGlobal(SemanticsObject* reference, CGRect local_rect) {
}
- (BOOL)accessibilityRespondsToUserInteraction {
if (self.node.flags.isAccessibilityFocusBlocked) {
return false;
}
// Return true only if the node contains actions other than system actions.
if ((self.node.actions & ~flutter::kSystemActions) != 0) {
return true;

View File

@ -352,6 +352,8 @@ typedef struct {
bool is_slider;
/// Whether the semantics node represents a keyboard key.
bool is_keyboard_key;
/// Whether to block a11y focus for the semantics node.
bool is_accessibility_focus_blocked;
} FlutterSemanticsFlags;
typedef enum {

View File

@ -55,6 +55,7 @@ std::unique_ptr<FlutterSemanticsFlags> ConvertToFlutterSemanticsFlags(
.is_link = source.isLink,
.is_slider = source.isSlider,
.is_keyboard_key = source.isKeyboardKey,
.is_accessibility_focus_blocked = source.isAccessibilityFocusBlocked,
});
}

View File

@ -955,6 +955,9 @@ class RenderCustomPaint extends RenderProxyBox {
if (properties.focused != null) {
config.isFocused = properties.focused;
}
if (properties.accessiblityFocusBlockType != null) {
config.accessiblityFocusBlockType = properties.accessiblityFocusBlockType!;
}
if (properties.enabled != null) {
config.isEnabled = properties.enabled;
}

View File

@ -4850,6 +4850,9 @@ mixin SemanticsAnnotationsMixin on RenderObject {
if (_properties.focused != null) {
config.isFocused = _properties.focused;
}
if (_properties.accessiblityFocusBlockType != null) {
config.accessiblityFocusBlockType = _properties.accessiblityFocusBlockType!;
}
if (_properties.inMutuallyExclusiveGroup != null) {
config.isInMutuallyExclusiveGroup = _properties.inMutuallyExclusiveGroup!;
}
@ -5115,6 +5118,7 @@ final class _SemanticsParentData {
required this.explicitChildNodes,
required this.tagsForChildren,
required this.localeForChildren,
required this.accessiblityFocusBlockType,
});
/// Whether [SemanticsNode]s created from this render object semantics subtree
@ -5130,6 +5134,15 @@ final class _SemanticsParentData {
/// [AbsorbPointer]s.
final bool blocksUserActions;
/// The **Accessibility Focus Block Type** controls how accessibility focus is blocked.
///
/// * **none**: Accessibility focus is **not blocked**.
/// * **blockSubtree**: Blocks accessibility focus for the entire subtree.
/// * **blockNode**: Blocks accessibility focus for the **current node only**.
///
/// Only `blockSubtree` from a parent will be propagated down.
final AccessiblityFocusBlockType? accessiblityFocusBlockType;
/// Any immediate render object semantics that
/// [_RenderObjectSemantics.contributesToSemanticsTree] should forms a node
///
@ -5551,6 +5564,13 @@ class _RenderObjectSemantics extends _SemanticsFragment with DiagnosticableTreeM
final bool blocksUserAction =
(parentData?.blocksUserActions ?? false) || configProvider.effective.isBlockingUserActions;
AccessiblityFocusBlockType accessiblityFocusBlockType;
if (parentData?.accessiblityFocusBlockType == AccessiblityFocusBlockType.blockSubtree) {
accessiblityFocusBlockType = AccessiblityFocusBlockType.blockSubtree;
} else {
accessiblityFocusBlockType = configProvider.effective.accessiblityFocusBlockType;
}
// localeForSubtree from the config overrides parentData's inherited locale.
final Locale? localeForChildren =
configProvider.effective.localeForSubtree ?? parentData?.localeForChildren;
@ -5562,6 +5582,7 @@ class _RenderObjectSemantics extends _SemanticsFragment with DiagnosticableTreeM
(parentData?.mergeIntoParent ?? false) ||
configProvider.effective.isMergingSemanticsOfDescendants,
blocksUserActions: blocksUserAction,
accessiblityFocusBlockType: accessiblityFocusBlockType,
localeForChildren: localeForChildren,
explicitChildNodes: explicitChildNodesForChildren,
tagsForChildren: tagsForChildren,
@ -5605,6 +5626,11 @@ class _RenderObjectSemantics extends _SemanticsFragment with DiagnosticableTreeM
tags.forEach(config.addTagForChildren);
});
}
if (accessiblityFocusBlockType != configProvider.effective.accessiblityFocusBlockType) {
configProvider.updateConfig((SemanticsConfiguration config) {
config.accessiblityFocusBlockType = accessiblityFocusBlockType;
});
}
if (blocksUserAction != configProvider.effective.isBlockingUserActions) {
configProvider.updateConfig((SemanticsConfiguration config) {
@ -5682,6 +5708,7 @@ class _RenderObjectSemantics extends _SemanticsFragment with DiagnosticableTreeM
effectiveChildParentData = _SemanticsParentData(
mergeIntoParent: childParentData.mergeIntoParent,
blocksUserActions: childParentData.blocksUserActions,
accessiblityFocusBlockType: childParentData.accessiblityFocusBlockType,
explicitChildNodes: false,
tagsForChildren: childParentData.tagsForChildren,
localeForChildren: childParentData.localeForChildren,

View File

@ -114,6 +114,40 @@ typedef SemanticsUpdateCallback = void Function(SemanticsUpdate update);
typedef ChildSemanticsConfigurationsDelegate =
ChildSemanticsConfigurationsResult Function(List<SemanticsConfiguration>);
/// Controls how accessibility focus is blocked.
///
/// This is typically used to prevent screen readers
/// from focusing on parts of the UI.
enum AccessiblityFocusBlockType {
/// Accessibility focus is **not blocked**.
none,
/// Blocks accessibility focus for the entire subtree.
blockSubtree,
/// Blocks accessibility focus for the **current node only**. Its descendants
/// may still be focusable.
blockNode;
/// The AccessiblityFocusBlockType when two nodes get merged.
AccessiblityFocusBlockType _merge(AccessiblityFocusBlockType other) {
// 1. If either is blockSubtree, the result is blockSubtree.
if (this == AccessiblityFocusBlockType.blockSubtree ||
other == AccessiblityFocusBlockType.blockSubtree) {
return AccessiblityFocusBlockType.blockSubtree;
}
// 2. If either is blockNode, the result is blockNode
if (this == AccessiblityFocusBlockType.blockNode ||
other == AccessiblityFocusBlockType.blockNode) {
return AccessiblityFocusBlockType.blockNode;
}
// 3. If neither is blockSubtree nor blockNode, both must be none.
return AccessiblityFocusBlockType.none;
}
}
final int _kUnblockedUserActions =
SemanticsAction.didGainAccessibilityFocus.index |
SemanticsAction.didLoseAccessibilityFocus.index;
@ -489,6 +523,12 @@ sealed class _DebugSemanticsRoleChecks {
return FlutterError('A collapsed node cannot have a collapse action.');
}
}
if (data.flagsCollection.isAccessibilityFocusBlocked &&
data.flagsCollection.isFocused != Tristate.none) {
return FlutterError(
'A node that is keyboard focusable cannot be set to accessibility unfocusable',
);
}
return null;
}
@ -1537,6 +1577,7 @@ class SemanticsProperties extends DiagnosticableTree {
)
this.focusable,
this.focused,
this.accessiblityFocusBlockType,
this.inMutuallyExclusiveGroup,
this.hidden,
this.obscured,
@ -1743,6 +1784,13 @@ class SemanticsProperties extends DiagnosticableTree {
/// element it is reading, and is separate from input focus.
final bool? focused;
/// If non-null, indicates if this subtree or current node is blocked in a11y focus.
///
/// This is for accessibility focus, which is the focus used by screen readers
/// like TalkBack and VoiceOver. It is different from input focus, which is
/// usually held by the element that currently responds to keyboard inputs.
final AccessiblityFocusBlockType? accessiblityFocusBlockType;
/// If non-null, whether a semantic node is in a mutually exclusive group.
///
/// For example, a radio button is in a mutually exclusive group because only
@ -6109,6 +6157,17 @@ class SemanticsConfiguration {
_hasBeenAnnotated = true;
}
AccessiblityFocusBlockType _accessiblityFocusBlockType = AccessiblityFocusBlockType.none;
/// Whether the owning [RenderObject] and its subtree
/// is blocked in the a11y focus (different from input focus).
AccessiblityFocusBlockType get accessiblityFocusBlockType => _accessiblityFocusBlockType;
set accessiblityFocusBlockType(AccessiblityFocusBlockType value) {
_accessiblityFocusBlockType = value;
_flags = _flags.copyWith(isAccessibilityFocusBlocked: value != AccessiblityFocusBlockType.none);
_hasBeenAnnotated = true;
}
/// Whether the owning [RenderObject] is a button (true) or not (false).
bool get isButton => _flags.isButton;
set isButton(bool value) {
@ -6418,9 +6477,10 @@ class SemanticsConfiguration {
if (_actionsAsBits & other._actionsAsBits != 0) {
return false;
}
if (_flags.hasRepeatedFlags(other._flags)) {
if (_flags.hasConflictingFlags(other._flags)) {
return false;
}
if (_platformViewId != null && other._platformViewId != null) {
return false;
}
@ -6542,6 +6602,9 @@ class SemanticsConfiguration {
_validationResult = child._validationResult;
}
}
_accessiblityFocusBlockType = _accessiblityFocusBlockType._merge(
child._accessiblityFocusBlockType,
);
_hasBeenAnnotated = hasBeenAnnotated || child.hasBeenAnnotated;
}
@ -6564,6 +6627,7 @@ class SemanticsConfiguration {
.._attributedValue = _attributedValue
.._attributedDecreasedValue = _attributedDecreasedValue
.._attributedHint = _attributedHint
.._accessiblityFocusBlockType = _accessiblityFocusBlockType
.._hintOverrides = _hintOverrides
.._tooltip = _tooltip
.._flags = _flags

View File

@ -3991,6 +3991,7 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget {
required bool? readOnly,
required bool? focusable,
required bool? focused,
required AccessiblityFocusBlockType? accessiblityFocusBlockType,
required bool? inMutuallyExclusiveGroup,
required bool? obscured,
required bool? multiline,
@ -4075,6 +4076,7 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget {
readOnly: readOnly,
focusable: focusable,
focused: focused,
accessiblityFocusBlockType: accessiblityFocusBlockType,
inMutuallyExclusiveGroup: inMutuallyExclusiveGroup,
obscured: obscured,
multiline: multiline,
@ -4323,6 +4325,7 @@ class SliverSemantics extends _SemanticsBase {
super.readOnly,
super.focusable,
super.focused,
super.accessiblityFocusBlockType,
super.inMutuallyExclusiveGroup,
super.obscured,
super.multiline,
@ -7900,6 +7903,7 @@ class Semantics extends _SemanticsBase {
super.readOnly,
super.focusable,
super.focused,
super.accessiblityFocusBlockType,
super.inMutuallyExclusiveGroup,
super.obscured,
super.multiline,

View File

@ -3,6 +3,7 @@
// found in the LICENSE file.
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@ -539,6 +540,7 @@ void main() {
testWidgets('Semantics widget supports all flags', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
// Checked state and toggled state are mutually exclusive.
// Focused and isAccessibilityFocusBlocked are mutually exclusive.
await tester.pumpWidget(
Semantics(
key: const Key('a'),
@ -569,12 +571,29 @@ void main() {
isRequired: true,
),
);
final List<SemanticsFlag> flags = SemanticsFlag.values.toList();
flags
..remove(SemanticsFlag.hasToggledState)
..remove(SemanticsFlag.isToggled)
..remove(SemanticsFlag.hasImplicitScrolling)
..remove(SemanticsFlag.isCheckStateMixed);
SemanticsFlags flags = SemanticsFlags(
isChecked: CheckedState.isTrue,
isSelected: Tristate.isTrue,
isEnabled: Tristate.isTrue,
isExpanded: Tristate.isTrue,
isRequired: Tristate.isTrue,
isFocused: Tristate.isTrue,
isButton: true,
isTextField: true,
isInMutuallyExclusiveGroup: true,
isObscured: true,
scopesRoute: true,
namesRoute: true,
isHidden: true,
isImage: true,
isHeader: true,
isLiveRegion: true,
isMultiline: true,
isReadOnly: true,
isLink: true,
isSlider: true,
isKeyboardKey: true,
);
TestSemantics expectedSemantics = TestSemantics.root(
children: <TestSemantics>[
@ -635,9 +654,7 @@ void main() {
isRequired: true,
),
);
flags
..remove(SemanticsFlag.isChecked)
..add(SemanticsFlag.isCheckStateMixed);
flags = flags.copyWith(isChecked: CheckedState.mixed);
semantics.dispose();
expectedSemantics = TestSemantics.root(
children: <TestSemantics>[
@ -816,6 +833,302 @@ void main() {
semantics.dispose();
});
testWidgets('AccessiblityFocusBlockType.blockSubtree is applied to the subtree,', (
WidgetTester tester,
) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
Semantics(
container: true,
accessiblityFocusBlockType: AccessiblityFocusBlockType.blockSubtree,
child: Column(
children: <Widget>[
// If the child set blockSubTreeAccessibilityFocus to `none`, it's still blcok because its parent.
Semantics(
container: true,
accessiblityFocusBlockType: AccessiblityFocusBlockType.none,
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
const CustomSemanticsAction(label: 'action1'): () {},
},
child: const SizedBox(width: 10, height: 10),
),
// If the child doesn't have a value for accessibilityFocusable, it will also use the parent data.
Semantics(
container: true,
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
const CustomSemanticsAction(label: 'action2'): () {},
},
child: const SizedBox(width: 10, height: 10),
),
],
),
),
);
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
flags: SemanticsFlags(isAccessibilityFocusBlocked: true),
children: <TestSemantics>[
TestSemantics(
id: 2,
flags: SemanticsFlags(isAccessibilityFocusBlocked: true),
actions: <SemanticsAction>[SemanticsAction.customAction],
),
TestSemantics(
id: 3,
flags: SemanticsFlags(isAccessibilityFocusBlocked: true),
actions: <SemanticsAction>[SemanticsAction.customAction],
),
],
),
],
),
ignoreTransform: true,
ignoreRect: true,
ignoreId: true,
),
);
semantics.dispose();
});
testWidgets('AccessiblityFocusBlockType.blockNode is not applied to the subtree,', (
WidgetTester tester,
) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
Semantics(
container: true,
accessiblityFocusBlockType: AccessiblityFocusBlockType.blockNode,
child: Column(
children: <Widget>[
Semantics(
container: true,
accessiblityFocusBlockType: AccessiblityFocusBlockType.none,
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
const CustomSemanticsAction(label: 'action1'): () {},
},
child: const SizedBox(width: 10, height: 10),
),
Semantics(
container: true,
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
const CustomSemanticsAction(label: 'action2'): () {},
},
child: const SizedBox(width: 10, height: 10),
),
],
),
),
);
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
flags: SemanticsFlags(isAccessibilityFocusBlocked: true),
children: <TestSemantics>[
TestSemantics(id: 2, actions: <SemanticsAction>[SemanticsAction.customAction]),
TestSemantics(id: 3, actions: <SemanticsAction>[SemanticsAction.customAction]),
],
),
],
),
ignoreTransform: true,
ignoreRect: true,
ignoreId: true,
),
);
semantics.dispose();
});
testWidgets('MergeSemantics merges children with AccessiblityFocusBlockType.blockNode', (
WidgetTester tester,
) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MergeSemantics(
child: Column(
children: <Widget>[
Semantics(
container: true,
accessiblityFocusBlockType: AccessiblityFocusBlockType.blockNode,
label: 'node1',
child: const SizedBox(width: 10, height: 10),
),
Semantics(
container: true,
label: 'node2',
child: const SizedBox(width: 10, height: 10),
),
],
),
),
),
);
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
flags: SemanticsFlags(isAccessibilityFocusBlocked: true),
label: 'node1\nnode2',
),
],
),
ignoreTransform: true,
ignoreRect: true,
ignoreId: true,
),
);
semantics.dispose();
});
testWidgets('AccessiblityFocusBlockType.blockNode doesnt merge up or merge down', (
WidgetTester tester,
) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Semantics(
label: 'root',
child: Semantics(
accessiblityFocusBlockType: AccessiblityFocusBlockType.blockNode,
label: 'semantics label 0',
child: Column(
children: <Widget>[
Semantics(label: 'semantics label 1', child: const SizedBox(width: 10, height: 10)),
Semantics(label: 'semantics label 2', child: const SizedBox(width: 10, height: 10)),
],
),
),
),
),
);
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
label: 'root',
children: <TestSemantics>[
TestSemantics(
id: 2,
label: 'semantics label 0',
flags: SemanticsFlags(isAccessibilityFocusBlocked: true),
children: <TestSemantics>[
TestSemantics(id: 3, label: 'semantics label 1'),
TestSemantics(id: 4, label: 'semantics label 2'),
],
),
],
),
],
),
ignoreTransform: true,
ignoreRect: true,
ignoreId: true,
),
);
semantics.dispose();
});
testWidgets('AccessiblityFocusBlockType.blockSubtree doesnt merge up', (
WidgetTester tester,
) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Semantics(
label: 'root',
child: Semantics(
accessiblityFocusBlockType: AccessiblityFocusBlockType.blockSubtree,
label: 'semantics label 0',
child: Column(
children: <Widget>[
Semantics(label: 'semantics label 1', child: const SizedBox(width: 10, height: 10)),
Semantics(label: 'semantics label 2', child: const SizedBox(width: 10, height: 10)),
],
),
),
),
),
);
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
label: 'root',
children: <TestSemantics>[
TestSemantics(
id: 2,
label: 'semantics label 0\nsemantics label 1\nsemantics label 2',
flags: SemanticsFlags(isAccessibilityFocusBlocked: true),
),
],
),
],
),
ignoreTransform: true,
ignoreRect: true,
ignoreId: true,
),
);
semantics.dispose();
});
testWidgets('semantics node cant be keyboard focusable but accessibility unfocusable', (
WidgetTester tester,
) async {
await tester.pumpWidget(
Semantics(
container: true,
accessiblityFocusBlockType: AccessiblityFocusBlockType.blockSubtree,
focused: true,
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
const CustomSemanticsAction(label: 'action1'): () {},
},
child: const SizedBox(width: 10, height: 10),
),
);
final Object? exception = tester.takeException();
expect(exception, isFlutterError);
final FlutterError error = exception! as FlutterError;
expect(
error.message,
startsWith('A node that is keyboard focusable cannot be set to accessibility unfocusable'),
);
});
testWidgets('Increased/decreased values are annotated', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);