04-06 Flutter列表清单实现上拉加载 + 下拉刷新 + 数据加载提示 On OpenHarmony

目录

本文基于开源鸿蒙跨平台开发框架,针对本地服装清单应用的列表功能进行扩展优化。完成列表下拉刷新、上拉加载、数据加载提示三大能力实现,并在DevEco Studio上运行验证。通过Dio封装分页API请求(cloth_api.dart)、pull_to_refresh实现下拉交互、infinite_scroll_pagination管理上拉分页逻辑,结合状态驱动UI设计加载提示;最终通过在DevEco Studio(模拟器)上验证功能兼容性。

一、核心依赖配置

infinite_scroll_pagination: 解决分页逻辑的核心,避免手动计算页码。

pull_to_refresh (或 easy_refresh): 提供美观的下拉动效。

dio: 网络请求。

bash 复制代码
flutter pub add infinite_scroll_pagination:^4.0.0
bash 复制代码
flutter pub add easy_refresh
bash 复制代码
flutter pub get

二、配置本地模拟服装API

1. 编写本地接口代码

在local_cloth_api文件夹中优化server.js文件,编写以下代码,添加一些新的数据:

bash 复制代码
// 顶部引入cors
const cors = require('cors');
// 导入Express
const express = require('express');
const app = express();
// 配置静态资源目录:让public文件夹里的文件可以通过HTTP访问
app.use(express.static('public'));
// 启用CORS(允许所有域名访问,测试用)
app.use(cors());
// 定义端口(可自定义,比如3000)
const port = 3000;
 
// 模拟服装列表数据
const mockClothData = [
  {
    id: 1,
    name: "WalkinDark【莲礼枝】",
    desc: "中式古典紫鸢花吊带连衣裙提花收腰开衫套装",
    image: "/cloth1.jpg", // 对应public里的cloth1.jpg
    score: 4.8
  },
  {
    id: 2,
    name: "男长袖秋冬新款蓝色休闲衬衣",
    desc: "[纯棉]Navigare意大利小帆船磨毛衬衫",
    image: "/cloth2.jpg",
    score: 4.9
  },
  {
    id: 3,
    name: "女装冬加厚连帽羽绒外套",
    desc: "【迪丽热巴同款】骆驼羽神PRO鹅绒羽绒服",
    image: "/cloth3.jpg",
    score: 4.5
  },
  {
    id: 4,
    name: "WASSUPNURAYA马上有钱红色毛衣男女款",
    desc: "2026新年马年本命年衣服",
    image: "/cloth4.jpg",
    score: 4.5
  },
  {
    id: 5,
    name: "2026秋冬装新款男中长款风衣",
    desc: "NASA STUSY美式复古,潮牌过膝大衣",
    image: "/cloth5.jpg",
    score: 5.0
  },
  {
    id: 6,
    name: "牛仔衬衫女",
    desc: "2026春新款慵懒宽松休闲外套",
    image: "/cloth6.jpg",
    score: 4.8
  },
  {
    id: 7,
    name: "西装外套",
    desc: "莱伦斯布黑色,女职场穿搭双排扣西服套装国考面试职业正装",
    image: "/cloth7.jpg",
    score: 4.7
  },
  {
    id: 8,
    name:"真皮皮衣男夹克",
    desc:"软皮秋冬翻领加绒中老年爸爸海宁绵羊皮外套",
    image:"/cloth8.jpg",
    score: 4.0
  },
  {
    id: 9,
    name:"V领针织马甲背心女",
    desc:"2025秋季高级感叠穿无袖毛衣坎肩马夹",
    image:"/cloth9.jpg",
    score: 4.0
  },
  {
    id: 10,
    name:"棒球服夹克",
    desc:"安踏好事发生丨马年新年红色,",
    image:"/cloth10.jpg",
    score: 5.0
  },
  {
    id: 11,
    name:"针织衫",
    desc:"GXG男装 亨利领简约毛衣休闲通勤",
    image:"/cloth11.jpg",
    score: 4.5
  },
  {
    id: 12,
    name:"皮夹克皮毛一体皮草外套冬",
    desc:"貂博士水貂整貂皮大衣真皮衣男短款翻领",
    image:"/cloth12.jpg",
    score: 4.3
  }
];
 
