Flutter 通用下拉刷新上拉加载列表 RefreshListWidget:分页 + 空态 + 错误处理

在 Flutter 开发中,分页列表(下拉刷新、上拉加载)是数据展示的核心场景。原生 RefreshIndicator 仅支持下拉刷新,上拉加载需手动实现,且缺乏空状态、错误状态统一处理。本文封装的 RefreshListWidget 整合 "下拉刷新 + 上拉加载 + 空态展示 + 错误重试" 四大核心能力,支持分页逻辑、样式自定义,一行代码即可集成,适配 90%+ 列表数据展示场景。

一、核心优势(精准解决开发痛点)

  1. 分页逻辑内置:统一封装下拉刷新(重置数据)、上拉加载(加载下一页)逻辑,无需外部手动管理页码与状态
  2. 状态全覆盖:支持加载中、空数据、加载失败、无更多数据四种状态,自动切换,无需额外判断
  3. 样式全自定义:刷新指示器、加载更多组件、空态组件、错误组件均可自定义,贴合 APP 设计风格
  4. 交互体验优化:上拉加载时禁止重复请求,滑动到底部自动加载,下拉刷新支持自定义动画
  5. 低侵入高复用:仅需传入数据源回调与列表项构建方法,无需关心状态管理,统一项目列表样式

二、核心配置速览(关键参数一目了然)

配置分类 核心参数 核心作用
必选配置 fetchData: Future<List<T>>itemBuilder: Widget Function(T, int) 数据请求回调(返回下一页数据)、列表项构建器
分页配置 pageSizeinitialPagehasMore 每页条数、初始页码、是否有更多数据
样式配置 refreshIndicatorColorloadMoreWidgetemptyWidgeterrorWidget 刷新指示器颜色、加载更多组件、空态组件、错误组件
交互配置 enablePullDownenablePullUponRefreshonLoadMore 启用下拉刷新、启用上拉加载、刷新回调、加载回调
适配配置 adaptDarkModepaddingphysics 深色模式适配、内边距、滚动物理效果

三、生产级完整代码(可直接复制,开箱即用)

dart

复制代码
import 'package:flutter/material.dart';

/// 列表加载状态枚举
enum ListLoadStatus {
  idle,        // 空闲状态
  loading,     // 加载中
  empty,       // 空数据
  error,       // 加载失败
  noMore,      // 无更多数据
}

/// 通用下拉刷新上拉加载列表组件
class RefreshListWidget<T> extends StatefulWidget {
  // 必选参数
  final Future<List<T>> Function(int page, int pageSize) fetchData; // 数据请求回调(参数:页码、每页条数)
  final Widget Function(T data, int index) itemBuilder; // 列表项构建器(参数:数据、索引)

  // 分页配置
  final int pageSize; // 每页条数(默认10)
  final int initialPage; // 初始页码(默认1)
  final bool Function(List<T> data)? hasMore; // 是否有更多数据(默认:返回数据长度 == pageSize)

  // 样式配置
  final Color refreshIndicatorColor; // 下拉刷新指示器颜色(默认蓝色)
  final Widget? loadMoreWidget; // 上拉加载组件
  final Widget? emptyWidget; // 空数据组件
  final Widget? errorWidget; // 加载失败组件
  final double itemSpacing; // 列表项间距(默认0)
  final EdgeInsetsGeometry padding; // 列表内边距(默认无)

  // 交互配置
  final bool enablePullDown; // 是否启用下拉刷新(默认true)
  final bool enablePullUp; // 是否启用上拉加载(默认true)
  final Duration refreshDuration; // 刷新动画时长(默认300ms)
  final ScrollPhysics? physics; // 滚动物理效果(默认BouncingScrollPhysics)

  // 适配配置
  final bool adaptDarkMode; // 适配深色模式(默认true)
  final bool shrinkWrap; // 是否自适应高度(默认false)
  final Axis scrollDirection; // 滚动方向(默认垂直)

