React Native 列表分页实战:下拉刷新与上拉加载的工程化方案

31 阅读17分钟

本文基于一个 React Native 0.77(新架构)+ RNOH 鸿蒙适配的真实项目,介绍如何用自定义 Hook 统一管理 22 个页面的分页列表逻辑。

一、问题背景

在移动端应用中,"下拉刷新 + 上拉加载更多"是最常见的交互模式。React Native 提供了 FlatList + RefreshControl 的原生能力,但如果每个页面都自己写分页逻辑,就会出现大量重复:页码管理、loading 状态切换、并发请求防护、错误处理、底部指示器……每个页面都要来一遍。

本文的目标是:封装一套通用的分页方案,让新页面只需要关心"用哪个 API"和"怎么渲染每一行"。


二、整体思路

2.1 为什么不用 Redux / 全局状态管理?

在动手之前,先回答一个根本问题:分页数据放哪里?

方案 A:Redux 全局 store

把每页的列表数据、页码、hasMore 都放进 Redux。问题是:

  • 22 个页面,每个页面的分页状态都要写一套 action / reducer / selector,样板代码量巨大
  • 用户浏览了 10 个列表页面后,Redux store 里会积累大量历史数据,即使用户已经离开了那些页面
  • 分页数据本质上是"页面私有的",没有跨页面共享的需求,放全局 store 是过度设计

方案 B:页面内 useState

每个页面自己管理 const [page, setPage] = useState(1)const [data, setData] = useState([])。问题是:

  • 每个页面都要重复写:请求逻辑、loading 切换、hasMore 判断、并发防护……
  • 容易遗漏边界处理(比如忘记加锁导致重复请求)

方案 C:自定义 Hook 集中管理(最终选择)

用一个 usePaginatedList Hook 把所有分页状态和逻辑封装起来,页面只需要:

  1. 定义一个 fetcher(告诉 Hook 用哪个 API)
  2. 从 Hook 拿到 datarefreshloadMore 等状态和方法
  3. 负责 UI 渲染
const fetcher = useCallback(({ pageNumber, pageSize }) => apiService({ pageNumber, pageSize }), []);
const { data, refreshing, loadingMore, hasMore, refresh, loadMore } = usePaginatedList({ fetcher });

<FlatList
  data={data}
  refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}
  onEndReached={loadMore}
  ListFooterComponent={<ListFooter loadingMore={loadingMore} hasMore={hasMore} />}
/>

这样既避免了 Redux 的样板代码和内存问题,又避免了每个页面重复造轮子。

2.2 为什么要拆成三个 Hook?

如果把所有逻辑塞进一个 Hook,它会变得非常臃肿(分页 + 筛选 + 滚动监听 + ……)。拆开之后,每个 Hook 职责单一,可以独立使用:

  • usePaginatedList(核心):管理 data、pageNum、hasMore、refreshing、loadingMore、error,对外暴露 refresh()loadMore()
  • useFilters(可选):管理筛选条件,配合 useRef 避免闭包陷阱
  • useScrollToTopFab(可选):监听滚动偏移,控制"回到顶部"按钮的显隐

不需要筛选条件的页面(比如食品许可证列表)只用 usePaginatedList;需要筛选的页面(比如待办事项列表)再加上 useFilters;不需要回到顶部按钮的页面可以不用 useScrollToTopFab

2.3 usePaginatedList 的核心设计

状态设计

一个分页列表需要管理的状态:

const [data, setData] = useState<T[]>([]);           // 列表数据
const [pageNum, setPageNum] = useState(1);            // 当前页码
const [hasMore, setHasMore] = useState(true);         // 是否还有更多数据
const [refreshing, setRefreshing] = useState(false);  // 下拉刷新 / 首次加载
const [loadingMore, setLoadingMore] = useState(false);// 上拉加载更多
const [total, setTotal] = useState(0);                // 服务端返回的总条数
const [error, setError] = useState<PaginatedError<T> | null>(null);

为什么把 refreshingloadingMore 分开?因为它们对应的 UI 不同:

  • refreshing 驱动的是 RefreshControl 的顶部下拉菊花
  • loadingMore 驱动的是列表底部的"加载中..."指示器

如果只用一个 loading 状态,下拉刷新时底部也会显示"加载中",这显然不对。

首次加载复用 refreshing

页面首次打开时,数据还没加载,屏幕是空的。常见的做法是显示一个全屏 loading(骨架屏或转圈),但这需要额外的状态和 UI。

我们的做法是:autoLoad: true(默认开启)会在组件挂载时自动调用 fetchData(1, 'refresh'),走和下拉刷新完全一样的路径。这样 refreshing 状态就会为 trueRefreshControl 的菊花会自动显示在列表顶部,既作为首次加载的反馈,又保持了和下拉刷新一致的视觉体验。不需要额外的全屏 loading 组件。

fetcher 模式:把"用什么 API"的决定权交给页面

Hook 不关心具体的 API 地址和参数格式,它只要求页面提供一个 fetcher 函数:

interface UsePaginatedListOptions<T> {
  fetcher: (params: { pageNumber: number; pageSize: number }) => Promise<FetcherResponse<T>>;
  pageSize?: number;       // 默认 10
  autoLoad?: boolean;      // 默认 true,挂载时自动加载第一页
  isSuccess?: (res) => boolean;  // 默认 code === 200
}

这样做的好处:

  • Hook 与业务完全解耦,任何页面、任何 API 都能用
  • 页面可以在 fetcher 里做任何事:加 token、加筛选参数、转换数据格式
  • 方便测试:mock 一个 fetcher 就能测试 Hook 的逻辑

2.4 为什么需要"state + ref"双轨制?

这是整个方案中最关键的设计,也是 React Hooks 最容易踩坑的地方。

问题:闭包捕获过期值

useCallback 创建的回调会捕获创建时的变量值。如果一个值后来变了,回调里拿到的还是旧值。

const [pageNum, setPageNum] = useState(1);