// 定义服装列表接口:GET请求,路径为/api/localCloth/list
app.get('/api/localCloth/list', (req, res) => {
  // 1. 获取前端传递的分页参数(默认page=1,pageSize=10)
  const page = parseInt(req.query.page) || 1;
  const pageSize = parseInt(req.query.pageSize) || 10;  // 统一为pageSize(驼峰)
 
  // 2. 计算分页的"起始/结束索引",对mock数据切片
  const startIndex = (page - 1) * pageSize;
  const endIndex = startIndex + pageSize;
  // const paginatedClothList = mockClothData.slice(startIndex, endIndex);
  const paginatedClothList = [];
  // 循环取数:当索引超过mock数据长度时,用"取模"循环重复数据
  for (let i = startIndex; i < endIndex; i++) {
    const loopIndex = i % mockClothData.length; // 取模实现循环
    paginatedClothList.push(mockClothData[loopIndex]);
  }
  // 3. 返回"包含clothList字段的对象"(前端要这个格式)
  res.json({
    clothList: paginatedClothList // 必须是"对象包裹数组"
  });
});
 
// 启动服务
app.listen(port, () => { // 改为用port变量
  console.log(`Server running on http://localhost:${port}`);
});

2. 启动本地接口服务

在local_cloth_api文件夹的终端中执行以下命令:

bash 复制代码
node server.js
  1. 浏览器访问http://localhost:3000/api/localCloth/list验证接口数据

因为这次定义了分页逻辑,所以只能显示前10条数据,在浏览器地址栏输入http://localhost:3000/api/localCloth/list?page=2,才能够显示后续数据。![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/f690063253484cd48b616a2f4008f0c4.png)

三、API层:分页请求封装

四、页面层:核心逻辑实现

逻辑模块 核心职责 关键代码点
下拉刷新 重置状态,重新拉取第一页 _pagingController.refresh()
上拉加载 计算页码,追加数据 _fetchPage(pageKey)
状态管理 处理 Loading/Error/Empty PagingState 的构建

1.状态变量定义

状态 UI 表现 用户操作
Idle 显示列表 可以上下滑动
Refreshing 顶部出现转圈 + "刷新中" 等待数据返回
Loading More 底部出现 "加载中..." 自动触发或滑动触发
No More 底部出现 "已加载全部"
bash 复制代码
class _ClothListPageState extends State<ClothListPage> {
  List<ClothModel> _clothList = [];
  final RefreshController _refreshController = RefreshController(initialRefresh: false);
  // 分页参数与加载状态
  final int _pageSize = 6;  // 每页加载的数据量:6条数据
  bool _isLoading = false;   // 加载锁(防止重复请求,如快速下拉/上拉)
  // 无限分页核心控制器,初始为1
  final PagingController<int, ClothModel> _pagingController = PagingController(firstPageKey: 1);
  @override
  void initState() {
    super.initState();
    _getClothListData(isRefresh: true); // 初始化加载数据,初始化时执行「下拉刷新」逻辑(加载第1页数据)
      // 新增:绑定上拉分页监听 - 滑到底部自动触发加载下一页,核心逻辑
      _pagingController.addPageRequestListener((pageKey) {
      _fetchPage(pageKey);
    });
  }
   // ...后续方法实现
}

2.下拉刷新逻辑实现

