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

相关推荐
小风呼呼吹儿2 小时前
Flutter 框架跨平台鸿蒙开发 - 书法印章制作记录应用开发教程
flutter·华为·harmonyos
●VON2 小时前
从系统亮度监听到 UI 重绘:Flutter for OpenHarmony TodoList 深色模式的端到端响应式实现
学习·flutter·ui·openharmony·布局·von
恋猫de小郭2 小时前
Android Gradle Plugin 9.0 发布,为什么这会是个史诗级大坑版本
android·flutter·ios·开源
一起养小猫2 小时前
Flutter实战:从零实现俄罗斯方块(三)交互控制与事件处理
javascript·flutter·交互
Whisper_Sy3 小时前
Flutter for OpenHarmony移动数据使用监管助手App实战 - 周报告实现
开发语言·javascript·网络·flutter·php
一起养小猫3 小时前
Flutter for OpenHarmony 实战:按钮类 Widget 完全指南
前端·javascript·flutter
一起养小猫3 小时前
Flutter实战:从零实现俄罗斯方块(二)CustomPaint绘制游戏画面
flutter·游戏
2601_949575864 小时前
Flutter for OpenHarmony二手物品置换App实战 - 本地存储实现
flutter
向前V4 小时前
Flutter for OpenHarmony轻量级开源记事本App实战:笔记编辑器
开发语言·笔记·python·flutter·游戏·开源·编辑器