[a11y] Add expanded flag support to Android. (#174981)

Closes https://github.com/flutter/flutter/issues/92040

- Adds `expanded` semantics flag support to Android
- Adds `onExpand` and `onCollapse` semantics actions
- Updates `robolectric` library
- Adds java and dart tests

#### Why were `onExpand` and `onCollapse` actions added?
It turned out that TalkBack doesn't announce the `expanded` state if
`expand/collapse` action is not set for the accessibility node.

#### Why was the `robolectric` library updated?

The `expanded` state support in Android was introduced in API 36. The
`roboelectric: 4.14.1` doesn't support API 36. To run tests for a newly
added functionality `roboelectric` library was updated to `4.16`, which
supports the latest Android version
(https://github.com/robolectric/robolectric/releases/tag/robolectric-4.16).

In case you think it would be better to update the `roboelectric` in a
separate PR, please let me know.

<br/>
<details>
<summary>Example Source Code</summary>

```dart

import 'package:flutter/material.dart';

void main() {
  runApp(const App());
}

class App extends StatefulWidget {
  const App({super.key});

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  final _controller = ExpansibleController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          spacing: 24,
          children: [
            Text('Expansible Example'),
            ListenableBuilder(
              listenable: _controller,
              builder: (context, child) {
                return Semantics(
                  expanded: _controller.isExpanded,
                  onExpand: () {
                    print(' \n onExpand \n ');
                    _controller.expand();
                  },
                  onCollapse: () {
                    print(' \n onCollapse \n ');
                    _controller.collapse();
                  },
                  child: child,
                );
              },
              child: Expansible(
                headerBuilder: (context, _) => ListTile(
                  tileColor: Colors.blue.shade100,
                  leading: Text(
                    'Expansible',
                    style: TextStyle(fontSize: 20),
                  ),
                  trailing: Icon(
                    _controller.isExpanded
                        ? Icons.arrow_upward
                        : Icons.arrow_downward,
                    semanticLabel: _controller.isExpanded
                        ? 'Arrow Up icon'
                        : 'Arrow Down icon',
                  ),
                ),
                bodyBuilder: (context, _) {
                  return Container(
                    color: Colors.blue,
                    height: 200,
                    width: 200,
                  );
                },
                controller: _controller,
              ),
            ),
          ],
        ),
      ),
    );
  }
}
```

</details>



https://github.com/user-attachments/assets/256c4182-a1e3-44fc-b028-5e6c9ec05ad7



## Pre-launch Checklist

- [X] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [X] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [X] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [X] I signed the [CLA].
- [X] I listed at least one issue that this PR fixes in the description
above.
- [X] I updated/added relevant documentation (doc comments with `///`).
- [X] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [X] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [X] All existing and new tests are passing.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

---------

Co-authored-by: ash2moon <muhatashim@google.com>
This commit is contained in:
Kostia Sokolovskyi 2025-09-27 02:13:18 +02:00 committed by GitHub
parent fc9a6fa6d4
commit c6ffbdf7ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 429 additions and 41 deletions

View File

@ -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.

View File

@ -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 =

View File

@ -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<int, SemanticsAction> _kActionById = <int, SemanticsAction>{
_kTapIndex: tap,
@ -115,6 +119,8 @@ class SemanticsAction {
_kMoveCursorBackwardByWordIndex: moveCursorBackwardByWord,
_kSetTextIndex: setText,
_kFocusIndex: focus,
_kExpandIndex: expand,
_kCollapseIndex: collapse,
};
static List<SemanticsAction> get values => _kActionById.values.toList(growable: false);

View File

@ -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) {

View File

@ -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"

View File

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

View File

@ -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<AccessibilityNodeInfo.AccessibilityAction> 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<AccessibilityNodeInfo.AccessibilityAction> 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);
}

View File

@ -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.

View File

@ -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;
}

View File

@ -37,6 +37,8 @@ static ActionData action_mapping[] = {
{kFlutterSemanticsActionMoveCursorBackwardByWord,
"MoveCursorBackwardByWord"},
{kFlutterSemanticsActionFocus, "Focus"},
{kFlutterSemanticsActionExpand, "Expand"},
{kFlutterSemanticsActionCollapse, "Collapse"},
{static_cast<FlutterSemanticsAction>(0), nullptr}};
struct FlAccessibleNodePrivate {

View File

@ -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) {

View File

@ -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,

View File

@ -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.

View File

@ -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.
///

View File

@ -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<CustomSemanticsAction, VoidCallback>? customSemanticsActions,
required SemanticsRole? role,
required Set<String>? 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,

View File

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

View File

@ -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.');
});
});
}

View File

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

View File

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