目录
- 一、核心依赖配置
- 二、配置本地模拟服装API
-
- [1. 编写本地接口代码](#1. 编写本地接口代码)
- [2. 启动本地接口服务](#2. 启动本地接口服务)
- 三、API层:分页请求封装
- 四、页面层:核心逻辑实现
- 五、测试与验证
-
- [1. 下拉刷新 + 数据加载提示](#1. 下拉刷新 + 数据加载提示)
- [2. 上拉加载 + 数据加载提示](#2. 上拉加载 + 数据加载提示)
本文基于开源鸿蒙跨平台开发框架,针对本地服装清单应用的列表功能进行扩展优化。完成列表下拉刷新、上拉加载、数据加载提示三大能力实现,并在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

因为这次定义了分页逻辑,所以只能显示前10条数据,在浏览器地址栏输入http://localhost:3000/api/localCloth/list?page=2,才能够显示后续数据。
三、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

