Skip to content

Commit 33fd813

Browse files
committed
Add Android accessibility collection support to FlatList
On Android, FlatList did not expose accessibility collection metadata, so screen readers (TalkBack) could not announce list/grid position. Changes: - Computes accessibilityCollection and sets accessibilityRole to list/grid - Attaches accessibilityCollectionItem to each rendered cell - Supports horizontal FlatList (numColumns + horizontal) - Preserves user-provided role/accessibilityRole/accessibilityCollection props [Android] [Added] - FlatList now exposes accessibility collection metadata Closes #30973
1 parent 102fde7 commit 33fd813

2 files changed

Lines changed: 261 additions & 5 deletions

File tree

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

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,88 @@ 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+
horizontal: boolean,
190+
): {
191+
itemCount: number,
192+
rowCount: number,
193+
columnCount: number,
194+
hierarchical: boolean,
195+
} {
196+
const itemCount = getItemCountForAccessibility(data);
197+
const totalCols = numColumns > 1 ? numColumns : 1;
198+
if (horizontal) {
199+
return {
200+
itemCount,
201+
rowCount: totalCols,
202+
columnCount: Math.ceil(itemCount / totalCols),
203+
hierarchical: false,
204+
};
205+
}
206+
return {
207+
itemCount,
208+
rowCount: Math.ceil(itemCount / totalCols),
209+
columnCount: totalCols,
210+
hierarchical: false,
211+
};
212+
}
213+
214+
type AccessibilityCollectionItem = Readonly<{
215+
itemIndex: number,
216+
rowIndex: number,
217+
rowSpan: number,
218+
columnIndex: number,
219+
columnSpan: number,
220+
heading: boolean,
221+
...
222+
}>;
223+
224+
function createAccessibilityCollectionItem(
225+
itemIndex: number,
226+
rowIndex: number,
227+
columnIndex: number,
228+
horizontal: boolean,
229+
): AccessibilityCollectionItem {
230+
return {
231+
itemIndex,
232+
rowIndex: horizontal ? 0 : rowIndex,
233+
rowSpan: 1,
234+
columnIndex: horizontal ? itemIndex : columnIndex,
235+
columnSpan: 1,
236+
heading: false,
237+
};
238+
}
239+
240+
function addAccessibilityCollectionItem(
241+
element: React.Node,
242+
accessibilityCollectionItem: ?AccessibilityCollectionItem,
243+
): React.Node {
244+
const elementForAccessibility: any = element;
245+
if (
246+
accessibilityCollectionItem == null ||
247+
!React.isValidElement(elementForAccessibility) ||
248+
elementForAccessibility.type === React.Fragment
249+
) {
250+
return element;
251+
}
252+
253+
if (elementForAccessibility.props.accessibilityCollectionItem !== undefined) {
254+
return element;
255+
}
256+
257+
return React.cloneElement(elementForAccessibility, {
258+
accessibilityCollectionItem,
259+
});
260+
}
261+
180262
type FlatListBaseProps<ItemT> = {
181263
...RequiredFlatListProps<ItemT>,
182264
...OptionalFlatListProps<ItemT>,
@@ -634,29 +716,61 @@ class FlatList<ItemT = any> extends React.PureComponent<FlatListProps<ItemT>> {
634716
};
635717

636718
const renderProp = (info: ListRenderItemInfo<ItemT>) => {
719+
const isAndroid = Platform.OS === 'android';
720+
const isHorizontal = this.props.horizontal === true;
637721
if (cols > 1) {
638722
const {item, index} = info;
639723
invariant(
640724
Array.isArray(item),
641725
'Expected array of items with numColumns > 1',
642726
);
727+
const rowAccessibilityProps: any = isAndroid
728+
? ({}: any)
729+
: {};
643730
return (
644-
<View style={StyleSheet.compose(styles.row, columnWrapperStyle)}>
731+
<View
732+
{...rowAccessibilityProps}
733+
style={StyleSheet.compose(styles.row, columnWrapperStyle)}>
645734
{item.map((it, kk) => {
735+
const itemIndex = index * cols + kk;
736+
const itemAccessibilityCollectionItem = isAndroid
737+
? createAccessibilityCollectionItem(
738+
itemIndex,
739+
index,
740+
kk,
741+
isHorizontal,
742+
)
743+
: undefined;
646744
const element = render({
647745
// $FlowFixMe[incompatible-type]
648746
item: it,
649-
index: index * cols + kk,
747+
index: itemIndex,
650748
separators: info.separators,
651749
});
652750
return element != null ? (
653-
<React.Fragment key={kk}>{element}</React.Fragment>
751+
<React.Fragment key={kk}>
752+
{addAccessibilityCollectionItem(
753+
element,
754+
itemAccessibilityCollectionItem,
755+
)}
756+
</React.Fragment>
654757
) : null;
655758
})}
656759
</View>
657760
);
658761
} else {
659-
return render(info);
762+
const itemAccessibilityCollectionItem = isAndroid
763+
? createAccessibilityCollectionItem(
764+
info.index,
765+
info.index,
766+
0,
767+
isHorizontal,
768+
)
769+
: undefined;
770+
return addAccessibilityCollectionItem(
771+
render(info),
772+
itemAccessibilityCollectionItem,
773+
);
660774
}
661775
};
662776

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

679793
const renderer = strictMode ? this._memoizedRenderer : this._renderer;
794+
const numColumnsValue = numColumnsOrDefault(numColumns);
795+
const androidAccessibilityProps =
796+
Platform.OS === 'android'
797+
? {
798+
accessibilityCollection:
799+
// $FlowFixMe[prop-missing] Internal native prop.
800+
this.props.accessibilityCollection ??
801+
createAccessibilityCollection(this.props.data, numColumnsValue, this.props.horizontal === true),
802+
accessibilityRole:
803+
this.props.role == null && this.props.accessibilityRole == null
804+
? numColumnsValue > 1
805+
? 'grid'
806+
: 'list'
807+
: this.props.accessibilityRole,
808+
}
809+
: {};
680810

681811
return (
682812
// $FlowFixMe[incompatible-exact] - `restProps` (`Props`) is inexact.
683813
<VirtualizedList
684814
{...restProps}
815+
{...androidAccessibilityProps}
685816
getItem={this._getItem}
686817
getItemCount={this._getItemCount}
687818
keyExtractor={this._keyExtractor}

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

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,38 @@ import * as Fantom from '@react-native/fantom';
1616
import nullthrows from 'nullthrows';
1717
import * as React from 'react';
1818
import {createRef} from 'react';
19-
import {FlatList, Text, View} from 'react-native';
19+
import {FlatList, Platform, Text, View} from 'react-native';
2020
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
2121

22+
// Object-valued props are serialized to a JSON string by the native Fantom
23+
// renderer, so we defensively parse them back into objects.
24+
// $FlowFixMe[unclear-type] parseCollectionProp returns a loosely-typed object.
25+
function parseCollectionProp(value: unknown): any {
26+
if (typeof value === 'string') {
27+
return JSON.parse(value);
28+
}
29+
return value;
30+
}
31+
32+
// $FlowFixMe[unclear-type] collectItems walks a dynamically-typed tree.
33+
function collectItems(node: unknown, acc: Array<any> = []): Array<any> {
34+
if (node == null || typeof node === 'string') {
35+
return acc;
36+
}
37+
// $FlowFixMe[unclear-type] Fantom nodes have a dynamic shape.
38+
const n: any = node;
39+
const item = n.props != null ? n.props.accessibilityCollectionItem : null;
40+
if (item != null) {
41+
acc.push(parseCollectionProp(item));
42+
}
43+
if (Array.isArray(n.children)) {
44+
n.children.forEach(child => {
45+
collectItems(child, acc);
46+
});
47+
}
48+
return acc;
49+
}
50+
2251
function testPropPropagatedToMountingLayer<TValue>({
2352
propName,
2453
value,
@@ -102,6 +131,102 @@ describe('<FlatList>', () => {
102131
});
103132
});
104133

134+
it('adds Android accessibility collection metadata to list items', () => {
135+
const originalPlatform = Platform.OS;
136+
// $FlowFixMe[incompatible-type] Platform.OS is read-only in production.
137+
Platform.OS = 'android';
138+
try {
139+
const root = Fantom.createRoot();
140+
Fantom.runTask(() => {
141+
root.render(
142+
<FlatList
143+
data={[{key: 'i1'}, {key: 'i2'}, {key: 'i3'}]}
144+
renderItem={({item}) => <Text>{item.key}</Text>}
145+
/>,
146+
);
147+
});
148+
149+
const list = root
150+
.getRenderedOutput({
151+
props: ['accessibilityRole', 'accessibilityCollection'],
152+
})
153+
.toJSONObject();
154+
expect(list.props.accessibilityRole).toBe('list');
155+
expect(parseCollectionProp(list.props.accessibilityCollection)).toEqual(
156+
{
157+
itemCount: 3,
158+
rowCount: 3,
159+
columnCount: 1,
160+
hierarchical: false,
161+
},
162+
);
163+
164+
const items = collectItems(
165+
root
166+
.getRenderedOutput({props: ['accessibilityCollectionItem']})
167+
.toJSONObject(),
168+
);
169+
expect(items.map(i => i.itemIndex).sort((a, b) => a - b)).toEqual([
170+
0, 1, 2,
171+
]);
172+
} finally {
173+
// $FlowFixMe[incompatible-type] Platform.OS is read-only in production.
174+
Platform.OS = originalPlatform;
175+
}
176+
});
177+
178+
it('adds Android accessibility collection metadata to multi-column list items', () => {
179+
const originalPlatform = Platform.OS;
180+
// $FlowFixMe[incompatible-type] Platform.OS is read-only in production.
181+
Platform.OS = 'android';
182+
try {
183+
const root = Fantom.createRoot();
184+
Fantom.runTask(() => {
185+
root.render(
186+
<FlatList
187+
data={[
188+
{key: 'i1'},
189+
{key: 'i2'},
190+
{key: 'i3'},
191+
{key: 'i4'},
192+
{key: 'i5'},
193+
]}
194+
renderItem={({item}) => <Text>{item.key}</Text>}
195+
numColumns={2}
196+
/>,
197+
);
198+
});
199+
200+
const list = root
201+
.getRenderedOutput({
202+
props: ['accessibilityRole', 'accessibilityCollection'],
203+
})
204+
.toJSONObject();
205+
expect(list.props.accessibilityRole).toBe('grid');
206+
expect(parseCollectionProp(list.props.accessibilityCollection)).toEqual(
207+
{
208+
itemCount: 5,
209+
rowCount: 3,
210+
columnCount: 2,
211+
hierarchical: false,
212+
},
213+
);
214+
215+
const items = collectItems(
216+
root
217+
.getRenderedOutput({props: ['accessibilityCollectionItem']})
218+
.toJSONObject(),
219+
);
220+
expect(items.map(i => i.itemIndex).sort((a, b) => a - b)).toEqual([
221+
0, 1, 2, 3, 4,
222+
]);
223+
expect(items.some(i => i.columnIndex === 1)).toBe(true);
224+
} finally {
225+
// $FlowFixMe[incompatible-type] Platform.OS is read-only in production.
226+
Platform.OS = originalPlatform;
227+
}
228+
});
229+
105230
describe('inverted', () => {
106231
it('changes prop isInvertedVirtualizedList which gets propagated to mounting layer', () => {
107232
const root = Fantom.createRoot();

0 commit comments

Comments
 (0)