const loadMore = useCallback(() => {
  fetchData(pageNum + 1);  // ❌ pageNum 可能是旧值!
}, [pageNum]);  // pageNum 变化时 loadMore 重建,但旧的 onEndReached 可能还引用着旧 loadMore

这个场景中,loadMore 被传给 FlatListonEndReached。如果 FlatList 还没来得及更新 onEndReached prop,它持有的就是旧的 loadMore,里面的 pageNum 也是旧的 → 重复请求第 2 页。

解法:ref 做同步副本

const [pageNum, setPageNum] useState(1);
const pageNumRef = useRef(1);

// 每次 pageNum 变化,同步更新 ref
// (实际上我们在 fetchData 里直接写 pageNumRef.current = page,更简洁)

const loadMore = useCallback(() => {
  fetchData(pageNumRef.current + 1);  // ✅ 从 ref 读,永远是最新值
}, [fetchData]);  // 依赖中不需要 pageNum

useRef.current 是一个可变的同步变量,不参与 React 的渲染周期,任何时候读到的都是最新值。

这个模式在方案中的应用

我们的代码里有 4 个地方用了这个"state + ref"模式:

stateref用途
pageNumpageNumRefloadMore 读取最新页码
datadataReffetchDataloadMore 模式读取当前数据做拼接
fetcherReffetchData 读取最新的 fetcher(不在 useCallback 依赖中)
isFetchingRef并发锁,同步检查/释放

注意 fetcherRef 只有 ref 没有 state,因为 fetcher 的引用不需要触发重渲染——我们只在发请求时读它,不在 JSX 里用它。

2.5 数据流

理解了上面的设计,再来看完整的数据流。

一次完整的下拉刷新

用户下拉
  │
  ▼
RefreshControl 检测到手势 → 调用 onRefresh={refresh}
  │
  ▼
refresh() → fetchData(1, 'refresh')
  │
  ├── isFetchingRef.current === true (加锁,拦截并发请求)
  ├── setRefreshing(true)(显示顶部菊花)
  ├── setError(null)(清除之前的错误)
  │
  ▼
fetcherRef.current({ pageNumber: 1, pageSize: 10 })
  │
  ▼
API 响应返回
  │
  ├── 成功:setData(items), setHasMore(长度 < total), setTotal(total)
  ├── 业务失败:setError({ type: 'business', response })
  └── 网络异常:setError({ type: 'exception', error })
  │
  ▼
finally(无论成功失败都执行)
  ├── setRefreshing(false)(隐藏菊花)
  └── isFetchingRef.current = false(释放锁)

一次完整的上拉加载更多

用户滚动到底部
  │
  ▼
FlatList 触发 onEndReached(前提是已经滚动到距离底部 onEndReachedThreshold 以内)
  │
  ▼
loadMore()
  │
  ├── 检查 hasMore === true(没有更多数据则跳过)
  ├── 检查 isFetchingRef.current === false(有请求在飞则跳过)
  │
  ▼
fetchData(pageNumRef.current + 1, 'loadMore')
  │
  ├── isFetchingRef.current = true
  ├── setLoadingMore(true)(显示底部"加载中...")
  │
  ▼
API 返回 → setData([...dataRef.current, ...items])(追加而非替换)
  │
  ▼
finally: setLoadingMore(false) + 释放锁

2.6 踩坑记录:五个关键的设计决策

踩坑 1:fetcher 重建导致意外请求

场景:页面有筛选条件,用户修改了关键词但还没点"查询",列表就自动刷新了。

原因:fetcher 依赖 filters → filters 变化 → fetcher 引用变化 → usePaginatedList 内部的 useEffect(监听 fetchData 变化)感知到变化 → 自动触发请求。

// ❌ 错误写法
const fetcher = useCallback(
  (params) => apiService({ ...params, ...filters }),
  [filters],  // filters 变 → fetcher 变 → 意外触发请求
);

解法:用 filtersRef 持有最新筛选条件,fetcher 的依赖中只有 filtersRef(useRef 引用永远不变):

// ✅ 正确写法
const fetcher = useCallback(
  (params) => apiService({ ...params, ...filtersRef.current }),
  [filtersRef],  // filtersRef 引用永远不变,fetcher 不会重建
);

同时,usePaginatedList 内部也用 fetcherRef 持有最新的 fetcher,fetchDatauseCallback 依赖中不包含 fetcher,只有 pageSize。这样即使页面侧的 fetcher 重建了,fetchData 也不会重建,内部的 useEffect 也不会重新触发。

踩坑 2:并发请求导致数据错乱

场景:用户快速下拉,第一次请求还没返回,第二次下拉又触发了请求。两个请求的返回顺序不确定,可能导致:先发的请求后返回,覆盖了后发请求的数据。

解法isFetchingRef 作为同步锁。fetchData 入口检查,如果正在请求中直接 returnfinally 中释放。因为 ref 的读写是同步的(不经过 React 调度),所以不会出现"两个请求同时通过检查"的竞态。

踩坑 3:onEndReached 不停触发

场景:列表数据不足一屏时,FlatListonEndReached 会反复触发(因为内容距离底部始终在阈值以内),导致不停发请求。

解法:两道防线——

  1. onEndReachedThreshold={0.01}:只有滚动到距离底部 1% 以内才触发(默认值 0.5 太大了)
  2. isFetchingRef 锁:即使 onEndReached 被多次调用,第二次之后都会被锁拦截

踩坑 4:组件卸载后 setState

场景:请求发出去了,用户在响应回来之前点了返回,组件已经卸载。此时 finally 里的 setRefreshing(false) 会触发 React 警告:"Can't perform a React state update on an unmounted component"。

解法isMountedRefuseEffect 的 cleanup 中置为 falsefetchDatatry / catch / finally 中都检查 isMountedRef.current,如果组件已卸载就跳过所有 setState。

const isMountedRef = useRef(true);
useEffect(() => {
  return () => { isMountedRef.current = false; };
}, []);

