Skip to content

Commit 6c5b5b4

Browse files
committed
Set AccessibilityNodeInfo stateDescription for checked state on Android
TalkBack speaks the unchecked state of a checkable node, and avoids announcing "checked" and "on" together for a switch, only when the node carries a stateDescription (API 30+). Native CompoundButton sets it, but ReactAccessibilityDelegate never did, so a React Native checkbox or radio with checked: false was announced with no state at all, and a switch with checked: true was read as "checked. on.". Set stateDescription whenever the checked state is a boolean: "on"/"off" for the switch role and "checked"/"not checked" otherwise, using the existing uimanager string resources. Fixes #58739 Fixes #58588
1 parent eec38e3 commit 6c5b5b4

3 files changed

Lines changed: 126 additions & 1 deletion

File tree

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac
9999
// state is changeable.
100100
val accessibilityState = host.getTag(R.id.accessibility_state) as ReadableMap?
101101
if (accessibilityState != null) {
102-
setState(info, accessibilityState)
102+
setState(info, accessibilityState, accessibilityRole, host.context)
103103
}
104104
val accessibilityActions = host.getTag(R.id.accessibility_actions) as ReadableArray?
105105

@@ -626,6 +626,8 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac
626626
private fun setState(
627627
info: AccessibilityNodeInfoCompat,
628628
accessibilityState: ReadableMap,
629+
role: AccessibilityRole?,
630+
context: Context,
629631
) {
630632
val i = accessibilityState.keySetIterator()
631633
while (i.hasNextKey()) {
@@ -639,10 +641,30 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac
639641
val boolValue = value.asBoolean()
640642
info.isCheckable = true
641643
info.isChecked = boolValue
644+
// TalkBack only speaks the unchecked state of a checkable node, and only avoids
645+
// announcing "checked" and "on" together for a switch, when the node carries a
646+
// stateDescription (API 30+). Native CompoundButton always sets it, so do the same.
647+
info.stateDescription = getCheckedStateDescription(boolValue, role, context)
642648
}
643649
}
644650
}
645651

