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