* Copyright (c) 2025 Huawei Technologies Co., Ltd.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {useCallback, useRef, useState} from 'react';
import {
View,
FlatList,
StyleSheet,
Text,
FlatListProps,
TouchableOpacity,
ViewToken,
ScrollViewComponent,
ScrollResponderMixin,
ViewabilityConfig,
} from 'react-native';
import {TestSuite} from '@rnoh/testerino';
import {
Button,
MockedVideoPlayer,
ObjectDisplayer,
TestCase,
} from '../components';
interface ItemData {
title: string;
id: string;
}
const DATA: ItemData[] = [
{
id: 'gd5jc6gnbb2sbrz9w8z2',
title: 'First Item',
},
{
id: 'jb95igwbswt13etu073o',
title: 'Second Item',
},
{
id: 'zcp3zsdkkjmc7cx66hjl',
title: 'Third Item',
},
{
id: 'fx72rfguehrydmd4n21l',
title: 'Fourth Item',
},
{
id: '8kadvdlhtr7m3yv3fp4v',
title: 'Fifth Item',
},
];
type ItemProps = {title: string};
const Item = ({title}: ItemProps) => (
<View style={styles.item}>
<Text style={styles.title}>{title}</Text>
</View>
);
const commonProps = {
style: {
height: 120,
},
data: DATA,
nestedScrollEnabled: true,
renderItem: ({item}) => <Item title={item.title} />,
keyExtractor: item => item.id,
} satisfies FlatListProps<any>;
export const FlatListTest = () => {
return (
<TestSuite name="FlatList">
<TestCase.Example itShould="display items in the FlatList (data, renderItem)">
<FlatList {...commonProps} />
</TestCase.Example>
<TestCase.Example itShould="display items with separator between them in the FlatList">
<FlatList
{...commonProps}
ItemSeparatorComponent={() => (
<View
style={{
height: 2,
alignSelf: 'center',
width: '90%',
backgroundColor: 'black',
}}
/>
)}
/>
</TestCase.Example>
<TestCase.Example modal itShould="render only the first two items">
<InitialNumToRenderTestCase />
</TestCase.Example>
<TestCase.Example
modal
itShould="display an array of fully visible items">
<ObjectDisplayer
renderContent={setObject => {
return <ViewabilityConfigTest setObject={setObject} />;
}}
/>
</TestCase.Example>
<TestCase.Example
modal
itShould="turn the items red on press (extraData)">
<ExtraDataTestCase />
</TestCase.Example>
<TestCase.Example
modal
itShould="the left list should render the added items one by one, while the right list should render almost all at once (maxToRenderPerBatch)">
<MaxToRenderPerBatchTestCase />
</TestCase.Example>
<TestCase.Example itShould="display empty list with a text saying that the list is empty ">
<View style={{height: 40}}>
<FlatList
data={[]}
nestedScrollEnabled
renderItem={() => null}
ListEmptyComponent={
<Text style={{textAlign: 'center'}}>This list is empty</Text>
}
/>
</View>
</TestCase.Example>
<TestCase.Example
skip={{android: false, harmony: {arkTs: true, cAPI: true}}}
itShould="scroll to the third item at the middle (scrollToIndex)">
<ScrollToIndexTestCase />
</TestCase.Example>
<TestCase.Example itShould="scroll to the third item at the middle (scrollToOffset)">
<ScrollToOffsetTestCase />
</TestCase.Example>
<TestCase.Example
modal
itShould="scroll to the third item (scrollToItem)">
<ScrollToItemTestCase />
</TestCase.Example>
<TestCase.Example
modal
skip={{android: true, harmony: {arkTs: false, cAPI: false}}}
itShould="support sticky headers (fails on Android with enabled Fabric)">
<View style={{height: 100, backgroundColor: '#fff'}}>
<FlatList
data={DATA}
renderItem={({item}) => (
<View
style={{
padding: 20,
borderBottomWidth: 1,
borderBottomColor: '#ccc',
}}>
<Text style={{fontSize: 16}}>{item.title}</Text>
</View>
)}
keyExtractor={item => item.id}
ListHeaderComponent={() => (
<View
style={{
backgroundColor: '#f0f0f0',
padding: 20,
justifyContent: 'center',
alignItems: 'center',
}}>
<Text style={{fontSize: 18, fontWeight: 'bold'}}>
Sticky Header
</Text>
</View>
)}
stickyHeaderIndices={[0]} // Make the header sticky
/>
</View>
</TestCase.Example>
<TestCase.Manual
modal
itShould="get the node number - getScrollableNode()"
initialState={undefined}
arrange={({state, setState}) => {
return (
<FlatListGetScrollableNode state={state} setState={setState} />
);
}}
assert={({state, expect}) => {
expect(state).to.be.an('number');
}}
/>
<TestCase.Manual
modal
itShould="get the nativeScrollRef - getNativeScrollRef()"
initialState={undefined}
arrange={({state, setState}) => {
return (
<FlatListGetNativeScrollRef state={state} setState={setState} />
);
}}
assert={({state, expect}) => {
expect(state).to.be.not.undefined;
}}
/>
<TestCase.Manual
modal
itShould="get the scroll responder - getScrollResponder()"
initialState={undefined}
arrange={({state, setState}) => {
return (
<FlatListGetScrollResponder state={state} setState={setState} />
);
}}
assert={({state, expect}) => {
expect(state).to.be.not.undefined;
}}
/>
<TestCase.Example
modal
itShould="change background color of visible items after scrolling slightly (to blue when fully visible and lightblue when at least 20% is visible)">
<FlatListTestViewabiliyConfigCallbackPairs />
</TestCase.Example>
<TestCase.Example
modal
skip={{
android: 'fails on Android with enabled Fabric',
harmony: {arkTs: false, cAPI: false},
}}
itShould="stick first item to the bottom (invertStickyHeaders)">
<View style={{height: 100, backgroundColor: '#fff'}}>
<FlatList
data={DATA}
renderItem={({item}) => (
<View
style={{
padding: 20,
borderBottomWidth: 1,
borderBottomColor: '#ccc',
}}>
<Text style={{fontSize: 16}}>{item.title}</Text>
</View>
)}
keyExtractor={item => item.id}
ListHeaderComponent={() => (
<View
style={{
backgroundColor: '#f0f0f0',
padding: 20,
justifyContent: 'center',
alignItems: 'center',
}}>
<Text style={{fontSize: 18, fontWeight: 'bold'}}>Header</Text>
</View>
)}
stickyHeaderIndices={[1]}
invertStickyHeaders
/>
</View>
</TestCase.Example>
</TestSuite>
);
};
function InitialNumToRenderTestCase() {
return (
<View
style={{
height: 120,
}}>
<FlatList
style={{
height: 120,
}}
data={DATA}
nestedScrollEnabled
renderItem={({item}) => {
return <Item title={item.title} />;
}}
keyExtractor={item => item.id}
initialNumToRender={2}
windowSize={1}
/>
</View>
);
}
function ViewabilityConfigTest({
setObject,
}: {
setObject: (obj: Object) => void;
}) {
const viewabilityConfig = {viewAreaCoveragePercentThreshold: 100};
const onViewableItemsChanged = useRef(
(item: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => {
setObject(item.viewableItems.map(i => i.item));
},
);
return (
<View style={{height: 300}}>
<FlatList
{...commonProps}
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged.current}
/>
</View>
);
}
function MaxToRenderPerBatchTestCase() {
const [data, setData] = useState<string[]>([]);
const renderItem = ({item}: {item: string; index: number}) => {
return <Text style={{height: 20}}>{item}</Text>;
};
return (
<View style={{height: 500}}>
<View style={{flexDirection: 'row'}}>
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={(item, index) => index.toString()}
maxToRenderPerBatch={1}
/>
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={(item, index) => index.toString()}
maxToRenderPerBatch={60}
/>
</View>
<Button
label="Add 60 items"
onPress={() => {
setData(prevData => [
...prevData,
...Array.from({length: 60}, (_, i) => `New item ${i + 1}`),
]);
}}
/>
</View>
);
}
interface SelectableListItemProps {
id: string;
onPressItem: (id: string) => void;
selected: boolean;
title: string;
}
class SelectableListItem extends React.PureComponent<SelectableListItemProps> {
_onPress = () => {
this.props.onPressItem(this.props.id);
};
render() {
const textColor = this.props.selected ? 'red' : 'black';
return (
<TouchableOpacity onPress={this._onPress}>
<View>
<Text style={{color: textColor}}>{this.props.title}</Text>
</View>
</TouchableOpacity>
);
}
}
interface MultiSelectListState {
selected: Map<string, boolean>;
}
class ExtraDataTestCase extends React.PureComponent<{}, MultiSelectListState> {
state: MultiSelectListState = {
selected: new Map<string, boolean>(),
};
_keyExtractor = (item: ItemData, _index: number) => item.id;
_onPressItem = (id: string) => {
this.setState(state => {
const selected = new Map(state.selected);
selected.set(id, !selected.get(id));
return {selected};
});
};
_renderItem = ({item}: {item: ItemData}) => (
<SelectableListItem
id={item.id}
onPressItem={this._onPressItem}
selected={!!this.state.selected.get(item.id)}
title={item.title}
/>
);
render() {
return (
<View style={{height: 200}}>
<FlatList
data={DATA}
extraData={this.state}
keyExtractor={this._keyExtractor}
renderItem={this._renderItem}
/>
</View>
);
}
}
function ScrollToIndexTestCase() {
const flatlistRef = useRef<FlatList>(null);
const [error, setError] = useState('');
return (
<>
<Button
label={'Scroll to the 3rd item at the middle'}
onPress={() => {
flatlistRef.current?.scrollToIndex({
animated: true,
index: 2,
viewPosition: 0.5,
});
}}
/>
<FlatList
{...commonProps}
ref={flatlistRef}
onScrollToIndexFailed={info => {
setError('Scroll to index failed ' + JSON.stringify(info));
}}
/>
<Text>{error}</Text>
</>
);
}
function ScrollToOffsetTestCase() {
const flatlistRef = useRef<FlatList>(null);
return (
<>
<Button
label={'Scroll to the 3rd item at top'}
onPress={() => {
flatlistRef.current?.scrollToOffset({
animated: true,
offset: 200,
});
}}
/>
<FlatList {...commonProps} ref={flatlistRef} />
</>
);
}
function ScrollToItemTestCase() {
const flatlistRef = useRef<FlatList>(null);
return (
<>
<Button
label={'Scroll to the 3rd item'}
onPress={() => {
flatlistRef.current?.scrollToItem({
animated: true,
item: DATA[2],
});
}}
/>
<FlatList {...commonProps} ref={flatlistRef} />
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
height: 120,
},
item: {
backgroundColor: '#f9c2ff',
padding: 20,
marginVertical: 8,
marginHorizontal: 16,
},
title: {
fontSize: 32,
height: 40,
width: '100%',
},
});
function FlatListGetScrollableNode({
state,
setState,
}: {
state: number | undefined;
setState: (state: any) => void;
}) {
const flatlistRef = useRef<FlatList>(null);
const getScrollableNode = useCallback(() => {
const node = flatlistRef.current?.getScrollableNode();
setState(node);
}, []);
return (
<>
<Button label={'Get Scrollable Node'} onPress={getScrollableNode} />
<FlatList {...commonProps} ref={flatlistRef} />
<Text>{state}</Text>
</>
);
}
type NativeScrollRef = React.ElementRef<typeof ScrollViewComponent>;
function FlatListGetNativeScrollRef({
setState,
}: {
state: NativeScrollRef | undefined;
setState: (state: any) => void;
}) {
const flatlistRef = useRef<FlatList>(null);
const getNativeScrollRef = useCallback(() => {
const nativeScrollRef = flatlistRef.current?.getNativeScrollRef();
setState(nativeScrollRef);
}, []);
return (
<>
<Button label={'Get nativeScrollRef'} onPress={getNativeScrollRef} />
<FlatList {...commonProps} ref={flatlistRef} />
</>
);
}
function FlatListGetScrollResponder({
setState,
}: {
state: ScrollResponderMixin | undefined;
setState: (state: any) => void;
}) {
const flatlistRef = useRef<FlatList>(null);
const getScrollResponder = useCallback(() => {
const scrollResponder = flatlistRef.current?.getScrollResponder();
setState(scrollResponder);
}, []);
return (
<>
<Button label={'Get scrollResponder'} onPress={getScrollResponder} />
<FlatList {...commonProps} ref={flatlistRef} />
</>
);
}
const firstViewabilityConfig: ViewabilityConfig = {
waitForInteraction: true,
minimumViewTime: 100,
itemVisiblePercentThreshold: 100,
};
const secondViewabilityConfig: ViewabilityConfig = {
waitForInteraction: true,
minimumViewTime: 100,
itemVisiblePercentThreshold: 20,
};
const CUSTOM_DATA: ItemData[] = Array(50)
.fill(0)
.map((_, index) => ({
id: index.toString(),
title: `Item ${index + 1}`,
}));
function FlatListTestViewabiliyConfigCallbackPairs() {
const [firstVisibleItems, setFirstVisibleItems] = useState<string[]>([]);
const [secondVisibleItems, setSecondVisibleItems] = useState<string[]>([]);
const viewabilityConfigCallbackPairsRef = useRef([
{
viewabilityConfig: firstViewabilityConfig,
onViewableItemsChanged: ({
viewableItems,
}: OnViewableItemsChangedType<ItemData>) => {
const newFirstVisibleItems = viewableItems.map(
viewableItem => viewableItem.item.id,
);
setFirstVisibleItems(newFirstVisibleItems);
},
},
{
viewabilityConfig: secondViewabilityConfig,
onViewableItemsChanged: ({
viewableItems,
}: OnViewableItemsChangedType<ItemData>) => {
const newSecondVisibleItems = viewableItems.map(
viewableItem => viewableItem.item.id,
);
setSecondVisibleItems(newSecondVisibleItems);
},
},
]);
return (
<View style={{height: 600}}>
<View style={{marginBottom: 10}}>
<Text>
First threshold {firstViewabilityConfig.itemVisiblePercentThreshold}%{' '}
visible items are: {JSON.stringify(firstVisibleItems)}
</Text>
<Text>
second threshold {secondViewabilityConfig.itemVisiblePercentThreshold}
% visible items are: {JSON.stringify(secondVisibleItems)}
</Text>
</View>
<FlatList
data={CUSTOM_DATA}
nestedScrollEnabled={true}
renderItem={({item}: {item: ItemData}) => (
<MockedVideoPlayer
height={300}
itemId={item.id}
playMockVideo={firstVisibleItems.includes(item.id)}
prefetchMockVideo={secondVisibleItems.includes(item.id)}
/>
)}
keyExtractor={(item: ItemData) => item.id}
viewabilityConfigCallbackPairs={
viewabilityConfigCallbackPairsRef.current
}
/>
</View>
);
}
interface ViewItemToken<TItem> {
item: TItem;
key: string;
index: number | null;
isViewable: boolean;
section?: any | undefined;
}
type OnViewableItemsChangedType<TItem> = {
viewableItems: Array<ViewItemToken<TItem>>;
changed: Array<ViewItemToken<TItem>>;
};
export default FlatListTest;