  const RefreshListWidget({
    super.key,
    required this.fetchData,
    required this.itemBuilder,
    // 分页配置
    this.pageSize = 10,
    this.initialPage = 1,
    this.hasMore,
    // 样式配置
    this.refreshIndicatorColor = Colors.blue,
    this.loadMoreWidget,
    this.emptyWidget,
    this.errorWidget,
    this.itemSpacing = 0.0,
    this.padding = EdgeInsets.zero,
    // 交互配置
    this.enablePullDown = true,
    this.enablePullUp = true,
    this.refreshDuration = const Duration(milliseconds: 300),
    this.physics,
    // 适配配置
    this.adaptDarkMode = true,
    this.shrinkWrap = false,
    this.scrollDirection = Axis.vertical,
  });

  @override
  State<RefreshListWidget<T>> createState() => _RefreshListWidgetState<T>();
}

class _RefreshListWidgetState<T> extends State<RefreshListWidget<T>> {
  late List<T> _dataList;
  late int _currentPage;
  late ListLoadStatus _loadStatus;
  bool _isLoading = false; // 防止重复请求

  @override
  void initState() {
    super.initState();
    _dataList = [];
    _currentPage = widget.initialPage;
    _loadStatus = ListLoadStatus.idle;
    // 初始化加载第一页数据
    _fetchData(_currentPage, isRefresh: false);
  }

  /// 数据请求逻辑
  Future<void> _fetchData(int page, {required bool isRefresh}) async {
    if (_isLoading) return;
    setState(() {
      _isLoading = true;
      if (isRefresh) {
        _loadStatus = ListLoadStatus.loading;
      } else if (_loadStatus != ListLoadStatus.loading) {
        _loadStatus = ListLoadStatus.loading;
      }
    });

    try {
      final data = await widget.fetchData(page, widget.pageSize);
      setState(() {
        if (isRefresh) {
          // 下拉刷新:重置数据
          _dataList = data;
          _currentPage = widget.initialPage;
        } else {
          // 上拉加载:追加数据
          _dataList.addAll(data);
        }

        // 判断是否有更多数据
        final hasMore = widget.hasMore?.call(data) ?? (data.length == widget.pageSize);
        if (_dataList.isEmpty) {
          _loadStatus = ListLoadStatus.empty;
        } else if (!hasMore) {
          _loadStatus = ListLoadStatus.noMore;
        } else {
          _loadStatus = ListLoadStatus.idle;
          _currentPage++;
        }
      });
    } catch (e) {
      setState(() {
        _loadStatus = _dataList.isEmpty ? ListLoadStatus.error : _loadStatus;
        debugPrint("列表加载失败:$e");
      });
    } finally {
      setState(() => _isLoading = false);
    }
  }

  /// 下拉刷新回调
  Future<void> _onRefresh() async {
    await _fetchData(widget.initialPage, isRefresh: true);
    await Future.delayed(widget.refreshDuration); // 保证刷新动画完整
  }

  /// 上拉加载回调(滑动到底部触发)
  void _onScrollNotification(ScrollNotification notification) {
    if (!widget.enablePullUp || _loadStatus != ListLoadStatus.idle || _isLoading) return;

    final metrics = notification.metrics;
    if (metrics.pixels >= metrics.maxScrollExtent - 200) { // 提前200px触发加载
      _fetchData(_currentPage, isRefresh: false);
    }
  }

