Skip to content

Commit e9b2647

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

6 files changed

Lines changed: 493 additions & 33 deletions

File tree

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

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,78 @@ 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+
type AccessibilityCollectionItem = Readonly<{
205+
itemIndex: number,
206+
rowIndex: number,
207+
rowSpan: number,
208+
columnIndex: number,
209+
columnSpan: number,
210+
heading: boolean,
211+
...
212+
}>;
213+
214+
function createAccessibilityCollectionItem(
215+
itemIndex: number,
216+
rowIndex: number,
217+
columnIndex: number,
218+
horizontal: boolean,
219+
): AccessibilityCollectionItem {
220+
return {
221+
itemIndex,
222+
rowIndex: horizontal ? 0 : rowIndex,
223+
rowSpan: 1,
224+
columnIndex: horizontal ? itemIndex : columnIndex,
225+
columnSpan: 1,
226+
heading: false,
227+
};
228+
}
229+
230+
function addAccessibilityCollectionItem(
231+
element: React.Node,
232+
accessibilityCollectionItem: ?AccessibilityCollectionItem,
233+
): React.Node {
234+
if (
235+
accessibilityCollectionItem == null ||
236+
!React.isValidElement(element) ||
237+
element.type === React.Fragment
238+
) {
239+
return element;
240+
}
241+
242+
// $FlowFixMe[prop-missing] React.Element internal inspection.
243+
if (element.props.accessibilityCollectionItem !== undefined) {
244+
return element;
245+
}
246+
247+
return React.cloneElement(element, {
248+
accessibilityCollectionItem,
249+
});
250+
}
251+
180252
type FlatListBaseProps<ItemT> = {
181253
...RequiredFlatListProps<ItemT>,
182254
...OptionalFlatListProps<ItemT>,
@@ -634,29 +706,58 @@ class FlatList<ItemT = any> extends React.PureComponent<FlatListProps<ItemT>> {
634706
};
635707

636708
const renderProp = (info: ListRenderItemInfo<ItemT>) => {
709+
const isAndroid = Platform.OS === 'android';
710+
const isHorizontal = this.props.horizontal === true;
637711
if (cols > 1) {
638712
const {item, index} = info;
639713
invariant(
640714
Array.isArray(item),
641715
'Expected array of items with numColumns > 1',
642716
);
643717
return (
644-
<View style={StyleSheet.compose(styles.row, columnWrapperStyle)}>
718+
<View
719+
accessibilityCollectionItem={isAndroid ? null : undefined}
720+
style={StyleSheet.compose(styles.row, columnWrapperStyle)}>
645721
{item.map((it, kk) => {
722+
const itemIndex = index * cols + kk;
723+
const itemAccessibilityCollectionItem = isAndroid
724+
? createAccessibilityCollectionItem(
725+
itemIndex,
726+
index,
727+
kk,
728+
isHorizontal,
729+
)
730+
: undefined;
646731
const element = render({
647732
// $FlowFixMe[incompatible-type]
648733
item: it,
649-
index: index * cols + kk,
734+
index: itemIndex,
650735
separators: info.separators,
651736
});
652737
return element != null ? (
653-
<React.Fragment key={kk}>{element}</React.Fragment>
738+
<React.Fragment key={kk}>
739+
{addAccessibilityCollectionItem(
740+
element,
741+
itemAccessibilityCollectionItem,
742+
)}
743+
</React.Fragment>
654744
) : null;
655745
})}
656746
</View>
657747
);
658748
} else {
659-
return render(info);
749+
const itemAccessibilityCollectionItem = isAndroid
750+
? createAccessibilityCollectionItem(
751+
info.index,
752+
info.index,
753+
0,
754+
isHorizontal,
755+
)
756+
: undefined;
757+
return addAccessibilityCollectionItem(
758+
render(info),
759+
itemAccessibilityCollectionItem,
760+
);
660761
}
661762
};
662763

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

679780
const renderer = strictMode ? this._memoizedRenderer : this._renderer;
781+
const numColumnsValue = numColumnsOrDefault(numColumns);
782+
const androidAccessibilityProps =
783+
Platform.OS === 'android'
784+
? {
785+
accessibilityCollection:
786+
// $FlowFixMe[prop-missing] Internal native prop.
787+
this.props.accessibilityCollection ??
788+
createAccessibilityCollection(this.props.data, numColumnsValue),
789+
accessibilityRole:
790+
this.props.role == null && this.props.accessibilityRole == null
791+
? numColumnsValue > 1
792+
? 'grid'
793+
: 'list'
794+
: this.props.accessibilityRole,
795+
}
796+
: {};
680797

681798
return (
682799
// $FlowFixMe[incompatible-exact] - `restProps` (`Props`) is inexact.
683800
<VirtualizedList
684801
{...restProps}
802+
{...androidAccessibilityProps}
685803
getItem={this._getItem}
686804
getItemCount={this._getItemCount}
687805
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)