From ed19f47bec7918d5ea84eb41ca3e63db7394d45a Mon Sep 17 00:00:00 2001 From: Hannah Jin Date: Mon, 3 Nov 2025 14:18:33 -0800 Subject: [PATCH] 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" https://github.com/flutter/flutter/blob/ecbb115ae3a8cba2977ecab9f52086df860cfb1a/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. [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 --- engine/src/flutter/lib/ui/semantics.dart | 56 ++- .../lib/ui/semantics/semantics_flags.cc | 4 +- .../lib/ui/semantics/semantics_flags.h | 4 +- .../src/flutter/lib/web_ui/lib/semantics.dart | 38 +- .../io/flutter/view/AccessibilityBridge.java | 6 +- .../platform_view_android_delegate.cc | 3 + .../ios/framework/Source/SemanticsObject.mm | 4 + .../shell/platform/embedder/embedder.h | 2 + .../embedder/embedder_semantics_update.cc | 1 + .../lib/src/rendering/custom_paint.dart | 3 + .../flutter/lib/src/rendering/object.dart | 27 ++ .../flutter/lib/src/semantics/semantics.dart | 66 +++- packages/flutter/lib/src/widgets/basic.dart | 4 + .../flutter/test/widgets/semantics_test.dart | 331 +++++++++++++++++- 14 files changed, 534 insertions(+), 15 deletions(-) diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart index e204b61befa..8d496fa664c 100644 --- a/engine/src/flutter/lib/ui/semantics.dart +++ b/engine/src/flutter/lib/ui/semantics.dart @@ -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([ @@ -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. diff --git a/engine/src/flutter/lib/ui/semantics/semantics_flags.cc b/engine/src/flutter/lib/ui/semantics/semantics_flags.cc index 0ddc9b100b8..ff4843157f9 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_flags.cc +++ b/engine/src/flutter/lib/ui/semantics/semantics_flags.cc @@ -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(); native_semantics_flags->AssociateWithDartWrapper(semantics_flags_handle); @@ -69,6 +70,7 @@ void NativeSemanticsFlags::initSemanticsFlags( .isLink = isLink, .isSlider = isSlider, .isKeyboardKey = isKeyboardKey, + .isAccessibilityFocusBlocked = isAccessibilityFocusBlocked, }; } diff --git a/engine/src/flutter/lib/ui/semantics/semantics_flags.h b/engine/src/flutter/lib/ui/semantics/semantics_flags.h index 8f4d983ef17..4dc49a52bd7 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_flags.h +++ b/engine/src/flutter/lib/ui/semantics/semantics_flags.h @@ -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. diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart index a6575b396b9..74352902430 100644 --- a/engine/src/flutter/lib/web_ui/lib/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart @@ -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 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 diff --git a/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java b/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java index 57d206918d2..5df60b743cd 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java @@ -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. diff --git a/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate.cc b/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate.cc index e3823200d28..6108b019483 100644 --- a/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate.cc +++ b/engine/src/flutter/shell/platform/android/platform_view_android_delegate/platform_view_android_delegate.cc @@ -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 diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm index 72be6fbfe53..cdcce174dfa 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm @@ -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; diff --git a/engine/src/flutter/shell/platform/embedder/embedder.h b/engine/src/flutter/shell/platform/embedder/embedder.h index 55d6498b1fc..00900d8f39b 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder.h +++ b/engine/src/flutter/shell/platform/embedder/embedder.h @@ -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 { diff --git a/engine/src/flutter/shell/platform/embedder/embedder_semantics_update.cc b/engine/src/flutter/shell/platform/embedder/embedder_semantics_update.cc index a339d52c5fc..aec9cc4422f 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder_semantics_update.cc +++ b/engine/src/flutter/shell/platform/embedder/embedder_semantics_update.cc @@ -55,6 +55,7 @@ std::unique_ptr ConvertToFlutterSemanticsFlags( .is_link = source.isLink, .is_slider = source.isSlider, .is_keyboard_key = source.isKeyboardKey, + .is_accessibility_focus_blocked = source.isAccessibilityFocusBlocked, }); } diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart index ebf87569089..a3d5a07efb9 100644 --- a/packages/flutter/lib/src/rendering/custom_paint.dart +++ b/packages/flutter/lib/src/rendering/custom_paint.dart @@ -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; } diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 7aa0c1840af..32669a38cf7 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -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, diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 4507b91cbad..6b9b6449bbe 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -114,6 +114,40 @@ typedef SemanticsUpdateCallback = void Function(SemanticsUpdate update); typedef ChildSemanticsConfigurationsDelegate = ChildSemanticsConfigurationsResult Function(List); +/// 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 diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index ca91bc34c72..cc73758e2a4 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -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, diff --git a/packages/flutter/test/widgets/semantics_test.dart b/packages/flutter/test/widgets/semantics_test.dart index da54109f4ae..30b99e6330c 100644 --- a/packages/flutter/test/widgets/semantics_test.dart +++ b/packages/flutter/test/widgets/semantics_test.dart @@ -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 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: [ @@ -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: [ @@ -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: [ + // If the child set blockSubTreeAccessibilityFocus to `none`, it's still blcok because its parent. + Semantics( + container: true, + accessiblityFocusBlockType: AccessiblityFocusBlockType.none, + customSemanticsActions: { + 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: { + const CustomSemanticsAction(label: 'action2'): () {}, + }, + child: const SizedBox(width: 10, height: 10), + ), + ], + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + flags: SemanticsFlags(isAccessibilityFocusBlocked: true), + children: [ + TestSemantics( + id: 2, + flags: SemanticsFlags(isAccessibilityFocusBlocked: true), + actions: [SemanticsAction.customAction], + ), + TestSemantics( + id: 3, + flags: SemanticsFlags(isAccessibilityFocusBlocked: true), + actions: [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: [ + Semantics( + container: true, + accessiblityFocusBlockType: AccessiblityFocusBlockType.none, + customSemanticsActions: { + const CustomSemanticsAction(label: 'action1'): () {}, + }, + child: const SizedBox(width: 10, height: 10), + ), + Semantics( + container: true, + customSemanticsActions: { + const CustomSemanticsAction(label: 'action2'): () {}, + }, + child: const SizedBox(width: 10, height: 10), + ), + ], + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + flags: SemanticsFlags(isAccessibilityFocusBlocked: true), + children: [ + TestSemantics(id: 2, actions: [SemanticsAction.customAction]), + TestSemantics(id: 3, actions: [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: [ + 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( + 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: [ + 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( + id: 1, + label: 'root', + children: [ + TestSemantics( + id: 2, + label: 'semantics label 0', + flags: SemanticsFlags(isAccessibilityFocusBlocked: true), + children: [ + 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: [ + 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( + id: 1, + label: 'root', + children: [ + 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: { + 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);