// fetchData 中:
if (!isMountedRef.current) return;  // try 块中
if (isMountedRef.current) { ... }   // catch / finally 块中

踩坑 5:error 状态需要区分类型

场景:网络断开(exception)和接口返回 code: 500(business error)需要不同的 UI 表现。网络断开可能需要显示"请检查网络",业务错误可能需要显示"服务繁忙"。

解法:error 是一个联合类型:

type PaginatedError<T> =
  | { type: 'exception'; error: unknown }     // 网络异常、超时
  | { type: 'business'; response: FetcherResponse<T> };  // 业务 code 不对

页面可以根据 error.type 渲染不同的错误提示,也可以统一处理。

2.7 筛选条件的"重置信号"机制

用户点击"重置"按钮时,需要做两件事:清空筛选条件 + 立刻刷新列表。

直觉上,resetFilters 里直接调 refresh() 不就行了?不行,因为这会导致循环依赖:resetFiltersrefreshrefresh 依赖 fetchDatafetchData 依赖 filtersRef → ……

解法是引入一个一次性的"重置信号":

// useFilters 内部
const resetSignalRef = useRef(false);

const resetFilters = useCallback((options?: { refresh?: boolean }) => {
  const fresh = { ...initialRef.current };
  filtersRef.current = fresh;
  setFiltersState(fresh);
  if (options?.refresh) {
    resetSignalRef.current = true;  // 埋下信号
  }
}, []);

const consumeResetSignal = useCallback(() => {
  if (resetSignalRef.current) {
    resetSignalRef.current = false;  // 消费后清零
    return true;
  }
  return false;
}, []);

页面在 useEffect 中消费信号:

useEffect(() => {
  if (consumeResetSignal()) {
    refresh();  // 重置后立刻刷新
  }
}, [filters, refresh, consumeResetSignal]);

这是一个经典的"跨 Hook 通信"模式:useFilters 通过 ref 埋下信号,useEffect 在合适的时机消费它。信号是一次性的,读取后自动清零,不会重复触发。


三、完整代码

以下是方案涉及的所有文件完整代码。其中 usePaginatedListuseFiltersuseScrollToTopFabListFooterPaginatedListScrollToTopFab 是与业务无关的通用代码,可以直接复用;FilterListLayout 和两个页面示例包含了项目特定的组件引用(如搜索栏、下拉筛选器等),展示的是集成思路,实际使用时需要替换为你自己的组件。

3.1 src/types/api.types.ts — 分页参数类型

// ─── 分页 ────────────────────────────────────────────────────────────────────

export interface PaginatedParams {
  pageNumber: number;
  pageSize: number;
  [key: string]: unknown;
}

3.2 src/hooks/usePaginatedList.ts — 分页核心 Hook

import {useCallback, useEffect, useState, useRef} from 'react';
import {FlatList} from 'react-native';

export type FetchType = 'refresh' | 'loadMore';

interface PaginatedResult<T> {
  data: T[];
  total: number;
}

interface FetcherResponse<T> {
  code: number | string;
  data?: PaginatedResult<T>;
}

interface UsePaginatedListOptions<T> {
  fetcher: (params: {
    pageNumber: number;
    pageSize: number;
  }) => Promise<FetcherResponse<T>>;
  pageSize?: number;
  autoLoad?: boolean;
  isSuccess?: (res: FetcherResponse<T>) => boolean;
}

export type PaginatedError<T> =
  | {type: 'exception'; error: unknown}
  | {type: 'business'; response: FetcherResponse<T>};

const defaultIsSuccess = <T>(res: FetcherResponse<T>) =>
  res.code === 200 || res.code === '200';

export function usePaginatedList<T>({
  fetcher,
  pageSize = 10,
  autoLoad = true,
  isSuccess = defaultIsSuccess,
}: UsePaginatedListOptions<T>) {
  const [data, setData] = useState<T[]>([]);
  const [pageNum, setPageNum] = useState(1);
  const pageNumRef = useRef(1);
  const [hasMore, setHasMore] = useState(true);
  const [refreshing, setRefreshing] = useState(false);
  const [loadingMore, setLoadingMore] = useState(false);
  const [total, setTotal] = useState(0);
  const [error, setError] = useState<PaginatedError<T> | null>(null);

  const isFetchingRef = useRef(false);
  const listRef = useRef<FlatList<T>>(null);

  const fetcherRef = useRef(fetcher);
  fetcherRef.current = fetcher;
  const isSuccessRef = useRef(isSuccess);
  isSuccessRef.current = isSuccess;
  const dataRef = useRef<T[]>([]);
  dataRef.current = data;
  const isMountedRef = useRef(true);
  useEffect(() => {
    isMountedRef.current = true;
    return () => {
      isMountedRef.current = false;
    };
  }, []);

  const fetchData = useCallback(
    async (page: number, type: FetchType) => {
      if (isFetchingRef.current) {
        return;
      }
      isFetchingRef.current = true;

      if (type === 'refresh') {
        setRefreshing(true);
      } else {
        setLoadingMore(true);
      }
      setError(null);

      try {
        const res = await fetcherRef.current({
          pageNumber: page,
          pageSize,
        });
        if (!isMountedRef.current) {
          return;
        }
        if (isSuccessRef.current(res) && res.data) {
          const items = res.data.data ?? [];
          const totalCount = res.data.total ?? 0;

          const newData =
            type === 'loadMore' ? [...dataRef.current, ...items] : items;
          setData(newData);
          setHasMore(newData.length < totalCount);
          setTotal(totalCount);
          pageNumRef.current = page;
          setPageNum(page);
        } else {
          setError({type: 'business', response: res});
        }
      } catch (err) {
        if (isMountedRef.current) {
          setError({type: 'exception', error: err});
        }
        if (__DEV__) {
          console.warn('[usePaginatedList] fetch error:', err);
        }
      } finally {
        if (isMountedRef.current) {
          setRefreshing(false);
          setLoadingMore(false);
        }
        isFetchingRef.current = false;
      }
    },
    [pageSize],
  );

  const refresh = useCallback(async () => {
    await fetchData(1, 'refresh');
  }, [fetchData]);

  const loadMore = useCallback(() => {
    if (hasMore && !isFetchingRef.current) {
      fetchData(pageNumRef.current + 1, 'loadMore');
    }
  }, [hasMore, fetchData]);

  const scrollToTop = useCallback(() => {
    listRef.current?.scrollToOffset({offset: 0, animated: true});
  }, []);

  useEffect(() => {
    if (autoLoad) {
      fetchData(1, 'refresh');
    }
  }, [autoLoad, fetchData]);

  const hasMoreThanOnePage = total > pageSize || data.length > pageSize;

  return {
    data,
    total,
    pageSize,
    hasMoreThanOnePage,
    refreshing,
    loadingMore,
    hasMore,
    pageNum,
    error,
    listRef,
    scrollToTop,
    refresh,
    loadMore,
  };
}

