本文基于一个 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 把所有分页状态和逻辑封装起来,页面只需要:
- 定义一个
fetcher(告诉 Hook 用哪个 API) - 从 Hook 拿到
data、refresh、loadMore等状态和方法 - 负责 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);
为什么把 refreshing 和 loadingMore 分开?因为它们对应的 UI 不同:
refreshing驱动的是RefreshControl的顶部下拉菊花loadingMore驱动的是列表底部的"加载中..."指示器
如果只用一个 loading 状态,下拉刷新时底部也会显示"加载中",这显然不对。
首次加载复用 refreshing
页面首次打开时,数据还没加载,屏幕是空的。常见的做法是显示一个全屏 loading(骨架屏或转圈),但这需要额外的状态和 UI。
我们的做法是:autoLoad: true(默认开启)会在组件挂载时自动调用 fetchData(1, 'refresh'),走和下拉刷新完全一样的路径。这样 refreshing 状态就会为 true,RefreshControl 的菊花会自动显示在列表顶部,既作为首次加载的反馈,又保持了和下拉刷新一致的视觉体验。不需要额外的全屏 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 被传给 FlatList 的 onEndReached。如果 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"模式:
| state | ref | 用途 |
|---|---|---|
pageNum | pageNumRef | loadMore 读取最新页码 |
data | dataRef | fetchData 的 loadMore 模式读取当前数据做拼接 |
| — | fetcherRef | fetchData 读取最新的 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,fetchData 的 useCallback 依赖中不包含 fetcher,只有 pageSize。这样即使页面侧的 fetcher 重建了,fetchData 也不会重建,内部的 useEffect 也不会重新触发。
踩坑 2:并发请求导致数据错乱
场景:用户快速下拉,第一次请求还没返回,第二次下拉又触发了请求。两个请求的返回顺序不确定,可能导致:先发的请求后返回,覆盖了后发请求的数据。
解法:isFetchingRef 作为同步锁。fetchData 入口检查,如果正在请求中直接 return。finally 中释放。因为 ref 的读写是同步的(不经过 React 调度),所以不会出现"两个请求同时通过检查"的竞态。
踩坑 3:onEndReached 不停触发
场景:列表数据不足一屏时,FlatList 的 onEndReached 会反复触发(因为内容距离底部始终在阈值以内),导致不停发请求。
解法:两道防线——
onEndReachedThreshold={0.01}:只有滚动到距离底部 1% 以内才触发(默认值 0.5 太大了)isFetchingRef锁:即使onEndReached被多次调用,第二次之后都会被锁拦截
踩坑 4:组件卸载后 setState
场景:请求发出去了,用户在响应回来之前点了返回,组件已经卸载。此时 finally 里的 setRefreshing(false) 会触发 React 警告:"Can't perform a React state update on an unmounted component"。
解法:isMountedRef 在 useEffect 的 cleanup 中置为 false。fetchData 在 try / 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() 不就行了?不行,因为这会导致循环依赖:resetFilters 调 refresh → refresh 依赖 fetchData → fetchData 依赖 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 在合适的时机消费它。信号是一次性的,读取后自动清零,不会重复触发。
三、完整代码
以下是方案涉及的所有文件完整代码。其中 usePaginatedList、useFilters、useScrollToTopFab、ListFooter、PaginatedListScrollToTopFab 是与业务无关的通用代码,可以直接复用;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.tsx、PaginatedListScrollToTopFab.tsx |
| 布局层 | 搜索栏 + 筛选器 + 列表组合 | src/components/FilterListLayout/index.tsx |
| 页面层 | 只关心 API 和渲染 | 各 Screen 文件 |
核心思想:把分页状态管理抽到 Hook 里,页面只做"配置 + 渲染"。 这套方案已在 22 个页面中稳定运行。