652+
private fun getCheckedStateDescription(
653+
checked: Boolean,
654+
role: AccessibilityRole?,
655+
context: Context,
656+
): String =
657+
if (role == AccessibilityRole.SWITCH) {
658+
context.getString(
659+
if (checked) R.string.state_on_description else R.string.state_off_description
660+
)
661+
} else {
662+
context.getString(
663+
if (checked) R.string.state_checked_description
664+
else R.string.state_unchecked_description
665+
)
666+
}
667+
646668
// TODO: Eventually support for other languages on talkback
647669
@JvmStatic
648670
public fun setRole(

‎packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/strings.xml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@
9696
name="state_off_description"
9797
description="a switch in its disabled state"
9898
>off</string>
99+
<string
100+
name="state_checked_description"
101+
description="a checkbox, radio button, or other checkable widget which is checked"
102+
>checked</string>
103+
<string
104+
name="state_unchecked_description"
105+
description="a checkbox, radio button, or other checkable widget which is not checked"
106+
>not checked</string>
99107
<string
100108
name="state_mixed_description"
101109
description="a checkbox, radio button, or other widget which is both checked and unchecked"

‎packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/ReactAccessibilityDelegateTest.kt‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.facebook.react.bridge.BridgeReactContext
1616
import com.facebook.react.bridge.JavaOnlyArray
1717
import com.facebook.react.bridge.JavaOnlyMap
1818
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
19+
import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole
1920
import com.facebook.react.views.view.ReactViewGroup
2021
import com.facebook.testutils.shadows.ShadowArguments
2122
import org.assertj.core.api.Assertions.assertThat
@@ -382,4 +383,98 @@ class ReactAccessibilityDelegateTest {
382383
assertThat(result).isTrue()
383384
assertThat(view.getTag(R.id.accessibility_state_expanded)).isEqualTo(false)
384385
}
386+
387+
@Test
388+
fun testOnInitializeAccessibilityNodeInfo_uncheckedCheckbox_setsStateDescription() {
389+
val info = initializeNodeInfoWithCheckedState(AccessibilityRole.CHECKBOX, checked = false)
390+
391+
assertThat(info.isCheckable).isTrue()
392+
assertThat(info.isChecked).isFalse()
393+
assertThat(info.stateDescription)
394+
.isEqualTo(view.context.getString(R.string.state_unchecked_description))
395+
}
396+
397+
@Test
398+
fun testOnInitializeAccessibilityNodeInfo_checkedCheckbox_setsStateDescription() {
399+
val info = initializeNodeInfoWithCheckedState(AccessibilityRole.CHECKBOX, checked = true)
400+
401+
assertThat(info.isCheckable).isTrue()
402+
assertThat(info.isChecked).isTrue()
403+
assertThat(info.stateDescription)
404+
.isEqualTo(view.context.getString(R.string.state_checked_description))
405+
}
406+
407+
@Test
408+
fun testOnInitializeAccessibilityNodeInfo_uncheckedRadio_setsStateDescription() {
409+
val info = initializeNodeInfoWithCheckedState(AccessibilityRole.RADIO, checked = false)
410+
411+
assertThat(info.stateDescription)
412+
.isEqualTo(view.context.getString(R.string.state_unchecked_description))
413+
}
414+
415+
@Test
416+
fun testOnInitializeAccessibilityNodeInfo_uncheckedWithoutRole_setsStateDescription() {
417+
val info = initializeNodeInfoWithCheckedState(role = null, checked = false)
418+
419+
assertThat(info.isCheckable).isTrue()
420+
assertThat(info.stateDescription)
421+
.isEqualTo(view.context.getString(R.string.state_unchecked_description))
422+
}
423+
424+
@Test
425+
fun testOnInitializeAccessibilityNodeInfo_switchOff_setsStateDescription() {
426+
val info = initializeNodeInfoWithCheckedState(AccessibilityRole.SWITCH, checked = false)
427+
428+
assertThat(info.isChecked).isFalse()
429+
assertThat(info.stateDescription)
430+
.isEqualTo(view.context.getString(R.string.state_off_description))
431+
}
432+
433+
@Test
434+
fun testOnInitializeAccessibilityNodeInfo_switchOn_setsStateDescription() {
435+
val info = initializeNodeInfoWithCheckedState(AccessibilityRole.SWITCH, checked = true)
436+
437+
assertThat(info.isChecked).isTrue()
438+
assertThat(info.stateDescription)
439+
.isEqualTo(view.context.getString(R.string.state_on_description))
440+
}
441+
442+
@Test
443+
fun testOnInitializeAccessibilityNodeInfo_roleProp_switchOff_setsStateDescription() {
444+
view.setTag(R.id.role, ReactAccessibilityDelegate.Role.SWITCH)
445+
view.setTag(R.id.accessibility_state, JavaOnlyMap().apply { putBoolean("checked", false) })
446+
val info = AccessibilityNodeInfoCompat.obtain()
447+
448+
accessibilityDelegate.onInitializeAccessibilityNodeInfo(view, info)
449+
450+
assertThat(info.stateDescription)
451+
.isEqualTo(view.context.getString(R.string.state_off_description))
452+
}
453+
454+
@Test
455+
fun testOnInitializeAccessibilityNodeInfo_noCheckedState_leavesStateDescriptionUnset() {
456+
view.setTag(R.id.accessibility_role, AccessibilityRole.CHECKBOX)
457+
view.setTag(R.id.accessibility_state, JavaOnlyMap().apply { putBoolean("selected", true) })
458+
val info = AccessibilityNodeInfoCompat.obtain()
459+
460+
accessibilityDelegate.onInitializeAccessibilityNodeInfo(view, info)
461+
462+
assertThat(info.isSelected).isTrue()
463+
assertThat(info.stateDescription).isNull()
464+
}
465+
466+
private fun initializeNodeInfoWithCheckedState(
467+
role: AccessibilityRole?,
468+
checked: Boolean,
469+
): AccessibilityNodeInfoCompat {
470+
if (role != null) {
471+
view.setTag(R.id.accessibility_role, role)
472+
}
473+
view.setTag(R.id.accessibility_state, JavaOnlyMap().apply { putBoolean("checked", checked) })
474+
val info = AccessibilityNodeInfoCompat.obtain()
475+
476+
accessibilityDelegate.onInitializeAccessibilityNodeInfo(view, info)
477+
478+
return info
479+
}
385480
}

0 commit comments

Comments
 (0)