From 71d96ddf9ce2a015eab49ccf73faae926c64e0bf Mon Sep 17 00:00:00 2001 From: Qun Cheng <36861262+QuncCccccc@users.noreply.github.com> Date: Mon, 31 Jul 2023 12:09:27 -0700 Subject: [PATCH] Add Expanded/Collapsed State for Semantics (#131233) --- .../flutter/lib/src/material/menu_anchor.dart | 32 +++-- .../lib/src/rendering/custom_paint.dart | 3 + .../flutter/lib/src/rendering/proxy_box.dart | 3 + .../flutter/lib/src/semantics/semantics.dart | 24 ++++ packages/flutter/lib/src/widgets/basic.dart | 2 + .../test/material/menu_anchor_test.dart | 128 +++++++++++++++++- .../test/widgets/custom_painter_test.dart | 4 +- .../flutter/test/widgets/semantics_test.dart | 4 +- packages/flutter_test/lib/src/matchers.dart | 12 ++ packages/flutter_test/test/matchers_test.dart | 6 + 10 files changed, 201 insertions(+), 17 deletions(-) diff --git a/packages/flutter/lib/src/material/menu_anchor.dart b/packages/flutter/lib/src/material/menu_anchor.dart index f10d133ae1d..dd293e43405 100644 --- a/packages/flutter/lib/src/material/menu_anchor.dart +++ b/packages/flutter/lib/src/material/menu_anchor.dart @@ -1079,7 +1079,7 @@ class _MenuItemButtonState extends State { ); } - return child; + return MergeSemantics(child: child); } void _handleFocusChange() { @@ -1904,19 +1904,23 @@ class _SubmenuButtonState extends State { controller._anchor!._focusButton(); } } - - child = TextButton( - style: mergedStyle, - focusNode: _buttonFocusNode, - onHover: _enabled ? (bool hovering) => handleHover(hovering, context) : null, - onPressed: _enabled ? () => toggleShowMenu(context) : null, - isSemanticButton: null, - child: _MenuItemLabel( - leadingIcon: widget.leadingIcon, - trailingIcon: widget.trailingIcon, - hasSubmenu: true, - showDecoration: (controller._anchor!._parent?._orientation ?? Axis.horizontal) == Axis.vertical, - child: child ?? const SizedBox(), + child = MergeSemantics( + child: Semantics( + expanded: controller.isOpen, + child: TextButton( + style: mergedStyle, + focusNode: _buttonFocusNode, + onHover: _enabled ? (bool hovering) => handleHover(hovering, context) : null, + onPressed: _enabled ? () => toggleShowMenu(context) : null, + isSemanticButton: null, + child: _MenuItemLabel( + leadingIcon: widget.leadingIcon, + trailingIcon: widget.trailingIcon, + hasSubmenu: true, + showDecoration: (controller._anchor!._parent?._orientation ?? Axis.horizontal) == Axis.vertical, + child: child ?? const SizedBox(), + ), + ), ), ); diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart index cb2142315ce..320640a9fad 100644 --- a/packages/flutter/lib/src/rendering/custom_paint.dart +++ b/packages/flutter/lib/src/rendering/custom_paint.dart @@ -900,6 +900,9 @@ class RenderCustomPaint extends RenderProxyBox { if (properties.button != null) { config.isButton = properties.button!; } + if (properties.expanded != null) { + config.isExpanded = properties.expanded; + } if (properties.link != null) { config.isLink = properties.link!; } diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 1026d40d2de..5e6f2e740f5 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -4319,6 +4319,9 @@ class RenderSemanticsAnnotations extends RenderProxyBox { if (_properties.button != null) { config.isButton = _properties.button!; } + if (_properties.expanded != null) { + config.isExpanded = _properties.expanded; + } if (_properties.link != null) { config.isLink = _properties.link!; } diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 53d03c274bc..21e14f3d188 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -870,6 +870,7 @@ class SemanticsProperties extends DiagnosticableTree { this.enabled, this.checked, this.mixed, + this.expanded, this.selected, this.toggled, this.button, @@ -964,6 +965,14 @@ class SemanticsProperties extends DiagnosticableTree { /// This is mutually exclusive with [checked] and [toggled]. final bool? mixed; + /// If non-null, indicates that this subtree represents something + /// that can be in an "expanded" or "collapsed" state. + /// + /// For example, if a [SubmenuButton] is opened, this property + /// should be set to true; otherwise, this property should be + /// false. + final bool? expanded; + /// If non-null, indicates that this subtree represents a toggle switch /// or similar widget with an "on" state, and what its current /// state is. @@ -1612,6 +1621,7 @@ class SemanticsProperties extends DiagnosticableTree { super.debugFillProperties(properties); properties.add(DiagnosticsProperty('checked', checked, defaultValue: null)); properties.add(DiagnosticsProperty('mixed', mixed, defaultValue: null)); + properties.add(DiagnosticsProperty('expanded', expanded, defaultValue: null)); properties.add(DiagnosticsProperty('selected', selected, defaultValue: null)); properties.add(StringProperty('label', label, defaultValue: null)); properties.add(AttributedStringProperty('attributedLabel', attributedLabel, defaultValue: null)); @@ -4398,6 +4408,20 @@ class SemanticsConfiguration { _setFlag(SemanticsFlag.isSelected, value); } + /// If this node has Boolean state that can be controlled by the user, whether + /// that state is expanded or collapsed, corresponding to true and false, respectively. + /// + /// Do not call the setter for this field if the owning [RenderObject] doesn't + /// have expanded/collapsed state that can be controlled by the user. + /// + /// The getter returns null if the owning [RenderObject] does not have + /// expanded/collapsed state. + bool? get isExpanded => _hasFlag(SemanticsFlag.hasExpandedState) ? _hasFlag(SemanticsFlag.isExpanded) : null; + set isExpanded(bool? value) { + _setFlag(SemanticsFlag.hasExpandedState, true); + _setFlag(SemanticsFlag.isExpanded, value!); + } + /// Whether the owning [RenderObject] is currently enabled. /// /// A disabled object does not respond to user interactions. Only objects that diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index bc45977e041..bd69cf4ea25 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -7138,6 +7138,7 @@ class Semantics extends SingleChildRenderObjectWidget { bool? hidden, bool? image, bool? liveRegion, + bool? expanded, int? maxValueLength, int? currentValueLength, String? label, @@ -7186,6 +7187,7 @@ class Semantics extends SingleChildRenderObjectWidget { enabled: enabled, checked: checked, mixed: mixed, + expanded: expanded, toggled: toggled, selected: selected, button: button, diff --git a/packages/flutter/test/material/menu_anchor_test.dart b/packages/flutter/test/material/menu_anchor_test.dart index f29f0112664..220e7922405 100644 --- a/packages/flutter/test/material/menu_anchor_test.dart +++ b/packages/flutter/test/material/menu_anchor_test.dart @@ -3181,7 +3181,8 @@ void main() { children: [ TestSemantics( rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), - flags: [SemanticsFlag.hasEnabledState], + flags: [SemanticsFlag.hasEnabledState, + SemanticsFlag.hasExpandedState], label: 'ABC', textDirection: TextDirection.ltr, ), @@ -3194,6 +3195,131 @@ void main() { semantics.dispose(); }); + + testWidgets('SubmenuButton expanded/collapsed state', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SubmenuButton( + onHover: (bool value) {}, + style: SubmenuButton.styleFrom(fixedSize: const Size(88.0, 36.0)), + menuChildren: [ + MenuItemButton( + child: const Text('Item 0'), + onPressed: () {}, + ), + ], + child: const Text('ABC'), + ), + ), + ), + ); + + // Test expanded state. + await tester.tap(find.text('ABC')); + await tester.pumpAndSettle(); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + children: [ + TestSemantics( + id: 2, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + children: [ + TestSemantics( + id: 3, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 4, + flags: [SemanticsFlag.hasExpandedState, SemanticsFlag.isExpanded, SemanticsFlag.isFocused, SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable], + actions: [SemanticsAction.tap], + label: 'ABC', + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + ) + ] + ) + ] + ), + TestSemantics( + id: 6, + rect: const Rect.fromLTRB(0.0, 0.0, 123.0, 64.0), + children: [ + TestSemantics( + id: 7, + rect: const Rect.fromLTRB(0.0, 0.0, 123.0, 48.0), + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + id: 8, + label: 'Item 0', + rect: const Rect.fromLTRB(0.0, 0.0, 123.0, 48.0), + flags: [SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, SemanticsFlag.isFocusable], + actions: [SemanticsAction.tap], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ignoreTransform: true, + ), + ); + + // Test collapsed state. + await tester.tap(find.text('ABC')); + await tester.pumpAndSettle(); + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + children: [ + TestSemantics( + id: 2, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + children: [ + TestSemantics( + id: 3, + rect: const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0), + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 4, + flags: [SemanticsFlag.hasExpandedState, SemanticsFlag.isFocused, SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable], + actions: [SemanticsAction.tap], + label: 'ABC', + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + ) + ] + ) + ] + ), + ], + ), + ], + ), + ignoreTransform: true, + ), + ); + + semantics.dispose(); + }); }); } diff --git a/packages/flutter/test/widgets/custom_painter_test.dart b/packages/flutter/test/widgets/custom_painter_test.dart index bb991a88e0e..e9320e21df1 100644 --- a/packages/flutter/test/widgets/custom_painter_test.dart +++ b/packages/flutter/test/widgets/custom_painter_test.dart @@ -440,6 +440,7 @@ void _defineTests() { image: true, liveRegion: true, toggled: true, + expanded: true, ), ), ), @@ -494,6 +495,7 @@ void _defineTests() { namesRoute: true, image: true, liveRegion: true, + expanded: true, ), ), ), @@ -520,7 +522,7 @@ void _defineTests() { ); expect(semantics, hasSemantics(expectedSemantics, ignoreRect: true, ignoreTransform: true)); semantics.dispose(); - }, skip: true); // https://github.com/flutter/flutter/issues/127617 + }); group('diffing', () { testWidgets('complains about duplicate keys', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/semantics_test.dart b/packages/flutter/test/widgets/semantics_test.dart index bfe86488b25..8a95217212f 100644 --- a/packages/flutter/test/widgets/semantics_test.dart +++ b/packages/flutter/test/widgets/semantics_test.dart @@ -609,6 +609,7 @@ void main() { namesRoute: true, image: true, liveRegion: true, + expanded: true, ), ); final List flags = SemanticsFlag.values.toList(); @@ -691,6 +692,7 @@ void main() { namesRoute: true, image: true, liveRegion: true, + expanded: true, ), ); flags @@ -706,7 +708,7 @@ void main() { ], ); expect(semantics, hasSemantics(expectedSemantics, ignoreId: true)); - }, skip: true); // https://github.com/flutter/flutter/issues/127617 + }); testWidgets('Actions can be replaced without triggering semantics update', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index 3cc1bb6ce2e..7da2de31aa4 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -564,6 +564,8 @@ Matcher matchesSemantics({ bool hasToggledState = false, bool isToggled = false, bool hasImplicitScrolling = false, + bool hasExpandedState = false, + bool isExpanded = false, // Actions // bool hasTapAction = false, bool hasLongPressAction = false, @@ -640,6 +642,8 @@ Matcher matchesSemantics({ hasToggledState: hasToggledState, isToggled: isToggled, hasImplicitScrolling: hasImplicitScrolling, + hasExpandedState: hasExpandedState, + isExpanded: isExpanded, // Actions hasTapAction: hasTapAction, hasLongPressAction: hasLongPressAction, @@ -737,6 +741,8 @@ Matcher containsSemantics({ bool? hasToggledState, bool? isToggled, bool? hasImplicitScrolling, + bool? hasExpandedState, + bool? isExpanded, // Actions bool? hasTapAction, bool? hasLongPressAction, @@ -813,6 +819,8 @@ Matcher containsSemantics({ hasToggledState: hasToggledState, isToggled: isToggled, hasImplicitScrolling: hasImplicitScrolling, + hasExpandedState: hasExpandedState, + isExpanded: isExpanded, // Actions hasTapAction: hasTapAction, hasLongPressAction: hasLongPressAction, @@ -2111,6 +2119,8 @@ class _MatchesSemanticsData extends Matcher { required bool? hasToggledState, required bool? isToggled, required bool? hasImplicitScrolling, + required bool? hasExpandedState, + required bool? isExpanded, // Actions required bool? hasTapAction, required bool? hasLongPressAction, @@ -2166,6 +2176,8 @@ class _MatchesSemanticsData extends Matcher { if (isToggled != null) SemanticsFlag.isToggled: isToggled, if (hasImplicitScrolling != null) SemanticsFlag.hasImplicitScrolling: hasImplicitScrolling, if (isSlider != null) SemanticsFlag.isSlider: isSlider, + if (hasExpandedState != null) SemanticsFlag.hasExpandedState: hasExpandedState, + if (isExpanded != null) SemanticsFlag.isExpanded: isExpanded, }, actions = { if (hasTapAction != null) SemanticsAction.tap: hasTapAction, diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index b4be731f6e2..87d5fffa40a 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -683,6 +683,8 @@ void main() { hasToggledState: true, isToggled: true, hasImplicitScrolling: true, + hasExpandedState: true, + isExpanded: true, /* Actions */ hasTapAction: true, hasLongPressAction: true, @@ -966,6 +968,8 @@ void main() { hasToggledState: true, isToggled: true, hasImplicitScrolling: true, + hasExpandedState: true, + isExpanded: true, /* Actions */ hasTapAction: true, hasLongPressAction: true, @@ -1055,6 +1059,8 @@ void main() { hasToggledState: false, isToggled: false, hasImplicitScrolling: false, + hasExpandedState: false, + isExpanded: false, /* Actions */ hasTapAction: false, hasLongPressAction: false,