3.3 src/hooks/useFilters.ts — 筛选条件管理

import {useCallback, useRef, useState} from 'react';

export type ResetFiltersOptions = {
  refresh?: boolean;
};

export function useFilters<T extends object>(initial: T) {
  const [filters, setFiltersState] = useState<T>(initial);
  const filtersRef = useRef<T>(initial);
  const initialRef = useRef(initial);

  const resetSignalRef = useRef(false);

  const updateFilter = useCallback(<K extends keyof T>(key: K, value: T[K]) => {
    filtersRef.current = {...filtersRef.current, [key]: value};
    setFiltersState(filtersRef.current);
  }, []);

  const setFilters = useCallback((next: T | ((prev: T) => T)) => {
    if (typeof next === 'function') {
      filtersRef.current = (next as (prev: T) => T)(filtersRef.current);
    } else {
      filtersRef.current = next;
    }
    setFiltersState(filtersRef.current);
  }, []);

  const resetFilters = useCallback((options?: ResetFiltersOptions) => {
    const fresh = {...initialRef.current};
    filtersRef.current = fresh;
    setFiltersState(fresh);
    if (options?.refresh) {
      resetSignalRef.current = true;
    }
  }, []);

  const consumeResetSignal = useCallback(() => {
    if (resetSignalRef.current) {
      resetSignalRef.current = false;
      return true;
    }
    return false;
  }, []);

  return {
    filters,
    filtersRef,
    setFilters,
    updateFilter,
    resetFilters,
    consumeResetSignal,
  };
}

3.4 src/hooks/useScrollToTopFab.ts — 回到顶部滚动监听

import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {
  Dimensions,
  type NativeScrollEvent,
  type NativeSyntheticEvent,
} from 'react-native';

const DEFAULT_OFFSET_THRESHOLD = Dimensions.get('window').height * 0.5;
const DEFAULT_THROTTLE = 16;

export interface UseScrollToTopFabOptions {
  resetVisibilityKey?: string | number;
  offsetThreshold?: number;
  enabled?: boolean;
}

export function useScrollToTopFab({
  resetVisibilityKey,
  offsetThreshold = DEFAULT_OFFSET_THRESHOLD,
  enabled = true,
}: UseScrollToTopFabOptions = {}) {
  const [scrollToTopFabVisible, setScrollToTopFabVisible] = useState(false);

  const isVisibleRef = useRef(false);
  const thresholdRef = useRef(offsetThreshold);
  thresholdRef.current = offsetThreshold;
  const enabledRef = useRef(enabled);
  enabledRef.current = enabled;

  const onListScroll = useCallback(
    (event: NativeSyntheticEvent<NativeScrollEvent>) => {
      if (!enabledRef.current) {
        return;
      }
      const offsetY = event.nativeEvent.contentOffset.y;
      const shouldShow = offsetY > thresholdRef.current;

      if (shouldShow !== isVisibleRef.current) {
        isVisibleRef.current = shouldShow;
        setScrollToTopFabVisible(shouldShow);
      }
    },
    [],
  );

  useEffect(() => {
    if (resetVisibilityKey !== undefined || !enabled) {
      isVisibleRef.current = false;
      setScrollToTopFabVisible(false);
    }
  }, [resetVisibilityKey, enabled]);

  const scrollBind = useMemo(
    () => ({
      onScroll: onListScroll,
      scrollEventThrottle: DEFAULT_THROTTLE,
    }),
    [onListScroll],
  );

  return {
    scrollBind,
    scrollToTopFabVisible,
  };
}

export type UseScrollToTopFabReturn = ReturnType<typeof useScrollToTopFab>;

3.5 src/components/ListFooter.tsx — 底部指示器

import React from 'react';
import {View, Text, ActivityIndicator, StyleSheet} from 'react-native';

interface ListFooterProps {
  loadingMore: boolean;
  hasMore: boolean;
  isEmpty: boolean;
}

const ListFooter: React.FC<ListFooterProps> = ({
  loadingMore,
  hasMore,
  isEmpty,
}) => {
  if (loadingMore) {
    return (
      <View style={styles.footer}>
        <ActivityIndicator color="#0C68F2" />
        <Text style={styles.footerText}>加载中...</Text>
      </View>
    );
  }
  if (!hasMore && !isEmpty) {
    return (
      <View style={styles.footer}>
        <Text style={styles.footerText}>没有更多数据了</Text>
      </View>
    );
  }
  return <View style={styles.footer} />;
};

const styles = StyleSheet.create({
  footer: {
    paddingVertical: 20,
    alignItems: 'center',
    justifyContent: 'center',
  },
  footerText: {
    fontSize: 13,
    color: '#999',
    marginTop: 8,
  },
});

export default ListFooter;

3.6 src/components/PaginatedListScrollToTopFab.tsx — 回到顶部按钮

import React from 'react';
import {StyleSheet, Text, TouchableOpacity} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';

export interface PaginatedListScrollToTopFabProps {
  visible: boolean;
  onPress: () => void;
  bottomOffset?: number;
}