  /// 构建加载更多组件
  Widget _buildLoadMoreWidget() {
    if (_loadStatus != ListLoadStatus.loading || !widget.enablePullUp) return const SizedBox.shrink();

    return widget.loadMoreWidget ?? Container(
      height: 60,
      alignment: Alignment.center,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const CircularProgressIndicator(strokeWidth: 2),
          const SizedBox(width: 8),
          Text(
            "加载中...",
            style: TextStyle(
              fontSize: 14,
              color: _adaptDarkMode(const Color(0xFF999999), const Color(0xFF777777)),
            ),
          ),
        ],
      ),
    );
  }

  /// 构建空数据组件
  Widget _buildEmptyWidget() {
    if (_loadStatus != ListLoadStatus.empty) return const SizedBox.shrink();

    return widget.emptyWidget ?? Container(
      height: MediaQuery.of(context).size.height - 200,
      alignment: Alignment.center,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.inbox_outlined,
            size: 64,
            color: _adaptDarkMode(const Color(0xFFE0E0E0), const Color(0xFF444444)),
          ),
          const SizedBox(height: 16),
          Text(
            "暂无相关数据",
            style: TextStyle(
              fontSize: 16,
              color: _adaptDarkMode(const Color(0xFF666666), const Color(0xFF999999)),
            ),
          ),
          const SizedBox(height: 8),
          TextButton(
            onPressed: () => _fetchData(widget.initialPage, isRefresh: true),
            child: Text(
              "点击重试",
              style: TextStyle(color: _adaptDarkMode(Colors.blue, Colors.blueAccent)),
            ),
          ),
        ],
      ),
    );
  }

  /// 构建加载失败组件
  Widget _buildErrorWidget() {
    if (_loadStatus != ListLoadStatus.error) return const SizedBox.shrink();

    return widget.errorWidget ?? Container(
      height: MediaQuery.of(context).size.height - 200,
      alignment: Alignment.center,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.error_outlined,
            size: 64,
            color: _adaptDarkMode(Colors.redAccent, const Color(0xFFE57373)),
          ),
          const SizedBox(height: 16),
          Text(
            "加载失败,请重试",
            style: TextStyle(
              fontSize: 16,
              color: _adaptDarkMode(const Color(0xFF666666), const Color(0xFF999999)),
            ),
          ),
          const SizedBox(height: 8),
          TextButton(
            onPressed: () => _fetchData(widget.initialPage, isRefresh: true),
            child: Text(
              "重新加载",
              style: TextStyle(color: _adaptDarkMode(Colors.blue, Colors.blueAccent)),
            ),
          ),
        ],
      ),
    );
  }

  /// 构建无更多数据组件
  Widget _buildNoMoreWidget() {
    if (_loadStatus != ListLoadStatus.noMore || !widget.enablePullUp) return const SizedBox.shrink();

    return Container(
      height: 40,
      alignment: Alignment.center,
      child: Text(
        "没有更多数据了",
        style: TextStyle(
          fontSize: 14,
          color: _adaptDarkMode(const Color(0xFF999999), const Color(0xFF777777)),
        ),
      ),
    );
  }

  /// 深色模式颜色适配
  Color _adaptDarkMode(Color lightColor, Color darkColor) {
    if (!widget.adaptDarkMode) return lightColor;
    return MediaQuery.platformBrightnessOf(context) == Brightness.dark
        ? darkColor
        : lightColor;
  }

  @override
  Widget build(BuildContext context) {
    // 构建列表项
    final listItems = List.generate(_dataList.length, (index) {
      final item = widget.itemBuilder(_dataList[index], index);
      return Padding(
        padding: EdgeInsets.only(bottom: index == _dataList.length - 1 ? 0 : widget.itemSpacing),
        child: item,
      );
    });

    // 列表主体(包含下拉刷新、上拉加载、状态组件)
    final listBody = ListView(
      shrinkWrap: widget.shrinkWrap,
      physics: widget.physics ?? const BouncingScrollPhysics(),
      scrollDirection: widget.scrollDirection,
      padding: widget.padding,
      children: [
        ...listItems,
        _buildLoadMoreWidget(),
        _buildNoMoreWidget(),
      ],
    );

    // 包裹下拉刷新指示器
    final refreshBody = widget.enablePullDown
        ? RefreshIndicator(
            color: widget.refreshIndicatorColor,
            onRefresh: _onRefresh,
            child: listBody,
          )
        : listBody;

    // 叠加状态组件(空态/错误态)
    return Stack(
      children: [
        refreshBody,
        _buildEmptyWidget(),
        _buildErrorWidget(),
      ],
    );
  }
}