bash 复制代码
// 获取服装数据(新增isRefresh参数,区分刷新/加载)
Future<void> _getClothListData({required bool isRefresh}) async {    // _getClothListData 方法
  // 加载锁:防止重复请求
  if (_isLoading) return;
  _isLoading = true;
  try {
    if(isRefresh){      // 下拉刷新时:重置页码、数据、状态
      _pagingController.refresh(); // 重置分页控制器,清空历史分页状态
      await Future.delayed(const Duration(seconds: 2)); // 下拉刷新→加载中 停留2秒
    } else {
      await Future.delayed(const Duration(seconds: 2));// 上拉加载时加延迟(延长"加载中..."显示时间)
    }
    // 调用API层分页请求,下拉刷新永远请求第1页
    final data = await ClothApi.getClothList(
      page: 1, // 下拉刷新永远请求第1页
      pageSize: _pageSize,
    );
    if (mounted) {
      if (data is Map<String, dynamic>) {    // 先判断data是否是List类型
        // 解析后端返回的Map格式数据(适配后端结构)
        final clothListModel = ClothListModel.fromJson(data);
        setState(() {
          if (isRefresh) {       
            _clothList = clothListModel.clothList;   // 下拉刷新:替换原有数据
            } else {
            _clothList.addAll(clothListModel.clothList); // 上拉加载:追加新数据
            }
        });
        // 新增:下拉刷新成功后,给分页控制器赋值第一页数据
        _pagingController.value = PagingState(// 更新分页控制器:设置第一页数据与下一页页码
          nextPageKey: clothListModel.clothList.length >= _pageSize ? 2 : null,
          itemList: clothListModel.clothList,
        );
      } else {
        throw Exception("接口返回数据格式错误,不是对象格式");
      }
    }
  } catch (e) {
    if (mounted) {// 加载失败:显示错误提示
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text("加载失败:${e.toString()}")),
      );
    }
  } finally {
      _isLoading = false;
      if (isRefresh) {// 下拉刷新成功提示
        Future.delayed(const Duration(seconds: 2), () {
          if (mounted) {
            _refreshController.refreshCompleted();// 结束下拉刷新动画(必须调用,否则刷新状态不会重置)
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text("服装列表加载成功")),
            );
          }
        });
      }
  }
}

3.上拉加载逻辑实现

bash 复制代码
// 上拉加载更多专用方法(pageKey为当前要加载的页码)
  // 新增:infinite_scroll_pagination 专用分页请求方法
  Future<void> _fetchPage(int pageKey) async {
    if (_isLoading) return;
    _isLoading = true;
    try {
      await Future.delayed(const Duration(seconds: 2));// 延长加载提示显示
      final data = await ClothApi.getClothList(  // 调用API层,请求当前页码数据
        page: pageKey,
        pageSize: _pageSize,
      );
      if (mounted && data is Map<String, dynamic>) {
        final clothListModel = ClothListModel.fromJson(data);
        final newItems = clothListModel.clothList;
        // 判断是否为最后一页(返回数据量 < 每页大小 → 无更多数据)
        final isLastPage = newItems.length < _pageSize;
        if (isLastPage) {
          // 无更多数据:通知分页控制器停止上拉加载
          _pagingController.appendLastPage(newItems);
        } else {
          // 有更多数据,自动加载下一页,页码+1,无限上拉核心逻辑
          final nextPageKey = pageKey + 1;
          _pagingController.appendPage(newItems, nextPageKey);
        }
      }
    } catch (e) {
      // 加载失败:通知分页控制器显示失败提示
      _pagingController.error = e;
      if(mounted){
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text("加载失败:${e.toString()}")),
        );
      }
    } finally {
      _isLoading = false;
    }
  }

4.数据加载提示实现