export default function PaginatedListScrollToTopFab({
  visible,
  onPress,
  bottomOffset = 16,
}: PaginatedListScrollToTopFabProps) {
  const insets = useSafeAreaInsets();

  if (!visible) {
    return null;
  }

  const fabBottom = insets.bottom + bottomOffset;

  return (
    <TouchableOpacity
      accessibilityLabel="回到顶部"
      accessibilityRole="button"
      activeOpacity={0.85}
      onPress={onPress}
      style={[styles.fab, {bottom: fabBottom}]}>
      <Text style={styles.arrow}></Text>
    </TouchableOpacity>
  );
}

const styles = StyleSheet.create({
  fab: {
    position: 'absolute',
    right: 16,
    width: 44,
    height: 44,
    borderRadius: 22,
    backgroundColor: '#0C68F2',
    alignItems: 'center',
    justifyContent: 'center',
    shadowColor: '#000',
    shadowOffset: {width: 0, height: 2},
    shadowOpacity: 0.2,
    shadowRadius: 3,
    elevation: 4,
    zIndex: 10,
  },
  arrow: {
    color: '#fff',
    fontSize: 22,
    fontWeight: '600',
    marginTop: -2,
  },
});

3.7 src/components/FilterListLayout/index.tsx — 搜索 + 筛选 + 列表组合布局

import React from 'react';
import {
  View,
  Text,
  StyleSheet,
  Image,
  FlatList,
  TouchableOpacity,
  RefreshControl,
  type ListRenderItem,
  type FlatListProps,
} from 'react-native';
import {DaDropdown, type DropdownMenuItem} from '@/components/DaDropdown';
import {DarkSearchBar} from '@/components/HeaderSearch/DarkSearchBar';
import {ListFooter, PaginatedListScrollToTopFab} from '@/components';
import type {PaginatedError} from '@/hooks/usePaginatedList';
import type {UseScrollToTopFabReturn} from '@/hooks/useScrollToTopFab';
import {SUB_STACK_HEADER_BG} from '@/theme/colors';

type FilterListSearchProps = {
  value?: string;
  placeholder: string;
  onChange: (value: string) => void;
  onSubmit: (value: string) => void;
  onCancel: (value: string) => void;
};

type FilterListDropdownProps = {
  menu: DropdownMenuItem[];
  onMenuChange: (menu: DropdownMenuItem[]) => void;
  onConfirm: (payload: Record<string, unknown>) => void;
  renderSlot1?: () => React.ReactNode;
};

type FilterListBodyProps<T> = {
  data: T[];
  keyExtractor: (item: T) => string;
  renderItem: ListRenderItem<T>;
  listRef: React.RefObject<FlatList<T> | null>;
  refreshing: boolean;
  loadingMore: boolean;
  hasMore: boolean;
  error: PaginatedError<T> | null;
  onRefresh: () => void;
  onLoadMore: () => void;
  onScrollToTop: () => void;
  scrollBind: UseScrollToTopFabReturn['scrollBind'];
  scrollToTopFabVisible: boolean;
  emptyText?: string;
  listProps?: Partial<
    Pick<
      FlatListProps<T>,
      'contentContainerStyle' | 'onEndReachedThreshold' | 'removeClippedSubviews'
    >
  >;
};

export type FilterListLayoutProps<T> = {
  search: FilterListSearchProps;
  dropdown: FilterListDropdownProps;
  list: FilterListBodyProps<T>;
};

function ListErrorState({onRetry}: {onRetry: () => void}) {
  return (
    <View style={styles.errorContainer}>
      <Text style={styles.errorText}>加载失败,请稍后重试</Text>
      <TouchableOpacity style={styles.retryBtn} onPress={onRetry}>
        <Text style={styles.retryBtnText}>重试</Text>
      </TouchableOpacity>
    </View>
  );
}

function ListEmptyState({text}: {text: string}) {
  return (
    <View style={styles.emptyContainer}>
      <Image
        source={require('@/assets/order/no.png')}
        style={styles.emptyImage}
        resizeMode="contain"
      />
      <Text style={styles.emptyText}>{text}</Text>
    </View>
  );
}

export function FilterListLayout<T>({
  search,
  dropdown,
  list,
}: FilterListLayoutProps<T>) {
  const {
    data,
    keyExtractor,
    renderItem,
    listRef,
    refreshing,
    loadingMore,
    hasMore,
    error,
    onRefresh,
    onLoadMore,
    onScrollToTop,
    scrollBind,
    scrollToTopFabVisible,
    emptyText = '暂无数据',
    listProps,
  } = list;

  const showError = error != null && !refreshing && data.length === 0;

  return (
    <View style={styles.root}>
      <View style={styles.header}>
        <DarkSearchBar
          value={search.value}
          placeholder={search.placeholder}
          onSubmit={search.onSubmit}
          onCancel={search.onCancel}
          onChange={search.onChange}
        />
        <DaDropdown
          dropdownMenu={dropdown.menu}
          onDropdownMenuChange={dropdown.onMenuChange}
          onConfirm={dropdown.onConfirm}
          onRefresh={onRefresh}
          renderSlot1={dropdown.renderSlot1}
        />
      </View>

      <View style={styles.body}>
        <FlatList
          ref={listRef}
          {...scrollBind}
          data={data}
          keyExtractor={keyExtractor}
          renderItem={renderItem}
          refreshControl={
            <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
          }
          onEndReached={onLoadMore}
          onEndReachedThreshold={listProps?.onEndReachedThreshold ?? 0.01}
          removeClippedSubviews={listProps?.removeClippedSubviews}
          ListFooterComponent={
            <ListFooter
              loadingMore={loadingMore}
              hasMore={hasMore}
              isEmpty={data.length === 0}
            />
          }
          contentContainerStyle={[
            styles.listContent,
            listProps?.contentContainerStyle,
          ]}
          ListEmptyComponent={
            !refreshing ? (
              showError ? (
                <ListErrorState onRetry={onRefresh} />
              ) : (
                <ListEmptyState text={emptyText} />
              )
            ) : null
          }
        />
        <PaginatedListScrollToTopFab
          visible={scrollToTopFabVisible}
          onPress={onScrollToTop}
        />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  root: {
    flex: 1,
    backgroundColor: SUB_STACK_HEADER_BG,
  },
  header: {
    width: '100%',
    backgroundColor: SUB_STACK_HEADER_BG,
    justifyContent: 'center',
    alignItems: 'center',
    paddingHorizontal: 10,
    paddingVertical: 10,
  },
  body: {
    flex: 1,
    backgroundColor: '#FFFFFF',
    paddingTop: 10,
  },
  listContent: {
    paddingHorizontal: 10,
    paddingBottom: 15,
  },
  emptyContainer: {
    paddingTop: 60,
    alignItems: 'center',
  },
  emptyImage: {
    width: 140,
    height: 140,
    marginBottom: 10,
  },
  emptyText: {
    color: '#ccc',
    fontSize: 14,
  },
  errorContainer: {
    paddingTop: 60,
    alignItems: 'center',
    paddingHorizontal: 24,
  },
  errorText: {
    color: '#999',
    fontSize: 14,
    marginBottom: 16,
    textAlign: 'center',
  },
  retryBtn: {
    height: 40,
    paddingHorizontal: 24,
    borderRadius: 6,
    backgroundColor: '#0C68F2',
    alignItems: 'center',
    justifyContent: 'center',
  },
  retryBtnText: {
    color: '#fff',
    fontSize: 15,
  },
});

