Skip to content

Commit aef472d

Browse files
committed
Fix Android accessibility collection metadata handling
1 parent 2ff3b81 commit aef472d

4 files changed

Lines changed: 179 additions & 6 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import android.widget.EditText
1919
import androidx.core.view.ViewCompat
2020
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
2121
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat
22+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat
2223
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat
2324
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat
2425
import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
@@ -103,6 +104,18 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac
103104
}
104105
val accessibilityActions = host.getTag(R.id.accessibility_actions) as ReadableArray?
105106

107+
val accessibilityCollection = host.getTag(R.id.accessibility_collection) as ReadableMap?
108+
if (accessibilityCollection != null) {
109+
val rowCount = accessibilityCollection.getInt("rowCount")
110+
val columnCount = accessibilityCollection.getInt("columnCount")
111+
val hierarchical =
112+
accessibilityCollection.hasKey("hierarchical") &&
113+
accessibilityCollection.getBoolean("hierarchical")
114+
115+
val collectionInfoCompat = CollectionInfoCompat.obtain(rowCount, columnCount, hierarchical)
116+
info.setCollectionInfo(collectionInfoCompat)
117+
}
118+
106119
val accessibilityCollectionItem =
107120
host.getTag(R.id.accessibility_collection_item) as ReadableMap?
108121
if (accessibilityCollectionItem != null) {
@@ -597,6 +610,7 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac
597610
view.getTag(R.id.accessibility_state) != null ||
598611
view.getTag(R.id.accessibility_actions) != null ||
599612
view.getTag(R.id.react_test_id) != null ||
613+
view.getTag(R.id.accessibility_collection) != null ||
600614
view.getTag(R.id.accessibility_collection_item) != null ||
601615
view.getTag(R.id.accessibility_links) != null ||
602616
view.getTag(R.id.role) != null)

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.kt

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa
5555
val accessibilityCollection =
5656
view.getTag(R.id.accessibility_collection) as? ReadableMap ?: return
5757

58-
event.itemCount = accessibilityCollection.getInt("itemCount")
58+
if (accessibilityCollection.hasKey("itemCount")) {
59+
event.itemCount = accessibilityCollection.getInt("itemCount")
60+
}
5961

6062
val contentView = (view as? ViewGroup)?.getChildAt(0) as? ViewGroup ?: return
6163

@@ -71,10 +73,10 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa
7173
return
7274
}
7375
var accessibilityCollectionItem: ReadableMap? =
74-
nextChild.getTag(R.id.accessibility_collection_item) as ReadableMap
76+
nextChild.getTag(R.id.accessibility_collection_item) as? ReadableMap
7577

7678
if (nextChild !is ViewGroup) {
77-
return
79+
continue
7880
}
7981

8082
// If this child's accessibilityCollectionItem is null, we'll check one more
@@ -93,10 +95,12 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa
9395
}
9496