四、三大高频场景落地示例(直接复制到项目可用)

场景 1:普通分页列表(商品列表 - 下拉刷新上拉加载)

适用场景:商品列表、资讯列表、用户列表等普通分页场景

dart

复制代码
// 商品列表页面
class GoodsListPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("商品列表")),
      body: RefreshListWidget<GoodsModel>(
        pageSize: 15,
        fetchData: (page, pageSize) async {
          // 实际业务:调用分页接口
          final response = await GoodsApi.getGoodsList(page: page, pageSize: pageSize);
          return response.data; // 返回List<GoodsModel>
        },
        itemBuilder: (goods, index) {
          // 构建商品列表项
          return GoodsListItem(
            goods: goods,
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (context) => GoodsDetailPage(goods: goods)),
            ),
          );
        },
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        itemSpacing: 12,
        refreshIndicatorColor: Colors.orangeAccent,
        adaptDarkMode: true,
      ),
    );
  }
}

// 商品列表项组件(示例)
class GoodsListItem extends StatelessWidget {
  final GoodsModel goods;
  final VoidCallback onTap;

  const GoodsListItem({super.key, required this.goods, required this.onTap});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Card(
        elevation: 0,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(goods.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
              const SizedBox(height: 4),
              Text("¥${goods.price}", style: const TextStyle(color: Colors.redAccent, fontSize: 15)),
            ],
          ),
        ),
      ),
    );
  }
}

场景 2:空态与错误处理(资讯列表 - 无数据 / 加载失败)

适用场景:需要特殊处理空数据、加载失败的列表场景

dart

复制代码
// 资讯列表页面
RefreshListWidget<NewsModel>(
  fetchData: (page, pageSize) async {
    // 模拟接口请求
    await Future.delayed(const Duration(milliseconds: 800));
    // 模拟空数据场景(page=1时返回空)
    if (page == 1) return [];
    // 模拟正常数据
    return List.generate(pageSize, (index) => NewsModel(title: "资讯标题 ${page * pageSize + index}"));
  },
  itemBuilder: (news, index) {
    return ListTile(
      title: Text(news.title),
      trailing: const Icon(Icons.arrow_forward_ios, size: 16),
      onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => NewsDetailPage(news: news))),
    );
  },
  // 自定义空态组件
  emptyWidget: Container(
    height: MediaQuery.of(context).size.height - 200,
    alignment: Alignment.center,
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Image.asset("assets/images/no_news.png", width: 120, height: 120),
        const SizedBox(height: 16),
        const Text("暂无资讯", style: TextStyle(fontSize: 16, color: Color(0xFF666666))),
        const SizedBox(height: 8),
        TextButton(
          onPressed: () => debugPrint("刷新资讯"),
          child: const Text("刷新一下", style: TextStyle(color: Colors.blue)),
        ),
      ],
    ),
  ),
  // 自定义错误组件
  errorWidget: Container(
    height: MediaQuery.of(context).size.height - 200,
    alignment: Alignment.center,
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Icon(Icons.wifi_off_outlined, size: 64, color: Colors.orangeAccent),
        const SizedBox(height: 16),
        const Text("网络异常,加载失败", style: TextStyle(fontSize: 16, color: Color(0xFF666666))),
        const SizedBox(height: 8),
        TextButton(
          onPressed: () => debugPrint("重新加载资讯"),
          child: const Text("检查网络并重试", style: TextStyle(color: Colors.orangeAccent)),
        ),
      ],
    ),
  ),
  enablePullUp: false, // 关闭上拉加载
  padding: const EdgeInsets.symmetric(horizontal: 16),
);

场景 3:自定义加载更多组件(聊天记录 - 倒序加载)

适用场景:聊天记录、历史记录等需要自定义加载样式的场景

dart