export {FilterSlotPanel} from './FilterSlotPanel';
export type {FilterSlotField} from './FilterSlotPanel';

3.8 示例 A:简单分页页面(无筛选)

// src/screens/Home/FoodCertificate/index.tsx

import React, {useCallback} from 'react';
import {
  View,
  Text,
  FlatList,
  RefreshControl,
  TouchableOpacity,
  StyleSheet,
} from 'react-native';
import {Icon} from '@ant-design/react-native';
import type {NativeStackScreenProps} from '@react-navigation/native-stack';
import type {RootStackParamList} from '@/navigation/types';
import {findFoodLicPage} from '@/services/homeService';
import {ListFooter, PaginatedListScrollToTopFab} from '@/components';
import {usePaginatedList} from '@/hooks/usePaginatedList';
import {useScrollToTopFab} from '@/hooks/useScrollToTopFab';

type Props = NativeStackScreenProps<RootStackParamList, '/Home/FoodCertificate'>;

interface FoodLicItem {
  id: string;
  companyName: string;
  validUntil: string;
  diffNum: number;
}

export default function FoodCertificateScreen({navigation}: Props) {
  const fetcher = useCallback(
    ({pageNumber, pageSize}: {pageNumber: number; pageSize: number}) =>
      findFoodLicPage({pageNumber, pageSize} as any, {noLoading: true}),
    [],
  );

  const {
    data,
    hasMoreThanOnePage,
    refreshing,
    loadingMore,
    hasMore,
    listRef,
    scrollToTop,
    refresh,
    loadMore,
  } = usePaginatedList<FoodLicItem>({fetcher});

  const {scrollBind, scrollToTopFabVisible} = useScrollToTopFab({
    enabled: hasMoreThanOnePage,
  });

  const getStatusStyle = (diffNum: number) => {
    if (diffNum < 0)
      return {bg: '#FFF5F5', border: '#FFCCCC', text: '#FF3434', label: '超期预警'};
    if (diffNum === 0)
      return {bg: '#FFF9F5', border: '#FFE1CC', text: '#FF8937', label: '今日到期'};
    return {bg: 'transparent', border: '#0C68F2', text: '#0C68F2', label: '到期时间'};
  };

  const renderItem = ({item}: {item: FoodLicItem}) => {
    const status = getStatusStyle(item.diffNum);
    return (
      <TouchableOpacity
        style={styles.item}
        onPress={() => navigation.navigate('/Home/FoodFirmMessage', {id: item.id})}>
        <View style={styles.itemHeader}>
          <View style={[styles.statusTag, {backgroundColor: status.bg, borderColor: status.border}]}>
            <Text style={[styles.statusText, {color: status.text}]}>{status.label}</Text>
          </View>
          <Text style={[styles.dateText, {color: status.text}]}>{item.validUntil}</Text>
        </View>
        <View style={styles.itemBody}>
          <Text style={styles.companyName}>{item.companyName}</Text>
          <Icon name="right" color="#BFBFBF" size={14} />
        </View>
        <View style={styles.divider} />
      </TouchableOpacity>
    );
  };

  return (
    <View style={styles.root}>
      <FlatList
        ref={listRef}
        {...scrollBind}
        data={data}
        keyExtractor={(item, index) => `${item.id}_${index}`}
        renderItem={renderItem}
        refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} />}
        onEndReached={loadMore}
        onEndReachedThreshold={0.01}
        ListFooterComponent={
          <ListFooter loadingMore={loadingMore} hasMore={hasMore} isEmpty={data.length === 0} />
        }
        contentContainerStyle={styles.list}
      />
      <PaginatedListScrollToTopFab visible={scrollToTopFabVisible} onPress={scrollToTop} />
    </View>
  );
}

const styles = StyleSheet.create({
  root: {flex: 1, backgroundColor: '#F6F9FF'},
  list: {paddingHorizontal: 16, paddingTop: 16},
  item: {paddingTop: 14, backgroundColor: '#fff', paddingHorizontal: 16, marginBottom: 8, borderRadius: 8},
  itemHeader: {flexDirection: 'row', alignItems: 'center', marginBottom: 10},
  statusTag: {borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 3, marginRight: 12},
  statusText: {fontSize: 12},
  dateText: {fontSize: 20, fontWeight: 'bold'},
  itemBody: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14},
  companyName: {fontSize: 14, color: '#666', flex: 1},
  divider: {height: 1, backgroundColor: '#f0f0f0'},
});

