前言
本文旨在记录作者在接手一个使用 React Native(RN)0.63.4 版本实现长列表功能时的思路与实践过程。
项目背景
RN 0.63.4 版本于 2020 年 11 月发布,如今已到 2025 年,面对如此陈旧的版本,我们无法期望使用最新的特性和第三方插件来实现功能。常用的插件集成到该项目中都极为困难,因此只能手动实现相关功能。
实现过程
下拉刷新
虽然 FlatList
提供了 refreshControl
属性用于处理下拉刷新,但在此项目中无法直接使用,因此我们通过 Animated
组件配合 PanResponder
自定义事件来实现下拉刷新功能。关键在于判断列表是否处于顶部,若不处理,滚动与触摸事件会产生冲突。以下是相关代码片段:
javascript
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (evt) => {
touchStartY.current = evt.nativeEvent.pageY;
isDragging.current = true;
},
onPanResponderMove: (evt, gestureState) => {
const currentY = evt.nativeEvent.pageY;
const distance = currentY - touchStartY.current;
if (distance > 0 && isAtTop.current) {
pullDistance.setValue(distance);
} else {
pullDistance.setValue(0);
}
},
onPanResponderRelease: () => {
isDragging.current = false;
const currentDistance = pullDistance._value;
if (currentDistance > 50) {
onRefresh();
}
Animated.spring(pullDistance, {
toValue: 0,
useNativeDriver: true,
tension: 40,
friction: 7
}).start();
}
})
).current;
滚动加载更多
借助 FlatList
组件自带的 onEndReached
属性实现滚动加载更多功能。关键在于处理加载中状态以及是否有更多数据等状态,以下是相关代码片段:
javascript
const loadMore = useCallback(() => {
if (isLoadingMore || !hasMore) return;
setIsLoadingMore(true);
setPage(prevPage => prevPage + 1);
}, [isLoadingMore, hasMore]);
const renderFooter = () => {
if (!isLoadingMore) return null;
return (
<View style={{ padding: 10 }}>
<ActivityIndicator size="small" color="#007AFF" />
<Text style={{ marginTop: 8, color: '#888', textAlign: "center" }}>加载中...</Text>
</View>
);
};
定时刷新
采用定时器实现定时刷新功能。需要注意的是,不能直接暴力替换数据,否则会导致页面闪动。关键在于对比新旧数据,若无变化,则使用旧数据,无需重新渲染。以下是相关代码片段:
javascript
const startAutoRefresh = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
timerRef.current = setInterval(() => {
const fetchLatestData = async () => {
try {
const response = await fetcher(
urlAppendQueryParams(`/gateway-api/mk/api/news/list`, {
time: moment().format('YYYY-MM-DD HH:mm:ss'),
pageNo: 1,
pageSize: "10",
type: "1",
flag: "2",
category: ""
}),
{
method: 'GET',
}
);
if (!response) return;
const recordCount = response?.records?.length || 0;
const firstRecordId = response?.records?.[0]?.id || '';
const lastRecordId = response?.records?.[recordCount - 1]?.id || '';
const firstRecordCommentCount = response?.records?.[0]?.commentCount || 0;
const firstRecordLikeCount = response?.records?.[0]?.likeCount || 0;
const dataSignature = `${recordCount}-${firstRecordId}-${lastRecordId}-${firstRecordCommentCount}-${firstRecordLikeCount}`;
if (dataSignature !== lastDataSignature.current) {
console.log("数据发生变化,更新UI");
lastDataSignature.current = dataSignature;
setIsSilentRefreshing(true);
setNow(moment().format('YYYY-MM-DD HH:mm:ss'));
} else {
console.log("数据未变化,不更新UI");
}
} catch (error) {
console.error("自动刷新时出错:", error);
}
};
fetchLatestData();
}, REFRESH_INTERVAL);
}, []);
完整代码
ini
import React,{useContext,useState,lazy,Suspense, useEffect,useRef,memo,useCallback,} from 'react';
import {
Text, TouchableOpacity,
View,FlatList,Image,
StyleSheet,Dimensions,
StatusBar,Platform,
ActivityIndicator,Animated,PanResponder,
RefreshControl
} from "react-native";
import {fetcher} from 'src/api/RestRequest';
import {urlAppendQueryParams} from 'src/utils/url';
import ListEmptyView from 'src/components/ListEmptyView';
import ListFooterView from 'src/components/ListFooterView';
import {useFetchHomeRecommandInfoAsyncQuery,useAddCommentQuery} from "../../api/InfoApi"//使用'@tanstack/react-query'定义接口
import moment from "moment";
const List=()=>{
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [translateY] = useState(new Animated.Value(0));
const [isDragging, setIsDragging] = useState(false);
const [isSilentRefreshing, setIsSilentRefreshing] = useState(false);
const [page, setPage] = useState(cachedData.page || 1);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(cachedData.hasMore);
const [allData, setAllData] = useState(cachedData.allData);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showRefreshTip, setShowRefreshTip] = useState(false);
const [now, setNow] = useState(moment().format('YYYY-MM-DD HH:mm:ss'));
const flatListRef = useRef(null);
const touchStartY = useRef(0);
const pullDistance = useRef(new Animated.Value(0)).current;
const isAtTop = useRef(true);
const infoAccessControl = getAppModuleAccessControl('INFO');
const timerRef = useRef(null);
const lastDataSignature = useRef('');
//相关方法
const { data, error, isLoading } = useFetchHomeRecommandInfoAsyncQuery({
time: now,
pageNo: page,
pageSize: '10',
type: '1',
flag: '2',
category: '',
});
useEffect(() => {
if (error) {
console.error('Error fetching data:', error);
setIsRefreshing(false);
setIsLoadingMore(false);
}
}, [error]);
useEffect(() => {
if (cachedData.allData.length > 0 && cachedData.lastFetchTime) {
const now = new Date().getTime();
const cacheTime = cachedData.lastFetchTime.getTime();
const cacheAgeMinutes = (now - cacheTime) / (1000 * 60);
if (cacheAgeMinutes < 5) {
setAllData(cachedData.allData);
setPage(cachedData.page);
setHasMore(cachedData.hasMore);
setTimeout(() => {
if (flatListRef.current && cachedData.scrollPosition > 0) {
flatListRef.current.scrollToOffset({ offset: cachedData.scrollPosition, animated: false });
}
}, 100);
return;
}
}
// 如果没有缓存或缓存过期,正常加载数据
}, [location]);
useEffect(() => {
if (data && data.records) {
if (isSilentRefreshing) {
setAllData(data.records);
cachedData.allData = data.records;
cachedData.lastFetchTime = new Date();
updateDataSignature(data.records);
setIsSilentRefreshing(false);
} else if (page === 1) {
setAllData(data.records);
cachedData.allData = data.records;
cachedData.page = 1;
cachedData.hasMore = true;
cachedData.lastFetchTime = new Date();
updateDataSignature(data.records);
setIsRefreshing(false);
} else {
if (data.records.length === 0) {
setHasMore(false);
cachedData.hasMore = false;
}
const newData = [...allData, ...data.records];
setAllData(newData);
cachedData.allData = newData;
cachedData.page = page;
cachedData.lastFetchTime = new Date();
setIsLoadingMore(false);
}
}
}, [data, page, isSilentRefreshing]);
const updateDataSignature = (records) => {
const recordCount = records.length || 0;
const firstRecordId = records[0]?.id || '';
const lastRecordId = records[recordCount - 1]?.id || '';
const firstRecordCommentCount = records[0]?.commentCount || 0;
const firstRecordLikeCount = records[0]?.likeCount || 0;
const dataSignature = `${recordCount}-${firstRecordId}-${lastRecordId}-${firstRecordCommentCount}-${firstRecordLikeCount}`;
lastDataSignature.current = dataSignature;
};
const loadMore = useCallback(() => {
if (isLoadingMore || !hasMore) return;
setIsLoadingMore(true);
setPage((prevPage) => prevPage + 1);
}, [isLoadingMore, hasMore]);
const startAutoRefresh = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
timerRef.current = setInterval(async () => {
const newTime = moment().format('YYYY-MM-DD HH:mm:ss');
const response = await fetcher(
urlAppendQueryParams(`/gateway-api/mk/api/news/list`, {
time: newTime,
pageNo: 1,
pageSize: '10',
type: '1',
flag: '2',
category: '',
}),
{ method: 'GET' }
);
if (!response) return;
const recordCount = response?.records?.length || 0;
const firstRecordId = response?.records?.[0]?.id || '';
const lastRecordId = response?.records?.[recordCount - 1]?.id || '';
const firstRecordCommentCount = response?.records?.[0]?.commentCount || 0;
const firstRecordLikeCount = response?.records?.[0]?.likeCount || 0;
const dataSignature = `${recordCount}-${firstRecordId}-${lastRecordId}-${firstRecordCommentCount}-${firstRecordLikeCount}`;
if (dataSignature !== lastDataSignature.current) {
console.log('数据发生变化,更新UI');
lastDataSignature.current = dataSignature;
setIsSilentRefreshing(true);
setNow(newTime);
} else {
console.log('数据未变化,不更新UI');
}
}, 30000);
}, []);
useEffect(() => {
startAutoRefresh();
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [startAutoRefresh]);
const onRefresh = useCallback(() => {
if (isRefreshing) return;
setIsRefreshing(true);
Animated.sequence([
Animated.timing(translateY, { toValue: 50, duration: 300, useNativeDriver: true }),
Animated.timing(translateY, { toValue: 0, duration: 300, useNativeDriver: true }),
]).start();
setNow(moment().format('YYYY-MM-DD HH:mm:ss'));
setPage(1);
setAllData([]);
setIsLoadingMore(false);
setTimeout(() => {
setShowRefreshTip(false);
setIsRefreshing(false);
}, 1000);
}, [isRefreshing]);
const handleScroll = useCallback((event) => {
const offsetY = event.nativeEvent.contentOffset.y;
isAtTop.current = offsetY <= 0;
cachedData.scrollPosition = offsetY;
}, []);
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (evt) => {
touchStartY.current = evt.nativeEvent.pageY;
setIsDragging(true);
},
onPanResponderMove: (evt) => {
const currentY = evt.nativeEvent.pageY;
const distance = currentY - touchStartY.current;
if (distance > 0 && isAtTop.current) {
pullDistance.setValue(distance);
} else {
pullDistance.setValue(0);
}
},
onPanResponderRelease: () => {
isDragging.current = false;
// 获取当前下拉距离
const currentDistance = pullDistance._value;
if (currentDistance > 50) {
// 触发刷新
onRefresh();
}
// 重置位置
Animated.spring(pullDistance, {
toValue: 0,
useNativeDriver: true,
tension: 40, // 弹簧张力
friction: 7 // 摩擦力
}).start();
}
})
).current;
const handleScroll = useCallback((event: any) => {
const offsetY = event.nativeEvent.contentOffset.y;
// 更新是否在顶部的状态
isAtTop.current = offsetY <= 0;
// 保存滚动位置到缓存
cachedData.scrollPosition = offsetY;
}, []);
//结构如下
return ( <View style={[styles.container, { paddingLeft: 16, paddingRight: 16 }]}>
<Animated.View
style={[
{
height: visibleHeight - 137,
transform: [{ translateY: translateY }]
}
]}
{...panResponder.panHandlers}
>
{showRefreshTip && (
<View style={styles.refreshTipContainer}>
<ActivityIndicator size="small" color="#268C45" />
<Text style={styles.refreshTipText}>下拉刷新</Text>
</View>
)}
<FlatList
ref={flatListRef1}
contentContainerStyle={{
paddingTop: 9,
paddingVertical: 0,
paddingHorizontal: 0,
flexGrow: 1,
}}
data={allData}
numColumns={1}
ListHeaderComponent={allData.length > 0 ? <HotView /> : null}
renderItem={({ item, index }) => (
<View key={index}>
<InformationItem69View
item={item}
index={index}
isLast={index === allData.length - 1}
/>
</View>
)}
onScroll={handleScroll}
keyExtractor={(item, index) => index.toString()}
onEndReached={loadMore}
onEndReachedThreshold={0.1}
scrollEventThrottle={16}
maxToRenderPerBatch={5}
ListEmptyComponent={() => (!isLoading && <ListEmptyView/>) || <ListFooterView />}
ListFooterComponent={renderFooter}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
bounces={true}
overScrollMode="always"
// 添加这些属性来优化触摸处理
removeClippedSubviews={true}
keyboardShouldPersistTaps="handled"
// 禁用默认的点击反馈
pressRetentionOffset={{ top: 0, left: 0, right: 0, bottom: 0 }}
/>
</Animated.View>
{/* </View> */}
</View>)
}
//css设置
const styles = StyleSheet.create({});
总结
通过上述实现,我们基于 FlatList
组件完成了长列表功能,包括下拉刷新、滚动加载更多以及定时无感刷新等功能。在项目实践中,我们充分考虑了性能优化和用户体验,确保了功能的稳定性和流畅性。