复制代码
// 聊天记录列表(倒序加载,最新消息在底部)
RefreshListWidget<MessageModel>(
  pageSize: 20,
  initialPage: 1,
  fetchData: (page, pageSize) async {
    // 实际业务:调用聊天记录分页接口(倒序分页)
    final response = await MessageApi.getHistoryMessages(page: page, pageSize: pageSize, chatId: "123");
    return response.data; // 返回List<MessageModel>
  },
  itemBuilder: (message, index) {
    // 构建聊天消息项(左侧他人消息,右侧自己消息)
    return MessageItem(
      message: message,
      isSelf: message.senderId == "myId",
    );
  },
  enablePullDown: false, // 关闭下拉刷新(倒序列表无需下拉)
  enablePullUp: true, // 上拉加载更早的消息
  // 自定义加载更多组件
  loadMoreWidget: Container(
    height: 40,
    alignment: Alignment.center,
    child: const Text("加载更早的消息...", style: TextStyle(fontSize: 13, color: Color(0xFF999999))),
  ),
  padding: const EdgeInsets.symmetric(vertical: 8),
  itemSpacing: 8,
  physics: const ClampingScrollPhysics(), // 禁止弹性滚动
  adaptDarkMode: true,
);

五、核心封装技巧(复用成熟设计思路)

  1. 分页逻辑统一:内置页码管理、数据追加 / 重置逻辑,外部仅需传入数据请求回调,无需关心分页细节
  2. 状态自动切换 :通过 ListLoadStatus 枚举管理四种核心状态,根据数据请求结果自动切换,无需外部手动判断
  3. 防止重复请求 :通过 _isLoading 标志位禁止加载中时重复请求,避免接口压力与数据混乱
  4. 组件插槽化:加载更多、空态、错误态组件支持自定义,兼顾通用性与个性化,适配不同设计需求
  5. 交互细节优化:上拉加载提前 200px 触发,提升用户体验;下拉刷新保证动画完整,避免视觉卡顿

六、避坑指南(解决 90% 开发痛点)

  1. 数据请求规范fetchData 回调需返回 Future<List<T>>,分页逻辑需与后端一致(页码从 1 开始或 0 开始)
  2. hasMore 配置 :默认通过 "返回数据长度 == pageSize" 判断是否有更多数据,若后端返回数据不足一页但仍有更多数据,需自定义 hasMore 回调
  3. 列表项高度:列表项高度建议固定或自适应,避免动态高度导致的滚动抖动;长文本建议限制行数
  4. 空态与错误态:空态组件、错误态组件需设置足够高度(如屏幕高度 - 导航栏高度),确保居中显示
  5. 性能优化 :列表项建议使用 const 构造函数(静态内容),避免频繁重建;数据量较大时建议使用 ListView.builder 而非 List.generate
  6. 倒序列表注意 :倒序列表(如聊天记录)需关闭下拉刷新、启用上拉加载,且数据追加后需滚动到底部,可通过 ScrollController 实现

https://openharmonycrossplatform.csdn.net/content

相关推荐
走在路上的菜鸟6 小时前
Android学Dart学习笔记第十七节 类-成员方法
android·笔记·学习·flutter
走在路上的菜鸟6 小时前
Android学Dart学习笔记第十八节 类-继承
android·笔记·学习·flutter
巴拉巴拉~~7 小时前
Flutter 通用列表刷新加载组件 CommonRefreshList:下拉刷新 + 上拉加载 + 状态适配
前端·javascript·flutter
走在路上的菜鸟7 小时前
Android学Dart学习笔记第十九节 类-混入Mixins
android·笔记·学习·flutter
ujainu小7 小时前
Flutter file_selector 插件:跨平台文件交互完全指南
flutter
爱吃大芒果7 小时前
Flutter 列表优化:ListView 性能调优与复杂列表实现
开发语言·hive·hadoop·flutter·华为
ujainu7 小时前
Flutter与DevEco混合开发:跨端状态同步简易指南
flutter·deveco studio
小a杰.7 小时前
Flutter工程化与协作实践指南
flutter
巴拉巴拉~~8 小时前
Flutter 通用按钮组件 CommonButtonWidget:多样式 + 多状态 + 交互优化
javascript·flutter·交互