Windows: Focus slider on gaining a11y focus (#95295)

Microsoft Active Accessibility (MSAA) does not include
increment/decrement keyboard shortcuts for manipulating sliders and
other similar controls. To make up for this, we give the slider keyboard
focus when it gains accessibility focus so that the user can use the
arrow keys to manipulate the slider.

Issue: https://github.com/flutter/flutter/issues/77838
This commit is contained in:
Chris Bracken 2021-12-14 17:40:36 -08:00 committed by GitHub
parent 166f1d76de
commit ed4dae3c27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 275 additions and 4 deletions

View File

@ -481,6 +481,9 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
// Value Indicator Animation that appears on the Overlay. // Value Indicator Animation that appears on the Overlay.
PaintValueIndicator? paintValueIndicator; PaintValueIndicator? paintValueIndicator;
FocusNode? _focusNode;
FocusNode get focusNode => widget.focusNode ?? _focusNode!;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -507,6 +510,10 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
onInvoke: _actionHandler, onInvoke: _actionHandler,
), ),
}; };
if (widget.focusNode == null) {
// Only create a new node if the widget doesn't have one.
_focusNode ??= FocusNode();
}
} }
@override @override
@ -520,6 +527,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
overlayEntry!.remove(); overlayEntry!.remove();
overlayEntry = null; overlayEntry = null;
} }
_focusNode?.dispose();
super.dispose(); super.dispose();
} }
@ -698,13 +706,32 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
// in range_slider.dart. // in range_slider.dart.
Size _screenSize() => MediaQuery.of(context).size; Size _screenSize() => MediaQuery.of(context).size;
VoidCallback? handleDidGainAccessibilityFocus;
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
case TargetPlatform.linux:
case TargetPlatform.macOS:
break;
case TargetPlatform.windows:
handleDidGainAccessibilityFocus = () {
// Automatically activate the slider when it receives a11y focus.
if (!focusNode.hasFocus && focusNode.canRequestFocus) {
focusNode.requestFocus();
}
};
break;
}
return Semantics( return Semantics(
container: true, container: true,
slider: true, slider: true,
onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus,
child: FocusableActionDetector( child: FocusableActionDetector(
actions: _actionMap, actions: _actionMap,
shortcuts: _shortcutMap, shortcuts: _shortcutMap,
focusNode: widget.focusNode, focusNode: focusNode,
autofocus: widget.autofocus, autofocus: widget.autofocus,
enabled: _enabled, enabled: _enabled,
onShowFocusHighlight: _handleFocusHighlightChanged, onShowFocusHighlight: _handleFocusHighlightChanged,

View File

@ -1322,8 +1322,16 @@ void main() {
children: <TestSemantics>[ children: <TestSemantics>[
TestSemantics( TestSemantics(
id: 4, id: 4,
flags: <SemanticsFlag>[SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, SemanticsFlag.isFocusable, SemanticsFlag.isSlider], flags: <SemanticsFlag>[
actions: <SemanticsAction>[SemanticsAction.increase, SemanticsAction.decrease], SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
SemanticsFlag.isSlider,
],
actions: <SemanticsAction>[
SemanticsAction.increase,
SemanticsAction.decrease,
],
value: '50%', value: '50%',
increasedValue: '55%', increasedValue: '55%',
decreasedValue: '45%', decreasedValue: '45%',
@ -1439,7 +1447,7 @@ void main() {
); );
semantics.dispose(); semantics.dispose();
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux }));
testWidgets('Slider Semantics', (WidgetTester tester) async { testWidgets('Slider Semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester); final SemanticsTester semantics = SemanticsTester(tester);
@ -1552,6 +1560,175 @@ void main() {
semantics.dispose(); semantics.dispose();
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('Slider Semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(MaterialApp(
home: Directionality(
textDirection: TextDirection.ltr,
child: Material(
child: Slider(
value: 0.5,
onChanged: (double v) { },
),
),
),
));
await tester.pumpAndSettle();
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
textDirection: TextDirection.ltr,
children: <TestSemantics>[
TestSemantics(
id: 2,
children: <TestSemantics>[
TestSemantics(
id: 3,
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[
TestSemantics(
id: 4,
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
SemanticsFlag.isSlider,
],
actions: <SemanticsAction>[
SemanticsAction.increase,
SemanticsAction.decrease,
SemanticsAction.didGainAccessibilityFocus,
],
value: '50%',
increasedValue: '55%',
decreasedValue: '45%',
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
],
),
ignoreRect: true,
ignoreTransform: true,
),
);
// Disable slider
await tester.pumpWidget(const MaterialApp(
home: Directionality(
textDirection: TextDirection.ltr,
child: Material(
child: Slider(
value: 0.5,
onChanged: null,
),
),
),
));
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
textDirection: TextDirection.ltr,
children: <TestSemantics>[
TestSemantics(
id: 2,
children: <TestSemantics>[
TestSemantics(
id: 3,
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[
TestSemantics(
id: 4,
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
// isFocusable is delayed by 1 frame.
SemanticsFlag.isFocusable,
SemanticsFlag.isSlider,
],
actions: <SemanticsAction>[
SemanticsAction.didGainAccessibilityFocus,
],
value: '50%',
increasedValue: '55%',
decreasedValue: '45%',
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
],
),
ignoreRect: true,
ignoreTransform: true,
),
);
await tester.pump();
expect(
semantics,
hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
textDirection: TextDirection.ltr,
children: <TestSemantics>[
TestSemantics(
id: 2,
children: <TestSemantics>[
TestSemantics(
id: 3,
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[
TestSemantics(
id: 4,
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.isSlider,
],
actions: <SemanticsAction>[
SemanticsAction.didGainAccessibilityFocus,
],
value: '50%',
increasedValue: '55%',
decreasedValue: '45%',
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
],
),
ignoreRect: true,
ignoreTransform: true,
),
);
semantics.dispose();
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.windows }));
testWidgets('Slider semantics with custom formatter', (WidgetTester tester) async { testWidgets('Slider semantics with custom formatter', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester); final SemanticsTester semantics = SemanticsTester(tester);
@ -1887,6 +2064,73 @@ void main() {
expect(value, 0.5); expect(value, 0.5);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('Slider gains keyboard focus when it gains semantics focus on Windows', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!;
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Slider(
value: 0.5,
onChanged: (double _) {},
focusNode: focusNode,
),
),
),
);
expect(semantics, hasSemantics(
TestSemantics.root(
children: <TestSemantics>[
TestSemantics(
id: 1,
textDirection: TextDirection.ltr,
children: <TestSemantics>[
TestSemantics(
id: 2,
children: <TestSemantics>[
TestSemantics(
id: 3,
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
children: <TestSemantics>[
TestSemantics(
id: 4,
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
SemanticsFlag.isSlider,
],
actions: <SemanticsAction>[
SemanticsAction.increase,
SemanticsAction.decrease,
SemanticsAction.didGainAccessibilityFocus,
],
value: '50%',
increasedValue: '55%',
decreasedValue: '45%',
textDirection: TextDirection.ltr,
),
],
),
],
),
],
),
],
),
ignoreRect: true,
ignoreTransform: true,
));
expect(focusNode.hasFocus, isFalse);
semanticsOwner.performAction(4, SemanticsAction.didGainAccessibilityFocus);
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isTrue);
semantics.dispose();
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.windows }));
testWidgets('Value indicator appears when it should', (WidgetTester tester) async { testWidgets('Value indicator appears when it should', (WidgetTester tester) async {
final ThemeData baseTheme = ThemeData( final ThemeData baseTheme = ThemeData(
platform: TargetPlatform.android, platform: TargetPlatform.android,