diff --git a/engine/src/flutter/lib/ui/semantics.dart b/engine/src/flutter/lib/ui/semantics.dart index e02bcf72477..fbd17a20f0d 100644 --- a/engine/src/flutter/lib/ui/semantics.dart +++ b/engine/src/flutter/lib/ui/semantics.dart @@ -46,6 +46,8 @@ class SemanticsAction { static const int _kSetTextIndex = 1 << 21; static const int _kFocusIndex = 1 << 22; static const int _kScrollToOffsetIndex = 1 << 23; + static const int _kExpandIndex = 1 << 24; + static const int _kCollapseIndex = 1 << 25; // READ THIS: // - The maximum supported bit index on the web (in JS mode) is 1 << 31. // - If you add an action here, you MUST update the numSemanticsActions value @@ -298,6 +300,16 @@ class SemanticsAction { /// VoiceOver (iOS), moving which does not move the input focus. static const SemanticsAction focus = SemanticsAction._(_kFocusIndex, 'focus'); + /// A request that the node should be expanded. + /// + /// For example, this action might be recognized by a dropdown. + static const SemanticsAction expand = SemanticsAction._(_kExpandIndex, 'expand'); + + /// A request that the node should be collapsed. + /// + /// For example, this action might be recognized by a dropdown. + static const SemanticsAction collapse = SemanticsAction._(_kCollapseIndex, 'collapse'); + /// The possible semantics actions. /// /// The map's key is the [index] of the action and the value is the action @@ -327,6 +339,8 @@ class SemanticsAction { _kMoveCursorBackwardByWordIndex: moveCursorBackwardByWord, _kSetTextIndex: setText, _kFocusIndex: focus, + _kExpandIndex: expand, + _kCollapseIndex: collapse, }; // TODO(matanlurey): have original authors document; see https://github.com/flutter/flutter/issues/151917. diff --git a/engine/src/flutter/lib/ui/semantics/semantics_node.h b/engine/src/flutter/lib/ui/semantics/semantics_node.h index 602739ac433..d419b3e037f 100644 --- a/engine/src/flutter/lib/ui/semantics/semantics_node.h +++ b/engine/src/flutter/lib/ui/semantics/semantics_node.h @@ -45,6 +45,8 @@ enum class SemanticsAction : int32_t { kSetText = 1 << 21, kFocus = 1 << 22, kScrollToOffset = 1 << 23, + kExpand = 1 << 24, + kCollapse = 1 << 25, }; constexpr int kVerticalScrollSemanticsActions = diff --git a/engine/src/flutter/lib/web_ui/lib/semantics.dart b/engine/src/flutter/lib/web_ui/lib/semantics.dart index b313a1a1703..755137993f2 100644 --- a/engine/src/flutter/lib/web_ui/lib/semantics.dart +++ b/engine/src/flutter/lib/web_ui/lib/semantics.dart @@ -34,6 +34,8 @@ class SemanticsAction { static const int _kSetTextIndex = 1 << 21; static const int _kFocusIndex = 1 << 22; static const int _kScrollToOffsetIndex = 1 << 23; + static const int _kExpandIndex = 1 << 24; + static const int _kCollapseIndex = 1 << 25; static const SemanticsAction tap = SemanticsAction._(_kTapIndex, 'tap'); static const SemanticsAction longPress = SemanticsAction._(_kLongPressIndex, 'longPress'); @@ -89,6 +91,8 @@ class SemanticsAction { 'moveCursorBackwardByWord', ); static const SemanticsAction focus = SemanticsAction._(_kFocusIndex, 'focus'); + static const SemanticsAction expand = SemanticsAction._(_kExpandIndex, 'expand'); + static const SemanticsAction collapse = SemanticsAction._(_kCollapseIndex, 'collapse'); static const Map _kActionById = { _kTapIndex: tap, @@ -115,6 +119,8 @@ class SemanticsAction { _kMoveCursorBackwardByWordIndex: moveCursorBackwardByWord, _kSetTextIndex: setText, _kFocusIndex: focus, + _kExpandIndex: expand, + _kCollapseIndex: collapse, }; static List get values => _kActionById.values.toList(growable: false); diff --git a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_api_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_api_test.dart index 56ff12fc18e..8f86c1e5ed3 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_api_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_api_test.dart @@ -29,7 +29,7 @@ void testMain() { }); // This must match the number of actions in lib/ui/semantics.dart - const int numSemanticsActions = 24; + const int numSemanticsActions = 26; test('SemanticsAction.values refers to all actions.', () async { expect(SemanticsAction.values.length, equals(numSemanticsActions)); for (int index = 0; index < numSemanticsActions; ++index) { diff --git a/engine/src/flutter/shell/platform/android/build.gradle b/engine/src/flutter/shell/platform/android/build.gradle index ac83eff5471..9a2fe44cf0d 100644 --- a/engine/src/flutter/shell/platform/android/build.gradle +++ b/engine/src/flutter/shell/platform/android/build.gradle @@ -55,7 +55,7 @@ android { implementation "androidx.test:core:1.4.0" implementation "com.google.android.play:core:1.8.0" implementation "com.ibm.icu:icu4j:69.1" - implementation "org.robolectric:robolectric:4.14.1" + implementation "org.robolectric:robolectric:4.16" implementation "junit:junit:4.13.2" implementation "androidx.test.ext:junit:1.1.4-alpha07" 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 1992a756684..d919edbc7e6 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 @@ -1015,6 +1015,22 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { } result.setSelected(semanticsNode.hasFlag(Flag.IS_SELECTED)); + if (Build.VERSION.SDK_INT >= API_LEVELS.API_36) { + if (semanticsNode.hasFlag(Flag.HAS_EXPANDED_STATE)) { + final boolean isExpanded = semanticsNode.hasFlag(Flag.IS_EXPANDED); + result.setExpandedState( + isExpanded + ? AccessibilityNodeInfo.EXPANDED_STATE_FULL + : AccessibilityNodeInfo.EXPANDED_STATE_COLLAPSED); + if (semanticsNode.hasAction(Action.EXPAND)) { + result.addAction(AccessibilityNodeInfo.ACTION_EXPAND); + } + if (semanticsNode.hasAction(Action.COLLAPSE)) { + result.addAction(AccessibilityNodeInfo.ACTION_COLLAPSE); + } + } + } + // Heading support if (Build.VERSION.SDK_INT >= API_LEVELS.API_28) { result.setHeading(semanticsNode.hasFlag(Flag.IS_HEADER)); @@ -1292,6 +1308,16 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { { return performSetText(semanticsNode, virtualViewId, arguments); } + case AccessibilityNodeInfo.ACTION_EXPAND: + { + accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.EXPAND); + return true; + } + case AccessibilityNodeInfo.ACTION_COLLAPSE: + { + accessibilityChannel.dispatchSemanticsAction(virtualViewId, Action.COLLAPSE); + return true; + } default: // might be a custom accessibility accessibilityAction. final int flutterId = accessibilityAction - FIRST_RESOURCE_ID; @@ -2168,7 +2194,9 @@ public class AccessibilityBridge extends AccessibilityNodeProvider { MOVE_CURSOR_BACKWARD_BY_WORD(1 << 20), SET_TEXT(1 << 21), FOCUS(1 << 22), - SCROLL_TO_OFFSET(1 << 23); + SCROLL_TO_OFFSET(1 << 23), + EXPAND(1 << 24), + COLLAPSE(1 << 25); public final int value; diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java index 6e9f7eb378c..d2e5f1df741 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/view/AccessibilityBridgeTest.java @@ -2119,6 +2119,156 @@ public class AccessibilityBridgeTest { assertTrue(actions.contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK)); } + @Config(sdk = API_LEVELS.API_36) + @TargetApi(API_LEVELS.API_36) + @Test + public void itSetsExpandedStateBasedOnFlagsCorrectly() { + AccessibilityBridge accessibilityBridge = setUpBridge(); + + TestSemanticsNode node = new TestSemanticsNode(); + TestSemanticsUpdate testSemanticsUpdate = node.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0); + assertEquals(nodeInfo.getExpandedState(), AccessibilityNodeInfo.EXPANDED_STATE_UNDEFINED); + + node = new TestSemanticsNode(); + node.addFlag(AccessibilityBridge.Flag.HAS_EXPANDED_STATE); + testSemanticsUpdate = node.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0); + assertEquals(nodeInfo.getExpandedState(), AccessibilityNodeInfo.EXPANDED_STATE_COLLAPSED); + + node = new TestSemanticsNode(); + node.addFlag(AccessibilityBridge.Flag.HAS_EXPANDED_STATE); + node.addFlag(AccessibilityBridge.Flag.IS_EXPANDED); + testSemanticsUpdate = node.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0); + assertEquals(nodeInfo.getExpandedState(), AccessibilityNodeInfo.EXPANDED_STATE_FULL); + } + + @Config(sdk = API_LEVELS.API_36) + @TargetApi(API_LEVELS.API_36) + @Test + public void itAddsExpandActionBasedOnFlagsCorrectly() { + AccessibilityBridge accessibilityBridge = setUpBridge(); + + TestSemanticsNode node = new TestSemanticsNode(); + TestSemanticsUpdate testSemanticsUpdate = node.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0); + List actions = nodeInfo.getActionList(); + assertFalse(actions.contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND)); + + node = new TestSemanticsNode(); + node.addFlag(AccessibilityBridge.Flag.HAS_EXPANDED_STATE); + testSemanticsUpdate = node.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0); + actions = nodeInfo.getActionList(); + assertFalse(actions.contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND)); + + node = new TestSemanticsNode(); + node.addFlag(AccessibilityBridge.Flag.HAS_EXPANDED_STATE); + node.addAction(AccessibilityBridge.Action.EXPAND); + testSemanticsUpdate = node.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0); + actions = nodeInfo.getActionList(); + assertTrue(actions.contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND)); + } + + @Config(sdk = API_LEVELS.API_36) + @TargetApi(API_LEVELS.API_36) + @Test + public void itCanPerformExpand() { + AccessibilityChannel mockChannel = mock(AccessibilityChannel.class); + AccessibilityBridge accessibilityBridge = + setUpBridge( + /* rootAccessibilityView= */ null, + /* accessibilityChannel= */ mockChannel, + /* accessibilityManager= */ null, + /* contentResolver= */ null, + /* accessibilityViewEmbedder= */ null, + /* platformViewsAccessibilityDelegate= */ null); + + TestSemanticsNode node = new TestSemanticsNode(); + node.addFlag(AccessibilityBridge.Flag.HAS_EXPANDED_STATE); + node.addAction(AccessibilityBridge.Action.EXPAND); + TestSemanticsUpdate testSemanticsUpdate = node.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + accessibilityBridge.performAction(0, AccessibilityNodeInfo.ACTION_EXPAND, null); + verify(mockChannel).dispatchSemanticsAction(0, AccessibilityBridge.Action.EXPAND); + } + + @Config(sdk = API_LEVELS.API_36) + @TargetApi(API_LEVELS.API_36) + @Test + public void itAddsCollapseActionBasedOnFlagsCorrectly() { + AccessibilityBridge accessibilityBridge = setUpBridge(); + + TestSemanticsNode node = new TestSemanticsNode(); + TestSemanticsUpdate testSemanticsUpdate = node.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + AccessibilityNodeInfo nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0); + List actions = nodeInfo.getActionList(); + assertFalse(actions.contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE)); + + node = new TestSemanticsNode(); + node.addFlag(AccessibilityBridge.Flag.HAS_EXPANDED_STATE); + node.addFlag(AccessibilityBridge.Flag.IS_EXPANDED); + testSemanticsUpdate = node.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0); + actions = nodeInfo.getActionList(); + assertFalse(actions.contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE)); + + node = new TestSemanticsNode(); + node.addFlag(AccessibilityBridge.Flag.HAS_EXPANDED_STATE); + node.addFlag(AccessibilityBridge.Flag.IS_EXPANDED); + node.addAction(AccessibilityBridge.Action.COLLAPSE); + testSemanticsUpdate = node.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + nodeInfo = accessibilityBridge.createAccessibilityNodeInfo(0); + actions = nodeInfo.getActionList(); + assertTrue(actions.contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE)); + } + + @Config(sdk = API_LEVELS.API_36) + @TargetApi(API_LEVELS.API_36) + @Test + public void itCanPerformCollapse() { + AccessibilityChannel mockChannel = mock(AccessibilityChannel.class); + AccessibilityBridge accessibilityBridge = + setUpBridge( + /* rootAccessibilityView= */ null, + /* accessibilityChannel= */ mockChannel, + /* accessibilityManager= */ null, + /* contentResolver= */ null, + /* accessibilityViewEmbedder= */ null, + /* platformViewsAccessibilityDelegate= */ null); + + TestSemanticsNode node = new TestSemanticsNode(); + node.addFlag(AccessibilityBridge.Flag.HAS_EXPANDED_STATE); + node.addFlag(AccessibilityBridge.Flag.IS_EXPANDED); + node.addAction(AccessibilityBridge.Action.COLLAPSE); + TestSemanticsUpdate testSemanticsUpdate = node.toUpdate(); + testSemanticsUpdate.sendUpdateToBridge(accessibilityBridge); + + accessibilityBridge.performAction(0, AccessibilityNodeInfo.ACTION_COLLAPSE, null); + verify(mockChannel).dispatchSemanticsAction(0, AccessibilityBridge.Action.COLLAPSE); + } + AccessibilityBridge setUpBridge() { return setUpBridge(null, null, null, null, null, null); } diff --git a/engine/src/flutter/shell/platform/embedder/embedder.h b/engine/src/flutter/shell/platform/embedder/embedder.h index a7e5dd9c314..9422c5cde83 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder.h +++ b/engine/src/flutter/shell/platform/embedder/embedder.h @@ -169,6 +169,10 @@ typedef enum { /// Request that scrolls the current scrollable container to a given scroll /// offset. kFlutterSemanticsActionScrollToOffset = 1 << 23, + /// A request that the node should be expanded. + kFlutterSemanticsActionExpand = 1 << 24, + /// A request that the node should be collapsed. + kFlutterSemanticsActionCollapse = 1 << 25, } FlutterSemanticsAction; /// The set of properties that may be associated with a semantics node. diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/accessibility_bridge.cc b/engine/src/flutter/shell/platform/fuchsia/flutter/accessibility_bridge.cc index 51a30ce181d..c51a617447d 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/accessibility_bridge.cc +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/accessibility_bridge.cc @@ -179,6 +179,12 @@ std::string NodeActionsToString(const flutter::SemanticsNode& node) { if (node.HasAction(flutter::SemanticsAction::kFocus)) { output += "kFocus|"; } + if (node.HasAction(flutter::SemanticsAction::kExpand)) { + output += "kExpand|"; + } + if (node.HasAction(flutter::SemanticsAction::kCollapse)) { + output += "kCollapse|"; + } return output; } diff --git a/engine/src/flutter/shell/platform/linux/fl_accessible_node.cc b/engine/src/flutter/shell/platform/linux/fl_accessible_node.cc index 39a67563273..7ce29f108fd 100644 --- a/engine/src/flutter/shell/platform/linux/fl_accessible_node.cc +++ b/engine/src/flutter/shell/platform/linux/fl_accessible_node.cc @@ -37,6 +37,8 @@ static ActionData action_mapping[] = { {kFlutterSemanticsActionMoveCursorBackwardByWord, "MoveCursorBackwardByWord"}, {kFlutterSemanticsActionFocus, "Focus"}, + {kFlutterSemanticsActionExpand, "Expand"}, + {kFlutterSemanticsActionCollapse, "Collapse"}, {static_cast(0), nullptr}}; struct FlAccessibleNodePrivate { diff --git a/engine/src/flutter/testing/dart/semantics_test.dart b/engine/src/flutter/testing/dart/semantics_test.dart index 857e67ec816..40afb08da25 100644 --- a/engine/src/flutter/testing/dart/semantics_test.dart +++ b/engine/src/flutter/testing/dart/semantics_test.dart @@ -22,7 +22,7 @@ void main() { }); // This must match the number of actions in lib/ui/semantics.dart - const int numSemanticsActions = 24; + const int numSemanticsActions = 26; test('SemanticsAction.values refers to all actions.', () async { expect(SemanticsAction.values.length, equals(numSemanticsActions)); for (int index = 0; index < numSemanticsActions; ++index) { diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart index 969c45f6bc6..ffd1a324762 100644 --- a/packages/flutter/lib/src/rendering/custom_paint.dart +++ b/packages/flutter/lib/src/rendering/custom_paint.dart @@ -1105,6 +1105,12 @@ class RenderCustomPaint extends RenderProxyBox { if (properties.onDismiss != null) { config.onDismiss = properties.onDismiss; } + if (properties.onExpand != null) { + config.onExpand = properties.onExpand; + } + if (properties.onCollapse != null) { + config.onCollapse = properties.onCollapse; + } newChild.updateWith( config: config, diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 2ed1fc424a4..12a9c61a1ff 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -5005,6 +5005,12 @@ mixin SemanticsAnnotationsMixin on RenderObject { if (_properties.onFocus != null) { config.onFocus = _performFocus; } + if (_properties.onExpand != null) { + config.onExpand = _performExpand; + } + if (_properties.onCollapse != null) { + config.onCollapse = _performCollapse; + } if (_properties.customSemanticsActions != null) { config.customSemanticsActions = _properties.customSemanticsActions!; } @@ -5093,6 +5099,14 @@ mixin SemanticsAnnotationsMixin on RenderObject { void _performFocus() { _properties.onFocus?.call(); } + + void _performExpand() { + _properties.onExpand?.call(); + } + + void _performCollapse() { + _properties.onCollapse?.call(); + } } /// Properties of _RenderObjectSemantics that are imposed from parent. diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index a34164023cc..d3c46b88a48 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -120,43 +120,51 @@ final int _kUnblockedUserActions = /// A static class to conduct semantics role checks. sealed class _DebugSemanticsRoleChecks { - static FlutterError? _checkSemanticsData(SemanticsNode node) => switch (node.role) { - SemanticsRole.alertDialog => _noCheckRequired, - SemanticsRole.dialog => _noCheckRequired, - SemanticsRole.none => _noCheckRequired, - SemanticsRole.tab => _semanticsTab, - SemanticsRole.tabBar => _semanticsTabBar, - SemanticsRole.tabPanel => _noCheckRequired, - SemanticsRole.table => _semanticsTable, - SemanticsRole.cell => _semanticsCell, - SemanticsRole.row => _semanticsRow, - SemanticsRole.columnHeader => _semanticsColumnHeader, - SemanticsRole.radioGroup => _semanticsRadioGroup, - SemanticsRole.menu => _semanticsMenu, - SemanticsRole.menuBar => _semanticsMenuBar, - SemanticsRole.menuItem => _semanticsMenuItem, - SemanticsRole.menuItemCheckbox => _semanticsMenuItemCheckbox, - SemanticsRole.menuItemRadio => _semanticsMenuItemRadio, - SemanticsRole.alert => _noLiveRegion, - SemanticsRole.status => _noLiveRegion, - SemanticsRole.list => _noCheckRequired, - SemanticsRole.listItem => _semanticsListItem, - SemanticsRole.complementary => _semanticsComplementary, - SemanticsRole.contentInfo => _semanticsContentInfo, - SemanticsRole.main => _semanticsMain, - SemanticsRole.navigation => _semanticsNavigation, - SemanticsRole.region => _semanticsRegion, - SemanticsRole.form => _noCheckRequired, - // TODO(chunhtai): add checks when the roles are used in framework. - // https://github.com/flutter/flutter/issues/159741. - SemanticsRole.dragHandle => _unimplemented, - SemanticsRole.spinButton => _unimplemented, - SemanticsRole.comboBox => _unimplemented, - SemanticsRole.tooltip => _unimplemented, - SemanticsRole.loadingSpinner => _unimplemented, - SemanticsRole.progressBar => _unimplemented, - SemanticsRole.hotKey => _unimplemented, - }(node); + static FlutterError? _checkSemanticsData(SemanticsNode node) { + final FlutterError? error = switch (node.role) { + SemanticsRole.alertDialog => _noCheckRequired, + SemanticsRole.dialog => _noCheckRequired, + SemanticsRole.none => _noCheckRequired, + SemanticsRole.tab => _semanticsTab, + SemanticsRole.tabBar => _semanticsTabBar, + SemanticsRole.tabPanel => _noCheckRequired, + SemanticsRole.table => _semanticsTable, + SemanticsRole.cell => _semanticsCell, + SemanticsRole.row => _semanticsRow, + SemanticsRole.columnHeader => _semanticsColumnHeader, + SemanticsRole.radioGroup => _semanticsRadioGroup, + SemanticsRole.menu => _semanticsMenu, + SemanticsRole.menuBar => _semanticsMenuBar, + SemanticsRole.menuItem => _semanticsMenuItem, + SemanticsRole.menuItemCheckbox => _semanticsMenuItemCheckbox, + SemanticsRole.menuItemRadio => _semanticsMenuItemRadio, + SemanticsRole.alert => _noLiveRegion, + SemanticsRole.status => _noLiveRegion, + SemanticsRole.list => _noCheckRequired, + SemanticsRole.listItem => _semanticsListItem, + SemanticsRole.complementary => _semanticsComplementary, + SemanticsRole.contentInfo => _semanticsContentInfo, + SemanticsRole.main => _semanticsMain, + SemanticsRole.navigation => _semanticsNavigation, + SemanticsRole.region => _semanticsRegion, + SemanticsRole.form => _noCheckRequired, + // TODO(chunhtai): add checks when the roles are used in framework. + // https://github.com/flutter/flutter/issues/159741. + SemanticsRole.dragHandle => _unimplemented, + SemanticsRole.spinButton => _unimplemented, + SemanticsRole.comboBox => _unimplemented, + SemanticsRole.tooltip => _unimplemented, + SemanticsRole.loadingSpinner => _unimplemented, + SemanticsRole.progressBar => _unimplemented, + SemanticsRole.hotKey => _unimplemented, + }(node); + + if (error != null) { + return error; + } + + return _semanticsGeneral(node); + } static FlutterError? _unimplemented(SemanticsNode node) => FlutterError('Missing checks for role ${node.getSemanticsData().role}'); @@ -460,6 +468,30 @@ sealed class _DebugSemanticsRoleChecks { return null; } + + static FlutterError? _semanticsGeneral(SemanticsNode node) { + final SemanticsData data = node.getSemanticsData(); + final bool? isExpanded = data.flagsCollection.isExpanded.toBoolOrNull(); + + if (isExpanded != null) { + final bool hasExpandAction = data.hasAction(SemanticsAction.expand); + final bool hasCollapseAction = data.hasAction(SemanticsAction.collapse); + + if (hasExpandAction && hasCollapseAction) { + return FlutterError( + 'An expandable node cannot have both expand and collapse actions set at the same time.', + ); + } + if (isExpanded && hasExpandAction) { + return FlutterError('An expanded node cannot have an expand action.'); + } + if (!isExpanded && hasCollapseAction) { + return FlutterError('A collapsed node cannot have a collapse action.'); + } + } + + return null; + } } /// A tag for a [SemanticsNode]. @@ -1531,6 +1563,8 @@ class SemanticsProperties extends DiagnosticableTree { this.onDidLoseAccessibilityFocus, this.onFocus, this.onDismiss, + this.onExpand, + this.onCollapse, this.customSemanticsActions, }) : assert( label == null || attributedLabel == null, @@ -2331,6 +2365,24 @@ class SemanticsProperties extends DiagnosticableTree { /// gesture or menu option. final VoidCallback? onDismiss; + /// The handler for [SemanticsAction.expand]. + /// + /// This is a request to expand the currently focused node. For example, this + /// action might be recognized by a dropdown. + /// + /// This handler should only be set when the node is in a collapsed state + /// (i.e., [expanded] is false). + final VoidCallback? onExpand; + + /// The handler for [SemanticsAction.collapse]. + /// + /// This is a request to collapse the currently focused node. For example, + /// this action might be recognized by a dropdown. + /// + /// This handler should only be set when the node is in an expanded state + /// (i.e., [expanded] is true). + final VoidCallback? onCollapse; + /// A map from each supported [CustomSemanticsAction] to a provided handler. /// /// The handler associated with each custom action is called whenever a @@ -5040,6 +5092,28 @@ class SemanticsConfiguration { _onFocus = value; } + /// The handler for [SemanticsAction.expand]. + /// + /// This is a request to expand the currently focused node. + VoidCallback? get onExpand => _onExpand; + VoidCallback? _onExpand; + set onExpand(VoidCallback? value) { + assert(value != null); + _addArgumentlessAction(SemanticsAction.expand, value!); + _onExpand = value; + } + + /// The handler for [SemanticsAction.collapse]. + /// + /// This is a request to collapse the currently focused node. + VoidCallback? get onCollapse => _onCollapse; + VoidCallback? _onCollapse; + set onCollapse(VoidCallback? value) { + assert(value != null); + _addArgumentlessAction(SemanticsAction.collapse, value!); + _onCollapse = value; + } + /// A delegate that decides how to handle [SemanticsConfiguration]s produced /// in the widget subtree. /// diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index e3e991bf95e..f583d04a166 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -4039,6 +4039,8 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget { required VoidCallback? onDidGainAccessibilityFocus, required VoidCallback? onDidLoseAccessibilityFocus, required VoidCallback? onFocus, + required VoidCallback? onExpand, + required VoidCallback? onCollapse, required Map? customSemanticsActions, required SemanticsRole? role, required Set? controlsNodes, @@ -4116,6 +4118,8 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget { onDismiss: onDismiss, onSetSelection: onSetSelection, onSetText: onSetText, + onExpand: onExpand, + onCollapse: onCollapse, customSemanticsActions: customSemanticsActions, hintOverrides: onTapHint != null || onLongPressHint != null ? SemanticsHintOverrides(onTapHint: onTapHint, onLongPressHint: onLongPressHint) @@ -4363,6 +4367,8 @@ class SliverSemantics extends _SemanticsBase { super.onDidGainAccessibilityFocus, super.onDidLoseAccessibilityFocus, super.onFocus, + super.onExpand, + super.onCollapse, super.customSemanticsActions, super.role, super.controlsNodes, @@ -7936,6 +7942,8 @@ class Semantics extends _SemanticsBase { super.onDidGainAccessibilityFocus, super.onDidLoseAccessibilityFocus, super.onFocus, + super.onExpand, + super.onCollapse, super.customSemanticsActions, super.role, super.controlsNodes, diff --git a/packages/flutter/test/widgets/custom_painter_test.dart b/packages/flutter/test/widgets/custom_painter_test.dart index 38c6f947eb3..27837890ab3 100644 --- a/packages/flutter/test/widgets/custom_painter_test.dart +++ b/packages/flutter/test/widgets/custom_painter_test.dart @@ -461,6 +461,8 @@ void _defineTests() { onDidLoseAccessibilityFocus: () => performedActions.add(SemanticsAction.didLoseAccessibilityFocus), onFocus: () => performedActions.add(SemanticsAction.focus), + onExpand: () => performedActions.add(SemanticsAction.expand), + onCollapse: () => performedActions.add(SemanticsAction.collapse), ), ), ), @@ -523,6 +525,8 @@ void _defineTests() { case SemanticsAction.showOnScreen: case SemanticsAction.tap: case SemanticsAction.focus: + case SemanticsAction.expand: + case SemanticsAction.collapse: semanticsOwner.performAction(expectedId, action); } expect(performedActions.length, expectedLength); diff --git a/packages/flutter/test/widgets/semantics_checks_test.dart b/packages/flutter/test/widgets/semantics_checks_test.dart new file mode 100644 index 00000000000..f77db2149a3 --- /dev/null +++ b/packages/flutter/test/widgets/semantics_checks_test.dart @@ -0,0 +1,60 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('expandable', () { + testWidgets('success case, no actions', (WidgetTester tester) async { + await tester.pumpWidget(Semantics(expanded: false, child: const SizedBox())); + expect(tester.takeException(), isNull); + }); + + testWidgets('success case, collapsed with expand action', (WidgetTester tester) async { + await tester.pumpWidget(Semantics(expanded: false, onExpand: () {}, child: const SizedBox())); + expect(tester.takeException(), isNull); + }); + + testWidgets('success case, expanded with collapse action', (WidgetTester tester) async { + await tester.pumpWidget( + Semantics(expanded: true, onCollapse: () {}, child: const SizedBox()), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('failure case, both expand and collapse actions are set', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + Semantics(expanded: false, onExpand: () {}, onCollapse: () {}, child: const SizedBox()), + ); + final Object? exception = tester.takeException(); + expect(exception, isFlutterError); + final FlutterError error = exception! as FlutterError; + expect( + error.message, + 'An expandable node cannot have both expand and collapse actions set at the same time.', + ); + }); + + testWidgets('failure case, expanded with expand action', (WidgetTester tester) async { + await tester.pumpWidget(Semantics(expanded: true, onExpand: () {}, child: const SizedBox())); + final Object? exception = tester.takeException(); + expect(exception, isFlutterError); + final FlutterError error = exception! as FlutterError; + expect(error.message, 'An expanded node cannot have an expand action.'); + }); + + testWidgets('failure case, collapsed with collapse action', (WidgetTester tester) async { + await tester.pumpWidget( + Semantics(expanded: false, onCollapse: () {}, child: const SizedBox()), + ); + final Object? exception = tester.takeException(); + expect(exception, isFlutterError); + final FlutterError error = exception! as FlutterError; + expect(error.message, 'A collapsed node cannot have a collapse action.'); + }); + }); +} diff --git a/packages/flutter/test/widgets/semantics_test.dart b/packages/flutter/test/widgets/semantics_test.dart index ddd41ec4c2f..da54109f4ae 100644 --- a/packages/flutter/test/widgets/semantics_test.dart +++ b/packages/flutter/test/widgets/semantics_test.dart @@ -462,6 +462,8 @@ void main() { onDidLoseAccessibilityFocus: () => performedActions.add(SemanticsAction.didLoseAccessibilityFocus), onFocus: () => performedActions.add(SemanticsAction.focus), + onExpand: () => performedActions.add(SemanticsAction.expand), + onCollapse: () => performedActions.add(SemanticsAction.collapse), ), ); @@ -522,6 +524,8 @@ void main() { case SemanticsAction.showOnScreen: case SemanticsAction.tap: case SemanticsAction.focus: + case SemanticsAction.expand: + case SemanticsAction.collapse: semanticsOwner.performAction(expectedId, action); } expect(performedActions.length, expectedLength); diff --git a/packages/flutter/test/widgets/sliversemantics_test.dart b/packages/flutter/test/widgets/sliversemantics_test.dart index c06556d740b..a85585f1d46 100644 --- a/packages/flutter/test/widgets/sliversemantics_test.dart +++ b/packages/flutter/test/widgets/sliversemantics_test.dart @@ -825,6 +825,8 @@ void _tests() { onDidLoseAccessibilityFocus: () => performedActions.add(SemanticsAction.didLoseAccessibilityFocus), onFocus: () => performedActions.add(SemanticsAction.focus), + onExpand: () => performedActions.add(SemanticsAction.expand), + onCollapse: () => performedActions.add(SemanticsAction.collapse), sliver: SliverList( delegate: SliverChildBuilderDelegate((BuildContext context, int index) { return Card( @@ -878,6 +880,8 @@ void _tests() { SemanticsAction.dismiss, SemanticsAction.setText, SemanticsAction.focus, + SemanticsAction.expand, + SemanticsAction.collapse, ], children: [ TestSemantics(label: 'Lorem Ipsum 0', textDirection: TextDirection.ltr), @@ -929,6 +933,8 @@ void _tests() { case SemanticsAction.showOnScreen: case SemanticsAction.tap: case SemanticsAction.focus: + case SemanticsAction.expand: + case SemanticsAction.collapse: semanticsOwner.performAction(expectedId, action); } expect(performedActions.length, expectedLength);