Skip to content

Commit 6dba326

Browse files
krsnaSurajclaude
andcommitted
Add Android accessibility collection support to FlatList
On Android, FlatList did not expose collection metadata, so TalkBack could not announce list/grid position (issue #30973). The native stack (BaseViewManager -> R.id.accessibility_collection and ReactScrollViewAccessibilityDelegate) already reads these tags, so only the JS propagation was missing. This change: - Computes accessibilityCollection (itemCount/rowCount/columnCount) and sets accessibilityRole list/grid on the underlying ScrollView on Android. - Attaches accessibilityCollectionItem (correct row/column indices, including multi-column grids) to each rendered cell. - Preserves user-provided role, accessibilityRole and accessibilityCollection. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 102fde7 commit 6dba326

2 files changed

Lines changed: 249 additions & 5 deletions

File tree

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

Lines changed: 125 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+
const elementForAccessibility: any = element;
235+
if (
236+
accessibilityCollectionItem == null ||
237+
!React.isValidElement(elementForAccessibility) ||
238+
elementForAccessibility.type === React.Fragment
239+
) {
240+
return element;
241+
}
242+
243+
if (elementForAccessibility.props.accessibilityCollectionItem !== undefined) {
244+
return element;
245+
}
246+
247+
return React.cloneElement(elementForAccessibility, {
248+
accessibilityCollectionItem,
249+
});
250+
}
251+
180252
type FlatListBaseProps<ItemT> = {
181253
...RequiredFlatListProps<ItemT>,
182254
...OptionalFlatListProps<ItemT>,
@@ -634,29 +706,61 @@ 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
);
717+
const rowAccessibilityProps: any = isAndroid
718+
? {accessibilityCollectionItem: null}
719+
: {};
643720
return (
644-
<View style={StyleSheet.compose(styles.row, columnWrapperStyle)}>
721+
<View
722+
{...rowAccessibilityProps}
723+
style={StyleSheet.compose(styles.row, columnWrapperStyle)}>
645724
{item.map((it, kk) => {
725+
const itemIndex = index * cols + kk;
726+
const itemAccessibilityCollectionItem = isAndroid
727+
? createAccessibilityCollectionItem(
728+
itemIndex,
729+
index,
730+
kk,
731+
isHorizontal,
732+
)
733+
: undefined;
646734
const element = render({
647735
// $FlowFixMe[incompatible-type]
648736
item: it,
649-
index: index * cols + kk,
737+
index: itemIndex,
650738
separators: info.separators,
651739
});
652740
return element != null ? (
653-
<React.Fragment key={kk}>{element}</React.Fragment>
741+
<React.Fragment key={kk}>
742+
{addAccessibilityCollectionItem(
743+
element,
744+
itemAccessibilityCollectionItem,
745+
)}
746+
</React.Fragment>
654747
) : null;
655748
})}
656749
</View>
657750
);
658751
} else {
659-
return render(info);
752+
const itemAccessibilityCollectionItem = isAndroid
753+
? createAccessibilityCollectionItem(
754+
info.index,
755+
info.index,
756+
0,
757+
isHorizontal,
758+
)
759+
: undefined;
760+
return addAccessibilityCollectionItem(
761+
render(info),
762+
itemAccessibilityCollectionItem,
763+
);
660764
}
661765
};
662766

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

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

681801
return (
682802
// $FlowFixMe[incompatible-exact] - `restProps` (`Props`) is inexact.
683803
<VirtualizedList
684804
{...restProps}
805+
{...androidAccessibilityProps}
685806
getItem={this._getItem}
686807
getItemCount={this._getItemCount}
687808
keyExtractor={this._keyExtractor}

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

Lines changed: 124 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,100 @@ describe('<FlatList>', () => {
102131
});
103132
});
104133

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

0 commit comments

Comments
 (0)