Skip to content

Commit c522865

Browse files
Huychunhtai
andauthored
Fix RadioButton is not vocalized as unselected by VoiceOver (#175926)
### Issue - Fix flutter/flutter#170422 ### Description This PR proposes a fix by adding a hint to Semantics here: https://github.com/flutter/flutter/blob/b220f5a2abf53c28623514b223b20a46c69a8ca6/packages/flutter/lib/src/widgets/raw_radio.dart#L217-L222 The hint is only available on iOS &macOS platforms with a localized string for `unselected` state to prevent regression on Android, which works as expected currently and this seems to be the only solution as investigated below ⬇️ <details open> <summary>Demo the fix</summary> https://github.com/user-attachments/assets/56c1c6c9-5178-45be-a633-47145a0543d6 </details> #### Why can't we make it simpler with Semantics flags only? - I looked at [UIAccessibilityTraits](https://developer.apple.com/documentation/uikit/uiaccessibilitytraits), it seems the [selected](https://developer.apple.com/documentation/uikit/uiaccessibilitytraits/selected) property does announce `selected` state, but it does not mention or give hints for `unselected` state. - [L824-L828](https://github.com/flutter/flutter/blob/e8bef98051944f0faee9913f76d22f2d0cc91712/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm#L824-L828) in Flutter source code does mention radio button: Looks like I can try marking both toggle and check, but it's impossible, due to an assertion: [A semantics node cannot be toggled and checked at the same time](https://github.com/flutter/flutter/blob/35375e43fbae661f41dd8a6bda7454f8d55a4d54/packages/flutter/lib/src/rendering/object.dart#L4807-L4809) ➡️ Not sure which flags can trigger `unselected` state implicitly. #### How do iOS native apps work? I did test with two approaches: 1. iOS Reminders app: VoiceOver vocalizes as follows: Unchecked radio: "...Incomplete..." checked radio:"...Completed..." <details> <summary>Demo video</summary> https://github.com/user-attachments/assets/6bed5d14-ded8-47c2-9c09-7afc266898cc </details> 2. Build Radio with SwiftUI Looks like [there is no built-in widget for Radio provided by Apple](https://www.reddit.com/r/SwiftUI/comments/1gsh7zo/does_swiftui_have_a_builtin_radiobutton_in_2024/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button). [Is it really a native iOS component?](https://www.reddit.com/r/SwiftUI/comments/1gsh7zo/comment/lxea1uf/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button) Most of results I have found suggest building radio from a button with changing image source(selected/unselected, respectively), as I do in this SwiftUI code here: https://gist.github.com/huycozy/93b12c030651c4fde6fe8fceda1ba1ee. We can control what the reader vocalizes by setting traits and value for it manually. This being said, setting accessibility hint or value from Flutter side makes sense in this case. Please let me know if I am missing something. <details> <summary>Demo video</summary> https://github.com/user-attachments/assets/118c89c5-46a3-497f-8b2a-6f7c5ae8edf3 </details> ## 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. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- 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 --------- Signed-off-by: huycozy <huy@nevercode.io> Co-authored-by: chunhtai <47866232+chunhtai@users.noreply.github.com>
1 parent e055ca0 commit c522865

119 files changed

Lines changed: 641 additions & 114 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/flutter/lib/src/widgets/localizations.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,9 @@ abstract class WidgetsLocalizations {
223223
/// Label for "share" edit buttons and menu items.
224224
String get shareButtonLabel;
225225

226+
/// The accessibility hint for an unselected radio button.
227+
String get radioButtonUnselectedLabel;
228+
226229
/// The `WidgetsLocalizations` from the closest [Localizations] instance
227230
/// that encloses the given context.
228231
///
@@ -319,6 +322,8 @@ class DefaultWidgetsLocalizations implements WidgetsLocalizations {
319322
@override
320323
String get shareButtonLabel => 'Share';
321324

325+
@override
326+
String get radioButtonUnselectedLabel => 'Not selected';
322327
@override
323328
TextDirection get textDirection => TextDirection.ltr;
324329

packages/flutter/lib/src/widgets/raw_radio.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
77
import 'basic.dart';
88
import 'focus_manager.dart';
99
import 'framework.dart';
10+
import 'localizations.dart';
1011
import 'radio_group.dart';
1112
import 'ticker_provider.dart';
1213
import 'toggleable.dart';
@@ -192,21 +193,32 @@ class _RawRadioState<T> extends State<RawRadio<T>>
192193
@override
193194
Widget build(BuildContext context) {
194195
final bool? accessibilitySelected;
196+
String? semanticsHint;
197+
195198
switch (defaultTargetPlatform) {
196199
case TargetPlatform.android:
197200
case TargetPlatform.fuchsia:
198201
case TargetPlatform.linux:
199202
case TargetPlatform.windows:
200203
accessibilitySelected = null;
204+
semanticsHint = null;
201205
case TargetPlatform.iOS:
202206
case TargetPlatform.macOS:
203207
accessibilitySelected = value;
208+
// Only provide hint for unselected radio buttons to avoid duplication
209+
// of the selected state announcement.
210+
// Selected state is already announced by iOS via the 'selected' property.
211+
if (!(value ?? false)) {
212+
final WidgetsLocalizations localizations = WidgetsLocalizations.of(context);
213+
semanticsHint = localizations.radioButtonUnselectedLabel;
214+
}
204215
}
205216

206217
return Semantics(
207218
inMutuallyExclusiveGroup: true,
208219
checked: value,
209220
selected: accessibilitySelected,
221+
hint: semanticsHint,
210222
child: buildToggleableWithChild(
211223
focusNode: focusNode,
212224
autofocus: widget.autofocus,

packages/flutter/test/cupertino/radio_test.dart

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,42 @@ void main() {
926926
kIsWeb ? SystemMouseCursors.click : SystemMouseCursors.basic,
927927
);
928928
});
929+
930+
// Regression tests for https://github.com/flutter/flutter/issues/170422
931+
group('Radio accessibility announcements on various platforms', () {
932+
testWidgets('Unselected radio should be vocalized via hint on iOS/macOS platform', (
933+
WidgetTester tester,
934+
) async {
935+
const WidgetsLocalizations localizations = DefaultWidgetsLocalizations();
936+
await tester.pumpWidget(
937+
CupertinoApp(
938+
home: Center(child: CupertinoRadio<int>(value: 2, groupValue: 1, onChanged: (int? i) {})),
939+
),
940+
);
941+
942+
final SemanticsNode semanticNode = tester.getSemantics(find.byType(Focus).last);
943+
if (defaultTargetPlatform == TargetPlatform.iOS ||
944+
defaultTargetPlatform == TargetPlatform.macOS) {
945+
expect(semanticNode.hint, localizations.radioButtonUnselectedLabel);
946+
} else {
947+
expect(semanticNode.hint, anyOf(isNull, isEmpty));
948+
}
949+
});
950+
951+
testWidgets('Selected radio should be vocalized via the selected flag on all platforms', (
952+
WidgetTester tester,
953+
) async {
954+
await tester.pumpWidget(
955+
CupertinoApp(
956+
home: Center(child: CupertinoRadio<int>(value: 1, groupValue: 1, onChanged: (int? i) {})),
957+
),
958+
);
959+
960+
final SemanticsNode semanticNode = tester.getSemantics(find.byType(Focus).last);
961+
// Radio semantics should not have hint.
962+
expect(semanticNode.hint, anyOf(isNull, isEmpty));
963+
});
964+
});
929965
}
930966

931967
class _RadioMouseCursor extends WidgetStateMouseCursor {

packages/flutter/test/material/radio_test.dart

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2723,4 +2723,53 @@ void main() {
27232723

27242724
focusNode.dispose();
27252725
});
2726+
2727+
// Regression tests for https://github.com/flutter/flutter/issues/170422
2728+
group('Radio accessibility announcements on various platforms', () {
2729+
testWidgets('Unselected radio should be vocalized via hint on iOS/macOS platform', (
2730+
WidgetTester tester,
2731+
) async {
2732+
const WidgetsLocalizations localizations = DefaultWidgetsLocalizations();
2733+
await tester.pumpWidget(
2734+
MaterialApp(
2735+
theme: theme,
2736+
home: Material(
2737+
child: RadioGroup<int>(
2738+
groupValue: 2,
2739+
onChanged: (int? value) {},
2740+
child: const Radio<int>(value: 1),
2741+
),
2742+
),
2743+
),
2744+
);
2745+
final SemanticsNode semanticNode = tester.getSemantics(find.byType(Focus).last);
2746+
if (defaultTargetPlatform == TargetPlatform.iOS ||
2747+
defaultTargetPlatform == TargetPlatform.macOS) {
2748+
expect(semanticNode.hint, localizations.radioButtonUnselectedLabel);
2749+
} else {
2750+
expect(semanticNode.hint, anyOf(isNull, isEmpty));
2751+
}
2752+
});
2753+
2754+
testWidgets('Selected radio should be vocalized via the selected flag on all platforms', (
2755+
WidgetTester tester,
2756+
) async {
2757+
await tester.pumpWidget(
2758+
MaterialApp(
2759+
theme: theme,
2760+
home: Material(
2761+
child: RadioGroup<int>(
2762+
groupValue: 1,
2763+
onChanged: (int? value) {},
2764+
child: const Radio<int>(value: 1),
2765+
),
2766+
),
2767+
),
2768+
);
2769+
2770+
final SemanticsNode semanticNode = tester.getSemantics(find.byType(Focus).last);
2771+
// Radio semantics should not have hint.
2772+
expect(semanticNode.hint, anyOf(isNull, isEmpty));
2773+
});
2774+
});
27262775
}

packages/flutter/test/widgets/raw_radio_test.dart

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
@Tags(<String>['reduced-test-set'])
88
library;
99

10-
import 'package:flutter/widgets.dart';
10+
import 'package:flutter/foundation.dart';
11+
import 'package:flutter/material.dart';
12+
import 'package:flutter/semantics.dart';
1113
import 'package:flutter_test/flutter_test.dart';
1214

1315
void main() {
@@ -96,6 +98,75 @@ void main() {
9698

9799
await expectLater(() => tester.pumpWidget(buildWidget()), throwsAssertionError);
98100
});
101+
102+
// Regression tests for https://github.com/flutter/flutter/issues/170422
103+
group('Raw Radio accessibility announcements on various platforms', () {
104+
testWidgets('Unselected radio should be vocalized via hint on iOS/macOS platform', (
105+
WidgetTester tester,
106+
) async {
107+
final TestRegistry<int> registry = TestRegistry<int>();
108+
registry.groupValue = 2; // To mark radio as unselected.
109+
const WidgetsLocalizations localizations = DefaultWidgetsLocalizations();
110+
final FocusNode node = FocusNode();
111+
addTearDown(node.dispose);
112+
113+
await tester.pumpWidget(
114+
MaterialApp(
115+
home: RawRadio<int>(
116+
value: 1,
117+
mouseCursor: WidgetStateProperty.all<MouseCursor>(SystemMouseCursors.click),
118+
toggleable: false,
119+
focusNode: node,
120+
autofocus: false,
121+
enabled: true,
122+
groupRegistry: registry,
123+
builder: (BuildContext context, ToggleableStateMixin state) {
124+
return const SizedBox.square(dimension: 24);
125+
},
126+
),
127+
),
128+
);
129+
130+
final SemanticsNode semanticNode = tester.getSemantics(find.byType(RawRadio<int>));
131+
// Radio semantics should have hint.
132+
if (defaultTargetPlatform == TargetPlatform.iOS ||
133+
defaultTargetPlatform == TargetPlatform.macOS) {
134+
expect(semanticNode.hint, localizations.radioButtonUnselectedLabel);
135+
} else {
136+
expect(semanticNode.hint, anyOf(isNull, isEmpty));
137+
}
138+
});
139+
140+
testWidgets('Selected radio should be vocalized via the selected flag on all platforms', (
141+
WidgetTester tester,
142+
) async {
143+
final TestRegistry<int> registry = TestRegistry<int>();
144+
registry.groupValue = 1; // To mark radio as selected.
145+
final FocusNode node = FocusNode();
146+
addTearDown(node.dispose);
147+
148+
await tester.pumpWidget(
149+
MaterialApp(
150+
home: RawRadio<int>(
151+
value: 1,
152+
mouseCursor: WidgetStateProperty.all<MouseCursor>(SystemMouseCursors.click),
153+
toggleable: false,
154+
focusNode: node,
155+
autofocus: false,
156+
enabled: true,
157+
groupRegistry: registry,
158+
builder: (BuildContext context, ToggleableStateMixin state) {
159+
return const SizedBox.square(dimension: 24);
160+
},
161+
),
162+
),
163+
);
164+
165+
final SemanticsNode semantics = tester.getSemantics(find.byType(RawRadio<int>));
166+
// Radio semantics should not have hint.
167+
expect(semantics.hint, anyOf(isNull, isEmpty));
168+
});
169+
});
99170
}
100171

101172
class TestRegistry<T> extends RadioGroupRegistry<T> {

0 commit comments

Comments
 (0)