Skip to content

Commit ed4dae3

Browse files
authored
Windows: Focus slider on gaining a11y focus (flutter#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: flutter#77838
1 parent 166f1d7 commit ed4dae3

File tree

2 files changed

+275
-4
lines changed

2 files changed

+275
-4
lines changed

packages/flutter/lib/src/material/slider.dart

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,9 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
481481
// Value Indicator Animation that appears on the Overlay.
482482
PaintValueIndicator? paintValueIndicator;
483483

484+
FocusNode? _focusNode;
485+
FocusNode get focusNode => widget.focusNode ?? _focusNode!;
486+
484487
@override
485488
void initState() {
486489
super.initState();
@@ -507,6 +510,10 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
507510
onInvoke: _actionHandler,
508511
),
509512
};
513+
if (widget.focusNode == null) {
514+
// Only create a new node if the widget doesn't have one.
515+
_focusNode ??= FocusNode();
516+
}
510517
}
511518

512519
@override
@@ -520,6 +527,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
520527
overlayEntry!.remove();
521528
overlayEntry = null;
522529
}
530+
_focusNode?.dispose();
523531
super.dispose();
524532
}
525533

@@ -698,13 +706,32 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
698706
// in range_slider.dart.
699707
Size _screenSize() => MediaQuery.of(context).size;
700708

709+
VoidCallback? handleDidGainAccessibilityFocus;
710+
switch (theme.platform) {
711+
case TargetPlatform.android:
712+
case TargetPlatform.fuchsia:
713+
case TargetPlatform.iOS:
714+
case TargetPlatform.linux:
715+
case TargetPlatform.macOS:
716+
break;
717+
case TargetPlatform.windows:
718+
handleDidGainAccessibilityFocus = () {
719+
// Automatically activate the slider when it receives a11y focus.
720+
if (!focusNode.hasFocus && focusNode.canRequestFocus) {
721+
focusNode.requestFocus();
722+
}
723+
};
724+
break;
725+
}
726+
701727
return Semantics(
702728
container: true,
703729
slider: true,
730+
onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus,
704731
child: FocusableActionDetector(
705732
actions: _actionMap,
706733
shortcuts: _shortcutMap,
707-
focusNode: widget.focusNode,
734+
focusNode: focusNode,
708735
autofocus: widget.autofocus,
709736
enabled: _enabled,
710737
onShowFocusHighlight: _handleFocusHighlightChanged,

packages/flutter/test/material/slider_test.dart

Lines changed: 247 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,8 +1322,16 @@ void main() {
13221322
children: <TestSemantics>[
13231323
TestSemantics(
13241324
id: 4,
1325-
flags: <SemanticsFlag>[SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled, SemanticsFlag.isFocusable, SemanticsFlag.isSlider],
1326-
actions: <SemanticsAction>[SemanticsAction.increase, SemanticsAction.decrease],
1325+
flags: <SemanticsFlag>[
1326+
SemanticsFlag.hasEnabledState,
1327+
SemanticsFlag.isEnabled,
1328+
SemanticsFlag.isFocusable,
1329+
SemanticsFlag.isSlider,
1330+
],
1331+
actions: <SemanticsAction>[
1332+
SemanticsAction.increase,
1333+
SemanticsAction.decrease,
1334+
],
13271335
value: '50%',
13281336
increasedValue: '55%',
13291337
decreasedValue: '45%',
@@ -1439,7 +1447,7 @@ void main() {
14391447
);
14401448

14411449
semantics.dispose();
1442-
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows }));
1450+
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux }));
14431451