9597
if (isVisible && accessibilityCollectionItem != null) {
96-
if (firstVisibleIndex == null) {
98+
if (accessibilityCollectionItem.hasKey("itemIndex") && firstVisibleIndex == null) {
9799
firstVisibleIndex = accessibilityCollectionItem.getInt("itemIndex")
98100
}
99-
lastVisibleIndex = accessibilityCollectionItem.getInt("itemIndex")
101+
if (accessibilityCollectionItem.hasKey("itemIndex")) {
102+
lastVisibleIndex = accessibilityCollectionItem.getInt("itemIndex")
103+
}
100104
}
101105

102106
if (firstVisibleIndex != null && lastVisibleIndex != null) {
@@ -121,7 +125,9 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa
121125
if (accessibilityCollection != null) {
122126
val rowCount = accessibilityCollection.getInt("rowCount")
123127
val columnCount = accessibilityCollection.getInt("columnCount")
124-
val hierarchical = accessibilityCollection.getBoolean("hierarchical")
128+
val hierarchical =
129+
accessibilityCollection.hasKey("hierarchical") &&
130+
accessibilityCollection.getBoolean("hierarchical")
125131

126132
val collectionInfoCompat =
127133
AccessibilityNodeInfoCompat.CollectionInfoCompat.obtain(

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
package com.facebook.react.uimanager
1111

1212
import android.os.Bundle
13+
import androidx.core.view.ViewCompat
1314
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
1415
import com.facebook.react.R
1516
import com.facebook.react.bridge.BridgeReactContext
@@ -123,6 +124,41 @@ class ReactAccessibilityDelegateTest {
123124
assertThat(result).isTrue()
124125
}
125126

127+
@Test
128+
fun testAccessibilityCollection_setsCollectionInfo() {
129+
view.setTag(
130+
R.id.accessibility_collection,
131+
JavaOnlyMap().apply {
132+
putInt("rowCount", 4)
133+
putInt("columnCount", 2)
134+
putBoolean("hierarchical", true)
135+
},
136+
)
137+
138+
val nodeInfo = AccessibilityNodeInfoCompat.obtain()
139+
accessibilityDelegate.onInitializeAccessibilityNodeInfo(view, nodeInfo)
140+
141+
assertThat(nodeInfo.collectionInfo).isNotNull()
142+
assertThat(nodeInfo.collectionInfo.rowCount).isEqualTo(4)
143+
assertThat(nodeInfo.collectionInfo.columnCount).isEqualTo(2)
144+
assertThat(nodeInfo.collectionInfo.isHierarchical).isTrue()
145+
}
146+
147+
@Test
148+
fun testSetDelegate_accessibilityCollection_installsAccessibilityDelegate() {
149+
view.setTag(
150+
R.id.accessibility_collection,
151+
JavaOnlyMap().apply {
152+
putInt("rowCount", 4)
153+
putInt("columnCount", 2)
154+
},
155+
)
156+
157+
ReactAccessibilityDelegate.setDelegate(view, false, 0)
158+
159+
assertThat(ViewCompat.hasAccessibilityDelegate(view)).isTrue()
160+
}
161+
126162
@Test
127163
fun testPerformAccessibilityAction_activateAction_dispatchesEvent() {
128164
val accessibilityActions = JavaOnlyArray()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.scroll
9+
10+
import android.content.Context
11+
import android.view.View
12+
import android.view.accessibility.AccessibilityEvent
13+
import android.widget.FrameLayout
14+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
15+
import com.facebook.react.R
16+
import com.facebook.react.bridge.JavaOnlyMap
17+
import org.assertj.core.api.Assertions.assertThat
18+
import org.junit.Before
19+
import org.junit.Test
20+
import org.junit.runner.RunWith
21+
import org.robolectric.RobolectricTestRunner
22+
import org.robolectric.RuntimeEnvironment
23+
24+
@RunWith(RobolectricTestRunner::class)
25+
class ReactScrollViewAccessibilityDelegateTest {
26+
private lateinit var context: Context
27+
private lateinit var scrollView: TestScrollView
28+
private lateinit var contentView: FrameLayout
29+
private lateinit var delegate: ReactScrollViewAccessibilityDelegate
30+
31+
@Before
32+
fun setUp() {
33+
context = RuntimeEnvironment.getApplication()
34+
scrollView = TestScrollView(context)
35+
contentView = FrameLayout(context)
36+
scrollView.addView(contentView)
37+
delegate = ReactScrollViewAccessibilityDelegate()
38+
}
39+
40+
@Test
41+
fun testOnInitializeAccessibilityEvent_allowsMissingOptionalCollectionFields() {
42+
scrollView.setTag(
43+
R.id.accessibility_collection,
44+
JavaOnlyMap().apply {
45+
putInt("rowCount", 3)
46+
putInt("columnCount", 1)
47+
},
48+
)
49+
contentView.addView(View(context))
50+
51+
val event = AccessibilityEvent.obtain()
52+
delegate.onInitializeAccessibilityEvent(scrollView, event)
53+
54+
assertThat(event.itemCount).isEqualTo(-1)
55+
assertThat(event.fromIndex).isEqualTo(-1)
56+
assertThat(event.toIndex).isEqualTo(-1)
57+
}
58+
59+
@Test
60+
fun testOnInitializeAccessibilityEvent_readsNestedCollectionItemIndex() {
61+
scrollView.setTag(
62+
R.id.accessibility_collection,
63+
JavaOnlyMap().apply {
64+
putInt("itemCount", 5)
65+
putInt("rowCount", 5)
66+
putInt("columnCount", 1)
67+
},
68+
)
69+
val wrapper = FrameLayout(context)
70+
val item = View(context)
71+
item.setTag(
72+
R.id.accessibility_collection_item,
73+
JavaOnlyMap().apply {
74+
putInt("itemIndex", 2)
75+
putInt("rowIndex", 2)
76+
putInt("rowSpan", 1)
77+
putInt("columnIndex", 0)
78+
putInt("columnSpan", 1)
79+
putBoolean("heading", false)
80+
},
81+
)
82+
wrapper.addView(item)
83+
contentView.addView(wrapper)
84+
85+
val event = AccessibilityEvent.obtain()
86+
delegate.onInitializeAccessibilityEvent(scrollView, event)
87+
88+
assertThat(event.itemCount).isEqualTo(5)
89+
assertThat(event.fromIndex).isEqualTo(2)
90+
assertThat(event.toIndex).isEqualTo(2)
91+
}
92+
93+
@Test
94+
fun testOnInitializeAccessibilityNodeInfo_defaultsMissingHierarchicalToFalse() {
95+
scrollView.setTag(
96+
R.id.accessibility_collection,
97+
JavaOnlyMap().apply {
98+
putInt("rowCount", 3)
99+
putInt("columnCount", 1)
100+
},
101+
)
102+
103+
val nodeInfo = AccessibilityNodeInfoCompat.obtain()
104+
delegate.onInitializeAccessibilityNodeInfo(scrollView, nodeInfo)
105+
106+
assertThat(nodeInfo.collectionInfo).isNotNull()
107+
assertThat(nodeInfo.collectionInfo.rowCount).isEqualTo(3)
108+
assertThat(nodeInfo.collectionInfo.columnCount).isEqualTo(1)
109+
assertThat(nodeInfo.collectionInfo.isHierarchical).isFalse()
110+
}
111+
112+
private class TestScrollView(context: Context) : FrameLayout(context), ReactAccessibleScrollView {
113+
override val scrollEnabled: Boolean = true
114+
115+
override fun isPartiallyScrolledInView(view: View): Boolean = true
116+
}
117+
}

0 commit comments

Comments
 (0)