3.9 示例 B:带筛选条件的分页页面(完整版)

// src/screens/MarketReg/DemoTodoList.tsx

import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {View, Text, StyleSheet, TouchableOpacity} from 'react-native';
import {useFocusEffect} from '@react-navigation/native';
import type {NativeStackScreenProps} from '@react-navigation/native-stack';
import type {MarketRegStackParamList} from './demoNavigationTypes';
import {usePaginatedList} from '@/hooks/usePaginatedList';
import {useScrollToTopFab} from '@/hooks/useScrollToTopFab';
import {useFilters} from '@/hooks/useFilters';
import {FilterListLayout, FilterSlotPanel} from '@/components/FilterListLayout';
import {type DropdownMenuItem} from '@/components/DaDropdown';
import {Picker} from '@ant-design/react-native';
import {
  MOCK_CATEGORIES,
  MOCK_PRIORITIES,
  MOCK_SOURCES,
  MOCK_STATUS_OPTIONS,
  mockFetchTodoList,
} from './demoTodoList/mock';
import type {TodoFilters, TodoItem} from './demoTodoList/types';
import {INITIAL_TODO_FILTERS} from './demoTodoList/types';

type Props = NativeStackScreenProps<MarketRegStackParamList, 'MarketRegTodoList'>;

const PRIORITY_COLOR: Record<string, {bg: string; text: string}> = {
  urgent: {bg: '#FFF0F0', text: '#E4393C'},
  important: {bg: '#FFF7E6', text: '#FA8C16'},
  normal: {bg: '#F0F9FF', text: '#0C68F2'},
};

const STATUS_COLOR: Record<string, {bg: string; text: string}> = {
  pending: {bg: '#FFF7E6', text: '#FA8C16'},
  processing: {bg: '#F0F9FF', text: '#0C68F2'},
  done: {bg: '#F0FFF0', text: '#52C41A'},
  rejected: {bg: '#FFF0F0', text: '#E4393C'},
};