14441452
testWidgets('Slider Semantics', (WidgetTester tester) async {
14451453
final SemanticsTester semantics = SemanticsTester(tester);
@@ -1552,6 +1560,175 @@ void main() {
15521560
semantics.dispose();
15531561
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
15541562

1563+
testWidgets('Slider Semantics', (WidgetTester tester) async {
1564+
final SemanticsTester semantics = SemanticsTester(tester);
1565+
1566+
await tester.pumpWidget(MaterialApp(
1567+
home: Directionality(
1568+
textDirection: TextDirection.ltr,
1569+
child: Material(
1570+
child: Slider(
1571+
value: 0.5,
1572+
onChanged: (double v) { },
1573+
),
1574+
),
1575+
),
1576+
));
1577+
1578+
await tester.pumpAndSettle();
1579+
1580+
expect(
1581+
semantics,
1582+
hasSemantics(
1583+
TestSemantics.root(
1584+
children: <TestSemantics>[
1585+
TestSemantics(
1586+
id: 1,
1587+
textDirection: TextDirection.ltr,
1588+
children: <TestSemantics>[
1589+
TestSemantics(
1590+
id: 2,
1591+
children: <TestSemantics>[
1592+
TestSemantics(
1593+
id: 3,
1594+
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
1595+
children: <TestSemantics>[
1596+
TestSemantics(
1597+
id: 4,
1598+
flags: <SemanticsFlag>[
1599+
SemanticsFlag.hasEnabledState,
1600+
SemanticsFlag.isEnabled,
1601+
SemanticsFlag.isFocusable,
1602+
SemanticsFlag.isSlider,
1603+
],
1604+
actions: <SemanticsAction>[
1605+
SemanticsAction.increase,
1606+
SemanticsAction.decrease,
1607+
SemanticsAction.didGainAccessibilityFocus,
1608+
],
1609+
value: '50%',
1610+
increasedValue: '55%',
1611+
decreasedValue: '45%',
1612+
textDirection: TextDirection.ltr,
1613+
),
1614+
],
1615+
),
1616+
],
1617+
),
1618+
],
1619+
),
1620+
],
1621+
),
1622+
ignoreRect: true,
1623+
ignoreTransform: true,
1624+
),
1625+
);
1626+
1627+
// Disable slider
1628+
await tester.pumpWidget(const MaterialApp(
1629+
home: Directionality(
1630+
textDirection: TextDirection.ltr,
1631+
child: Material(
1632+
child: Slider(
1633+
value: 0.5,
1634+
onChanged: null,
1635+
),
1636+
),
1637+
),
1638+
));
1639+
1640+
expect(
1641+
semantics,
1642+
hasSemantics(
1643+
TestSemantics.root(
1644+
children: <TestSemantics>[
1645+
TestSemantics(
1646+
id: 1,
1647+
textDirection: TextDirection.ltr,
1648+
children: <TestSemantics>[
1649+
TestSemantics(
1650+
id: 2,
1651+
children: <TestSemantics>[
1652+
TestSemantics(
1653+
id: 3,
1654+
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
1655+
children: <TestSemantics>[
1656+
TestSemantics(
1657+
id: 4,
1658+
flags: <SemanticsFlag>[
1659+
SemanticsFlag.hasEnabledState,
1660+
// isFocusable is delayed by 1 frame.
1661+
SemanticsFlag.isFocusable,
1662+
SemanticsFlag.isSlider,
1663+
],
1664+
actions: <SemanticsAction>[
1665+
SemanticsAction.didGainAccessibilityFocus,
1666+
],
1667+
value: '50%',
1668+
increasedValue: '55%',
1669+
decreasedValue: '45%',
1670+
textDirection: TextDirection.ltr,
1671+
),
1672+
],
1673+
),
1674+
],
1675+
),
1676+
],
1677+
),
1678+
],
1679+
),
1680+
ignoreRect: true,
1681+
ignoreTransform: true,
1682+
),
1683+
);
1684+
1685+
await tester.pump();
1686+
expect(
1687+
semantics,
1688+
hasSemantics(
1689+
TestSemantics.root(
1690+
children: <TestSemantics>[
1691+
TestSemantics(
1692+
id: 1,
1693+
textDirection: TextDirection.ltr,
1694+
children: <TestSemantics>[
1695+
TestSemantics(
1696+
id: 2,
1697+
children: <TestSemantics>[
1698+
TestSemantics(
1699+
id: 3,
1700+
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
1701+
children: <TestSemantics>[
1702+
TestSemantics(
1703+
id: 4,
1704+
flags: <SemanticsFlag>[
1705+
SemanticsFlag.hasEnabledState,
1706+
SemanticsFlag.isSlider,
1707+
],
1708+
actions: <SemanticsAction>[
1709+
SemanticsAction.didGainAccessibilityFocus,
1710+
],
1711+
value: '50%',
1712+
increasedValue: '55%',
1713+
decreasedValue: '45%',
1714+
textDirection: TextDirection.ltr,
1715+
),
1716+
],
1717+
),
1718+
],
1719+
),
1720+
],
1721+
),
1722+
],
1723+
),
1724+
ignoreRect: true,
1725+
ignoreTransform: true,
1726+
),
1727+
);
1728+
1729+
semantics.dispose();
1730+
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.windows }));
1731+
15551732
testWidgets('Slider semantics with custom formatter', (WidgetTester tester) async {
15561733
final SemanticsTester semantics = SemanticsTester(tester);
15571734

@@ -1887,6 +2064,73 @@ void main() {
18872064
expect(value, 0.5);
18882065
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
18892066

2067+
testWidgets('Slider gains keyboard focus when it gains semantics focus on Windows', (WidgetTester tester) async {
2068+
final SemanticsTester semantics = SemanticsTester(tester);
2069+
final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!;
2070+
final FocusNode focusNode = FocusNode();
2071+
await tester.pumpWidget(
2072+
MaterialApp(
2073+
home: Material(
2074+
child: Slider(
2075+
value: 0.5,
2076+
onChanged: (double _) {},
2077+
focusNode: focusNode,
2078+
),
2079+
),
2080+
),
2081+
);
2082+
2083+
expect(semantics, hasSemantics(
2084+
TestSemantics.root(
2085+
children: <TestSemantics>[
2086+
TestSemantics(
2087+
id: 1,
2088+
textDirection: TextDirection.ltr,
2089+
children: <TestSemantics>[
2090+
TestSemantics(
2091+
id: 2,
2092+
children: <TestSemantics>[
2093+
TestSemantics(
2094+
id: 3,
2095+
flags: <SemanticsFlag>[SemanticsFlag.scopesRoute],
2096+
children: <TestSemantics>[
2097+
TestSemantics(
2098+
id: 4,
2099+
flags: <SemanticsFlag>[
2100+
SemanticsFlag.hasEnabledState,
2101+
SemanticsFlag.isEnabled,
2102+
SemanticsFlag.isFocusable,
2103+
SemanticsFlag.isSlider,
2104+
],
2105+
actions: <SemanticsAction>[
2106+
SemanticsAction.increase,
2107+
SemanticsAction.decrease,
2108+
SemanticsAction.didGainAccessibilityFocus,
2109+
],
2110+
value: '50%',
2111+
increasedValue: '55%',
2112+
decreasedValue: '45%',
2113+
textDirection: TextDirection.ltr,
2114+
),
2115+
],
2116+
),
2117+
],
2118+
),
2119+
],
2120+
),
2121+
],
2122+
),
2123+
ignoreRect: true,
2124+
ignoreTransform: true,
2125+
));
2126+
2127+
expect(focusNode.hasFocus, isFalse);
2128+
semanticsOwner.performAction(4, SemanticsAction.didGainAccessibilityFocus);
2129+
await tester.pumpAndSettle();
2130+
expect(focusNode.hasFocus, isTrue);
2131+
semantics.dispose();
2132+
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.windows }));
2133+
18902134
testWidgets('Value indicator appears when it should', (WidgetTester tester) async {
18912135
final ThemeData baseTheme = ThemeData(
18922136
platform: TargetPlatform.android,

0 commit comments

Comments
 (0)