Skip to content

Commit 967cdbf

Browse files
author
liumin01
committed
Fix Android FlatList accessibility collection positions
1 parent 9ac12ce commit 967cdbf

9 files changed

Lines changed: 466 additions & 41 deletions

File tree

packages/react-native/Libraries/Lists/FlatList.js

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,52 @@ function isArrayLike(data: unknown): boolean {
177177
return typeof Object(data).length === 'number';
178178
}
179179

180+
function getItemCountForAccessibility<ItemT>(
181+
data: ?Readonly<$ArrayLike<ItemT>>,
182+
): number {
183+
return data != null && isArrayLike(data) ? data.length : 0;
184+
}
185+
186+
function createAccessibilityCollection<ItemT>(
187+
data: ?Readonly<$ArrayLike<ItemT>>,
188+
numColumns: number,
189+
): {
190+
itemCount: number,
191+
rowCount: number,
192+
columnCount: number,
193+
hierarchical: boolean,
194+
} {
195+
const itemCount = getItemCountForAccessibility(data);
196+
return {
197+
itemCount,
198+
rowCount: numColumns > 1 ? Math.ceil(itemCount / numColumns) : itemCount,
199+
columnCount: numColumns,
200+
hierarchical: false,
201+
};
202+
}
203+
204+
function addAccessibilityCollectionItem(
205+
element: React.Node,
206+
accessibilityCollectionItem: ?$FlowFixMe,
207+
): React.Node {
208+
if (
209+
accessibilityCollectionItem == null ||
210+
!React.isValidElement(element) ||
211+
element.type === React.Fragment
212+
) {
213+
return element;
214+
}
215+
216+
// $FlowFixMe[prop-missing] React.Element internal inspection.
217+
if (element.props.accessibilityCollectionItem != null) {
218+
return element;
219+
}
220+
221+
return React.cloneElement(element, {
222+
accessibilityCollectionItem,
223+
});
224+
}
225+
180226
type FlatListBaseProps<ItemT> = {
181227
...RequiredFlatListProps<ItemT>,
182228
...OptionalFlatListProps<ItemT>,
@@ -635,28 +681,46 @@ class FlatList<ItemT = any> extends React.PureComponent<FlatListProps<ItemT>> {
635681

636682
const renderProp = (info: ListRenderItemInfo<ItemT>) => {
637683
if (cols > 1) {
638-
const {item, index} = info;
684+
const {accessibilityCollectionItem, item, index} = info;
639685
invariant(
640686
Array.isArray(item),
641687
'Expected array of items with numColumns > 1',
642688
);
643689
return (
644690
<View style={StyleSheet.compose(styles.row, columnWrapperStyle)}>
645691
{item.map((it, kk) => {
692+
const itemIndex = index * cols + kk;
693+
const itemAccessibilityCollectionItem =
694+
accessibilityCollectionItem == null
695+
? undefined
696+
: {
697+
...accessibilityCollectionItem,
698+
columnIndex: kk,
699+
itemIndex,
700+
};
646701
const element = render({
647702
// $FlowFixMe[incompatible-type]
648703
item: it,
649-
index: index * cols + kk,
704+
index: itemIndex,
650705
separators: info.separators,
706+
accessibilityCollectionItem: itemAccessibilityCollectionItem,
651707
});
652708
return element != null ? (
653-
<React.Fragment key={kk}>{element}</React.Fragment>
709+
<React.Fragment key={kk}>
710+
{addAccessibilityCollectionItem(
711+
element,
712+
itemAccessibilityCollectionItem,
713+
)}
714+
</React.Fragment>
654715
) : null;
655716
})}
656717
</View>
657718
);
658719
} else {
659-
return render(info);
720+
return addAccessibilityCollectionItem(
721+
render(info),
722+
info.accessibilityCollectionItem,
723+
);
660724
}
661725
};
662726

@@ -677,11 +741,28 @@ class FlatList<ItemT = any> extends React.PureComponent<FlatListProps<ItemT>> {
677741
} = this.props;
678742

679743
const renderer = strictMode ? this._memoizedRenderer : this._renderer;
744+
const numColumnsValue = numColumnsOrDefault(numColumns);
745+
const androidAccessibilityProps =
746+
Platform.OS === 'android'
747+
? {
748+
accessibilityCollection:
749+
// $FlowFixMe[prop-missing] Internal native prop.
750+
this.props.accessibilityCollection ??
751+
createAccessibilityCollection(this.props.data, numColumnsValue),
752+
accessibilityRole:
753+
this.props.role == null && this.props.accessibilityRole == null
754+
? numColumnsValue > 1
755+
? 'grid'
756+
: 'list'
757+
: this.props.accessibilityRole,
758+
}
759+
: {};
680760

681761
return (
682762
// $FlowFixMe[incompatible-exact] - `restProps` (`Props`) is inexact.
683763
<VirtualizedList
684764
{...restProps}
765+
{...androidAccessibilityProps}
685766
getItem={this._getItem}
686767
getItemCount={this._getItemCount}
687768
keyExtractor={this._keyExtractor}

packages/react-native/Libraries/Lists/__tests__/FlatList-test.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
'use strict';
1212

1313
const FlatList = require('../FlatList').default;
14+
const Platform = require('../../Utilities/Platform').default;
1415
const {create} = require('@react-native/jest-preset/jest/renderer');
1516
const React = require('react');
1617
const {createRef} = require('react');
@@ -35,6 +36,141 @@ describe('FlatList', () => {
3536
);
3637
expect(component).toMatchSnapshot();
3738
});
39+
it('adds Android accessibility collection metadata to list items', async () => {
40+
const originalOS = Platform.OS;
41+
// $FlowFixMe[incompatible-type] Platform.OS is read-only in production.
42+
Platform.OS = 'android';
43+
44+
try {
45+
const component = await create(
46+
<FlatList
47+
data={[{key: 'i1'}, {key: 'i2'}, {key: 'i3'}]}
48+
renderItem={({item}) => <item value={item.key} />}
49+
/>,
50+
);
51+
52+
const root = component.toJSON();
53+
expect(root?.props.accessibilityRole).toBe('list');
54+
expect(root?.props.accessibilityCollection).toEqual({
55+
itemCount: 3,
56+
rowCount: 3,
57+
columnCount: 1,
58+
hierarchical: false,
59+
});
60+
expect(
61+
component.root
62+
.findAllByType('item')
63+
.map(item => item.props.accessibilityCollectionItem),
64+
).toEqual([
65+
{
66+
itemIndex: 0,
67+
rowIndex: 0,
68+
rowSpan: 1,
69+
columnIndex: 0,
70+
columnSpan: 1,
71+
heading: false,
72+
},
73+
{
74+
itemIndex: 1,
75+
rowIndex: 1,
76+
rowSpan: 1,
77+
columnIndex: 0,
78+
columnSpan: 1,
79+
heading: false,
80+
},
81+
{
82+
itemIndex: 2,
83+
rowIndex: 2,
84+
rowSpan: 1,
85+
columnIndex: 0,
86+
columnSpan: 1,
87+
heading: false,
88+
},
89+
]);
90+
} finally {
91+
// $FlowFixMe[incompatible-type] Platform.OS is read-only in production.
92+
Platform.OS = originalOS;
93+
}
94+
});
95+
it('adds Android accessibility collection metadata to multi-column list items', async () => {
96+
const originalOS = Platform.OS;
97+
// $FlowFixMe[incompatible-type] Platform.OS is read-only in production.
98+
Platform.OS = 'android';
99+
100+
try {
101+
const component = await create(
102+
<FlatList
103+
data={[
104+
{key: 'i1'},
105+
{key: 'i2'},
106+
{key: 'i3'},
107+
{key: 'i4'},
108+
{key: 'i5'},
109+
]}
110+
renderItem={({item}) => <item value={item.key} />}
111+
numColumns={2}
112+
/>,
113+
);
114+
115+
const root = component.toJSON();
116+
expect(root?.props.accessibilityRole).toBe('grid');
117+
expect(root?.props.accessibilityCollection).toEqual({
118+
itemCount: 5,
119+
rowCount: 3,
120+
columnCount: 2,
121+
hierarchical: false,
122+
});
123+
expect(
124+
component.root
125+
.findAllByType('item')
126+
.map(item => item.props.accessibilityCollectionItem),
127+
).toEqual([
128+
{
129+
itemIndex: 0,
130+
rowIndex: 0,
131+
rowSpan: 1,
132+
columnIndex: 0,
133+
columnSpan: 1,
134+
heading: false,
135+
},
136+
{
137+
itemIndex: 1,
138+
rowIndex: 0,
139+
rowSpan: 1,
140+
columnIndex: 1,
141+
columnSpan: 1,
142+
heading: false,
143+
},
144+
{
145+
itemIndex: 2,
146+
rowIndex: 1,
147+
rowSpan: 1,
148+
columnIndex: 0,
149+
columnSpan: 1,
150+
heading: false,
151+
},
152+
{
153+
itemIndex: 3,
154+
rowIndex: 1,
155+
rowSpan: 1,
156+
columnIndex: 1,
157+
columnSpan: 1,
158+
heading: false,
159+
},
160+
{
161+
itemIndex: 4,
162+
rowIndex: 2,
163+
rowSpan: 1,
164+
columnIndex: 0,
165+
columnSpan: 1,
166+
heading: false,
167+
},
168+
]);
169+
} finally {
170+
// $FlowFixMe[incompatible-type] Platform.OS is read-only in production.
171+
Platform.OS = originalOS;
172+
}
173+
});
38174
it('renders simple list using ListItemComponent', async () => {
39175
function ListItemComponent({item}: Readonly<{item: {key: string}}>) {
40176
return <item value={item.key} />;

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

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -70,33 +70,13 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa
7070
} else {
7171
return
7272
}
73-
var accessibilityCollectionItem: ReadableMap? =
74-
nextChild.getTag(R.id.accessibility_collection_item) as ReadableMap
73+
val accessibilityCollectionItemRange = findAccessibilityCollectionItemRange(nextChild)
7574

76-
if (nextChild !is ViewGroup) {
77-
return
78-
}
79-
80-
// If this child's accessibilityCollectionItem is null, we'll check one more
81-
// nested child.
82-
// Happens when getItemLayout is not passed in FlatList which adds an additional
83-
// View in the hierarchy.
84-
if (nextChild.childCount > 0 && accessibilityCollectionItem == null) {
85-
val nestedNextChild = nextChild.getChildAt(0)
86-
if (nestedNextChild != null) {
87-
val nestedChildAccessibility =
88-
nestedNextChild.getTag(R.id.accessibility_collection_item) as? ReadableMap
89-
if (nestedChildAccessibility != null) {
90-
accessibilityCollectionItem = nestedChildAccessibility
91-
}
92-
}
93-
}
94-
95-
if (isVisible && accessibilityCollectionItem != null) {
75+
if (isVisible && accessibilityCollectionItemRange != null) {
9676
if (firstVisibleIndex == null) {
97-
firstVisibleIndex = accessibilityCollectionItem.getInt("itemIndex")
77+
firstVisibleIndex = accessibilityCollectionItemRange.first
9878
}
99-
lastVisibleIndex = accessibilityCollectionItem.getInt("itemIndex")
79+
lastVisibleIndex = accessibilityCollectionItemRange.second
10080
}
10181

10282
if (firstVisibleIndex != null && lastVisibleIndex != null) {
@@ -106,6 +86,36 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa
10686
}
10787
}
10888

89+
private fun findAccessibilityCollectionItemRange(view: View): Pair<Int, Int>? {
90+
val accessibilityCollectionItem =
91+
view.getTag(R.id.accessibility_collection_item) as? ReadableMap
92+
if (accessibilityCollectionItem != null) {
93+
val itemIndex = accessibilityCollectionItem.getInt("itemIndex")
94+
return Pair(itemIndex, itemIndex)
95+
}
96+
97+
if (view !is ViewGroup) {
98+
return null
99+
}
100+
101+
var firstItemIndex: Int? = null
102+
var lastItemIndex: Int? = null
103+
for (index in 0..<view.childCount) {
104+
val childItemRange =
105+
findAccessibilityCollectionItemRange(view.getChildAt(index)) ?: continue
106+
if (firstItemIndex == null) {
107+
firstItemIndex = childItemRange.first
108+
}
109+
lastItemIndex = childItemRange.second
110+
}
111+
112+
return if (firstItemIndex != null && lastItemIndex != null) {
113+
Pair(firstItemIndex, lastItemIndex)
114+
} else {
115+
null
116+
}
117+
}
118+
109119
private fun onInitializeAccessibilityNodeInfoInternal(
110120
view: View,
111121
info: AccessibilityNodeInfoCompat,

0 commit comments

Comments
 (0)