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:
parent
166f1d76de
commit
ed4dae3c27
@ -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,
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user