export default function DemoTodoListScreen({navigation}: Props) {
  const {filters, filtersRef, updateFilter, resetFilters} =
    useFilters<TodoFilters>(INITIAL_TODO_FILTERS);

  const [priorityPickerVisible, setPriorityPickerVisible] = useState(false);
  const [sourcePickerVisible, setSourcePickerVisible] = useState(false);

  const initialMenu = useMemo<DropdownMenuItem[]>(
    () => [
      {
        title: '业务分类',
        type: 'filter',
        prop: 'category',
        options: [{type: 'checkbox', prop: 'ft1', options: MOCK_CATEGORIES}],
      },
      {
        title: '状态',
        type: 'filter',
        prop: 'status',
        options: [{type: 'radio', prop: 'ft1', options: MOCK_STATUS_OPTIONS}],
      },
      {title: '更多筛选', type: 'slot1', prop: 'more'},
    ],
    [],
  );

  const [menu, setMenu] = useState<DropdownMenuItem[]>(initialMenu);

  const fetcher = useCallback(
    (params: {pageNumber: number; pageSize: number}) =>
      mockFetchTodoList({
        ...params,
        keyword: filtersRef.current.keyword,
        category: filtersRef.current.category,
        status: filtersRef.current.status,
        priority: filtersRef.current.priority,
        source: filtersRef.current.source,
      }),
    [filtersRef],
  );

  const {
    data, hasMoreThanOnePage, refreshing, loadingMore, hasMore,
    listRef, scrollToTop, refresh, loadMore, error,
  } = usePaginatedList<TodoItem>({fetcher, pageSize: 10, autoLoad: true});

  const {scrollBind, scrollToTopFabVisible} = useScrollToTopFab({
    enabled: hasMoreThanOnePage,
  });

  const applyDropdownPayload = useCallback(
    (payload: Record<string, unknown>) => {
      if ('category' in payload) {
        const catPayload = payload.category as Record<string, unknown> | undefined;
        if (catPayload && JSON.stringify(catPayload) !== '{}') {
          const selected = catPayload.ft1 as string[] | undefined;
          updateFilter('category', selected?.join(',') ?? '');
        } else {
          updateFilter('category', '');
        }
      }
      if ('status' in payload) {
        const statusPayload = payload.status as Record<string, unknown> | undefined;
        if (statusPayload && JSON.stringify(statusPayload) !== '{}') {
          const selected = statusPayload.ft1 as string | undefined;
          updateFilter('status', selected ?? '');
        } else {
          updateFilter('status', '');
        }
      }
    },
    [updateFilter],
  );

  const onDropdownConfirm = useCallback(
    (payload: Record<string, unknown>) => applyDropdownPayload(payload),
    [applyDropdownPayload],
  );

  const onSearchSubmit = useCallback(
    (value: string) => { updateFilter('keyword', value); refresh(); },
    [updateFilter, refresh],
  );

  const onSearchCancel = useCallback(
    (value: string) => { updateFilter('keyword', value); refresh(); },
    [updateFilter, refresh],
  );

  const onSearchChange = useCallback(
    (value: string) => {
      updateFilter('keyword', value);
      if (value.trim() === '') { refresh(); }
    },
    [updateFilter, refresh],
  );

  const onResetAll = useCallback(() => {
    resetFilters();
    setMenu(initialMenu);
  }, [resetFilters, initialMenu]);

  useFocusEffect(useCallback(() => { refresh(); }, [refresh]));

  useEffect(() => {
    const slotActive = !!(filters.priority || filters.source);
    setMenu(prev =>
      prev.map(item =>
        item.type === 'slot1' && item.prop === 'more'
          ? {...item, isActived: slotActive}
          : item,
      ),
    );
  }, [filters.priority, filters.source]);

  const prioritySelected = filters.priority != null && filters.priority !== '';
  const sourceSelected = filters.source != null && filters.source !== '';
  const priorityLabel = prioritySelected
    ? MOCK_PRIORITIES.find(p => p.value === filters.priority)?.label
    : '选择优先级';
  const sourceLabel = sourceSelected
    ? MOCK_SOURCES.find(s => s.value === filters.source)?.label
    : '选择来源';

  const renderSlot = useCallback(
    () => (
      <FilterSlotPanel
        fields={[
          {label: priorityLabel ?? '选择优先级', selected: prioritySelected, onPress: () => setPriorityPickerVisible(true)},
          {label: sourceLabel ?? '选择来源', selected: sourceSelected, onPress: () => setSourcePickerVisible(true)},
        ]}
        onReset={() => { updateFilter('priority', undefined); updateFilter('source', undefined); }}
        onResetAll={onResetAll}
        onQuery={refresh}
      />
    ),
    [priorityLabel, sourceLabel, prioritySelected, sourceSelected, updateFilter, refresh, onResetAll],
  );

  const renderItem = useCallback(
    ({item}: {item: TodoItem}) => {
      const pc = PRIORITY_COLOR[item.priorityCode] ?? PRIORITY_COLOR.normal;
      const sc = STATUS_COLOR[item.statusCode] ?? STATUS_COLOR.pending;

      return (
        <TouchableOpacity style={styles.card} activeOpacity={0.75}
          onPress={() => navigation.navigate('MarketRegTodoDetail', {id: item.id})}>
          <View style={styles.cardHeader}>
            <Text style={styles.cardTitle} numberOfLines={1}>{item.title}</Text>
            <View style={[styles.tag, {backgroundColor: pc.bg}]}>
              <Text style={[styles.tagText, {color: pc.text}]}>{item.priority}</Text>
            </View>
          </View>
          <View style={styles.infoRow}>
            <View style={styles.infoItem}>
              <Text style={styles.infoLabel}>业务分类:</Text>
              <Text style={styles.infoValue}>{item.category}</Text>
            </View>
            <View style={[styles.tag, {backgroundColor: sc.bg}]}>
              <Text style={[styles.tagText, {color: sc.text}]}>{item.status}</Text>
            </View>
          </View>
          <View style={styles.infoRow}>
            <View style={styles.infoItem}>
              <Text style={styles.infoLabel}>来源:</Text>
              <Text style={styles.infoValue}>{item.source}</Text>
            </View>
            <View style={styles.infoItem}>
              <Text style={styles.infoLabel}>指派人:</Text>
              <Text style={styles.infoValue}>{item.assignee}</Text>
            </View>
          </View>
          <View style={styles.cardFooter}>
            <Text style={styles.clockIcon}>🕐</Text>
            <Text style={styles.footerLabel}>创建:</Text>
            <Text style={styles.footerTime}>{item.createTime}</Text>
            <Text style={styles.footerDivider}>|</Text>
            <Text style={styles.footerLabel}>截止:</Text>
            <Text style={styles.footerTime}>{item.deadline}</Text>
          </View>
          <View style={styles.divider} />
        </TouchableOpacity>
      );
    },
    [navigation],
  );

  return (
    <>
      <FilterListLayout
        search={{
          value: filters.keyword,
          placeholder: '请输入待办事项标题/指派人',
          onSubmit: onSearchSubmit,
          onCancel: onSearchCancel,
          onChange: onSearchChange,
        }}
        dropdown={{menu, onMenuChange: setMenu, onConfirm: onDropdownConfirm, renderSlot1: renderSlot}}
        list={{
          data, keyExtractor: item => item.id, renderItem, listRef,
          refreshing, loadingMore, hasMore, error,
          onRefresh: refresh, onLoadMore: loadMore, onScrollToTop: scrollToTop,
          scrollBind, scrollToTopFabVisible, emptyText: '暂无待办事项',
        }}
      />

      <Picker
        visible={priorityPickerVisible}
        data={MOCK_PRIORITIES}
        cols={1}
        onOk={val => { updateFilter('priority', (val[0] as string) || undefined); setPriorityPickerVisible(false); }}
        onDismiss={() => setPriorityPickerVisible(false)}
      />
      <Picker
        visible={sourcePickerVisible}
        data={MOCK_SOURCES}
        cols={1}
        onOk={val => { updateFilter('source', (val[0] as string) || undefined); setSourcePickerVisible(false); }}
        onDismiss={() => setSourcePickerVisible(false)}
      />
    </>
  );
}

const styles = StyleSheet.create({
  card: {paddingTop: 15, paddingHorizontal: 15},
  cardHeader: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8},
  cardTitle: {flex: 1, fontSize: 15, color: '#333', marginRight: 8},
  tag: {borderRadius: 4, paddingHorizontal: 8, paddingVertical: 3},
  tagText: {fontSize: 12},
  infoRow: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6},
  infoItem: {flexDirection: 'row', alignItems: 'center', flex: 1},
  infoLabel: {fontSize: 14, color: '#999'},
  infoValue: {fontSize: 14, color: '#666'},
  cardFooter: {flexDirection: 'row', alignItems: 'center', marginTop: 8, marginBottom: 12},
  clockIcon: {fontSize: 14, marginRight: 4},
  footerLabel: {fontSize: 13, color: '#999'},
  footerTime: {fontSize: 13, color: '#666'},
  footerDivider: {marginHorizontal: 8, color: '#E0E0E0'},
  divider: {height: 1, backgroundColor: '#F0F0F0'},
});

四、总结

层级职责文件
类型层分页参数接口src/types/api.types.ts
数据层分页请求、状态管理、并发控制src/hooks/usePaginatedList.ts
筛选层筛选条件、重置信号src/hooks/useFilters.ts
监听层滚动监听、FAB 可见性src/hooks/useScrollToTopFab.ts
UI 层底部指示器、回到顶部按钮src/components/ListFooter.tsxPaginatedListScrollToTopFab.tsx
布局层搜索栏 + 筛选器 + 列表组合src/components/FilterListLayout/index.tsx
页面层只关心 API 和渲染各 Screen 文件

核心思想:把分页状态管理抽到 Hook 里,页面只做"配置 + 渲染"。 这套方案已在 22 个页面中稳定运行。