bash 复制代码
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text("本地服装清单"), centerTitle: true),
    body: SmartRefresher(    // 下拉刷新容器(pull_to_refresh组件)
      controller: _refreshController,
      // 下拉刷新:调用带isRefresh=true的请求
      onRefresh: () => _getClothListData(isRefresh: true),  // 绑定下拉刷新
      // 自定义下拉头部(实现"刷新中/刷新完成"提示 + 颜色/字体优化)
      header: CustomHeader(
        builder: (context, mode) {
          String headerText = "";
          Color textColor = Colors.black87; // 初始文字颜色
          // 根据刷新状态切换文案和颜色
          if (mode == RefreshStatus.refreshing) {
            headerText = "刷新中"; // 刷新中显示"刷新中"
            textColor = Colors.blueAccent; // 加载中用橙色,更醒目
          } else if (mode == RefreshStatus.completed) {
            headerText = "刷新完成"; // 刷新完成显示"刷新完成"
            textColor = Colors.grey[600]!;  // 刷新完成用绿色,更友好
          } else {
            headerText = "下拉刷新"; // 初始状态显示"下拉刷新"
            textColor = Colors.grey[600]!;
          }
          return Container(
            height: 60,
            alignment: Alignment.center,
            child: Text(headerText,          // 给Text添加style,应用textColor和字体样式
              style: TextStyle(
                color: textColor, // 应用定义的文字颜色
                fontSize: 16, // 加大字号,更醒目
                fontWeight: FontWeight.w400, // 加粗字体
              ),
            ), // 显示对应的提示文本
          );
        },
      ),
      // 上拉分页列表(infinite_scroll_pagination组件)
      child: PagedListView<int, ClothModel>(
        pagingController: _pagingController,
          builderDelegate: PagedChildBuilderDelegate<ClothModel>(
            // 列表项UI(复用原有方法)
            itemBuilder: (context, item, index) => _buildClothItem(item),
            // 上拉加载中提示(转圈+文字)
            newPageProgressIndicatorBuilder: (_) => Container(
              height: 60,
              alignment: Alignment.center,
              child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: const [
                  CircularProgressIndicator(strokeWidth: 2),
                  SizedBox(width: 8),
                  Text("加载中..."),
                ],
              ),
            ),
            noMoreItemsIndicatorBuilder: (_) => Container(   // 无更多数据提示
              height: 60, 
              alignment: Alignment.center,
              child: const Text("已加载全部数据"),
            ),
            // 初始化加载失败提示
            firstPageErrorIndicatorBuilder: (_) => Container(
              height: 60,
              alignment: Alignment.center,
              child: const Text("加载失败,下拉重试"),
            ),
            // 上拉加载失败提示
            newPageErrorIndicatorBuilder: (_) => Container(
              height: 60,
              alignment: Alignment.center,
              child: const Text("加载失败,上拉重试"),
            ),
          ),
        ),
      ),
  );
}

5.模型层返回数据

bash 复制代码
// cloth_model.dart
import 'package:json_annotation/json_annotation.dart';
part 'cloth_model.g.dart'; // 关联自动生成的解析代码文件

@JsonSerializable()
class ClothModel {
  final int? id;
  final String? name; // 对应接口的name
  final String? desc; // 对应接口的desc
  final String? image; // 对应接口的image
  final double? score; // 服装评分(比如4.5、3.8)
  ClothModel({
    this.id,
    this.name,
    this.desc,
    this.image,
    this.score, // 构造函数添加score
  });
  // 自动生成的JSON转模型方法(由g.dart实现)
  factory ClothModel.fromJson(Map<String, dynamic> json) => _$ClothModelFromJson(json);
  // 自动生成的模型转JSON方法(由g.dart实现)
  Map<String, dynamic> toJson() => _$ClothModelToJson(this);
}

// 服装列表模型
class ClothListModel {
  final List<ClothModel> clothList;
  ClothListModel({required this.clothList});
  // 列表数据解析(简化格式),从JSON数组构建列表模型
  factory ClothListModel.fromJson(Map<String, dynamic> json) {
    List<ClothModel> list = [];   // 声明list变量
    if (json['clothList'] != null && json['clothList'] is List) {  // 逻辑与用&&,判断clothList存在且是List类型
      list = (json['clothList'] as List)
          .map((e) => ClothModel.fromJson(e as Map<String, dynamic>))
          .toList();
    }
    return ClothListModel(clothList: list);
  }
}

五、测试与验证

1. 下拉刷新 + 数据加载提示


2. 上拉加载 + 数据加载提示

欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net

相关推荐
程序员Ctrl喵16 小时前
异步编程:Event Loop 与 Isolate 的深层博弈
开发语言·flutter
前端不太难17 小时前
Flutter 如何设计可长期维护的模块边界?
flutter
小蜜蜂嗡嗡18 小时前
flutter列表中实现置顶动画
flutter
始持19 小时前
第十二讲 风格与主题统一
前端·flutter
始持19 小时前
第十一讲 界面导航与路由管理
flutter·vibecoding
始持19 小时前
第十三讲 异步操作与异步构建
前端·flutter
新镜19 小时前
【Flutter】 视频视频源横向、竖向问题
flutter
黄林晴20 小时前
Compose Multiplatform 1.10 发布:统一 Preview、Navigation 3、Hot Reload 三箭齐发
android·flutter
Swift社区20 小时前
Flutter 应该按功能拆,还是按技术层拆?
flutter
肠胃炎21 小时前
树形选择器组件封装
前端·flutter