mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
[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:
parent
fc9a6fa6d4
commit
c6ffbdf7ea
@ -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.
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -37,6 +37,8 @@ static ActionData action_mapping[] = {
|
||||
{kFlutterSemanticsActionMoveCursorBackwardByWord,
|
||||
"MoveCursorBackwardByWord"},
|
||||
{kFlutterSemanticsActionFocus, "Focus"},
|
||||
{kFlutterSemanticsActionExpand, "Expand"},
|
||||
{kFlutterSemanticsActionCollapse, "Collapse"},
|
||||
{static_cast<FlutterSemanticsAction>(0), nullptr}};
|
||||
|
||||
struct FlAccessibleNodePrivate {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
///
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
60
packages/flutter/test/widgets/semantics_checks_test.dart
Normal file
60
packages/flutter/test/widgets/semantics_checks_test.dart
Normal 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.');
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user