使用 React Native 中的 FlatList 实现下拉刷新、滚动加载更多及定时刷新的长列表

前言

本文旨在记录作者在接手一个使用 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 组件完成了长列表功能,包括下拉刷新、滚动加载更多以及定时无感刷新等功能。在项目实践中,我们充分考虑了性能优化和用户体验,确保了功能的稳定性和流畅性。

相关推荐
霸王蟹12 小时前
从前端工程化角度解析 Vite 打包策略:为何选择 Rollup 而非 esbuild。
前端·笔记·学习·react.js·vue·rollup·vite
EndingCoder12 小时前
React从基础入门到高级实战:React 生态与工具 - 构建与部署
前端·javascript·react.js·前端框架·ecmascript
市民中心的蟋蟀13 小时前
第十章 案例 4 - React Tracked 【上】
前端·javascript·react.js
工呈士13 小时前
React Hooks 与异步数据管理
前端·react.js·面试
NoneCoder16 小时前
React 路由管理与动态路由配置实战
前端·react.js·面试·前端框架
海盐泡泡龟16 小时前
React和原生事件的区别
前端·react.js·前端框架
飘尘16 小时前
用GSAP实现一个有趣的加载页!
前端·javascript·react.js
zwjapple1 天前
react-native的token认证流程
react native·状态模式·token
TE-茶叶蛋1 天前
React-props
前端·javascript·react.js
安分小尧1 天前
[特殊字符] 超强 Web React版 PDF 阅读器!支持分页、缩放、旋转、全屏、懒加载、缩略图!
前端·javascript·react.js