在 Flutter 开发中,卡片(Card)是展示列表数据的核心组件,分页则是处理大量数据加载的关键需求。本文将补充 通用卡片封装 和 两种常用分页实现方案(列表分页、滚动加载),提供可直接复制的代码,适配鸿蒙应用开发中的数据展示场景。
一、为什么要封装?
在开发初期,我们可能会写出这样的代码:
Dart
// 未经封装的列表
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text(item.description),
SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => onEdit(item),
child: Text('编辑'),
),
TextButton(
onPressed: () => onDelete(item),
child: Text('删除'),
),
],
),
],
),
),
);
},
)
这样的代码在只有一个列表时还能接受,但当你的应用中有多个列表,且每个列表的卡片样式、交互逻辑都有所不同时,问题就会暴露出来:
- 代码重复 :每个列表都要写一遍
Card、Padding、Column等结构。 - 维护困难:如果需要统一修改所有卡片的圆角、边距或阴影,你需要找到所有使用的地方并逐一修改。
- 可读性差 :
itemBuilder中的代码会变得臃肿,难以快速理解列表项的结构。 - 逻辑混杂 :UI 布局代码和业务逻辑(如
onEdit,onDelete)混在一起。
封装的好处:
- 代码复用:封装后的组件可以在项目任何地方通过一行代码调用。
- 单一职责 :每个组件只负责一件事(如
CustomCard只负责卡片的样式)。 - 易于维护:修改组件内部实现,所有引用它的地方都会自动更新。
- 提高可读性 :
PagedListView<MyItem>这样的代码一看就知道是一个分页列表。 - 便于测试:独立的组件更容易进行单元测试。
二、列表卡片封装
1、核心组件封装-通用卡片
通用卡片是所有列表项的基础。它应该提供一些常用的自定义属性,如边距、内边距、圆角、阴影等。
Dart
import 'package:flutter/material.dart';
class CustomCard extends StatelessWidget {
/// 卡片的子组件
final Widget child;
/// 卡片的外边距
final EdgeInsetsGeometry margin;
/// 卡片的内边距
final EdgeInsetsGeometry padding;
/// 卡片的圆角半径
final double borderRadius;
/// 卡片的阴影高度
final double elevation;
/// 卡片的背景色
final Color? color;
/// 卡片点击事件
final VoidCallback? onTap;
const CustomCard({
super.key,
required this.child,
this.margin = const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
this.padding = const EdgeInsets.all(16.0),
this.borderRadius = 12.0,
this.elevation = 2.0,
this.color,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
margin: margin,
elevation: elevation,
color: color ?? Theme.of(context).cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadius),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(borderRadius),
child: Padding(
padding: padding,
child: child,
),
),
);
}
}
封装思路解析:
child: 必传参数,是卡片内部要展示的具体内容。margin和padding: 提供默认值,覆盖了大部分场景,同时允许用户根据需要修改。borderRadius,elevation,color: 这些都是Card组件的常用属性,我们将它们暴露出来,并提供合理的默认值。onTap: 为卡片添加点击事件的能力,并使用InkWell包裹,以提供 Material Design 的水波纹反馈。Theme.of(context).cardColor: 使用主题颜色,让卡片更好地融入应用的整体风格。
2、列表项封装
有了通用卡片,我们就可以基于它来封装具体的列表项了。假设我们有一个 Article 模型。
Dart
class Article {
final String id;
final String title;
final String author;
final String date;
final String excerpt;
Article({
required this.id,
required this.title,
required this.author,
required this.date,
required this.excerpt,
});
}
现在,我们来封装一个 ArticleListItem。
Dart
import 'package:flutter/material.dart';
import 'package:your_app_name/models/article.dart';
import 'package:your_app_name/widgets/custom_card.dart';
class ArticleListItem extends StatelessWidget {
final Article article;
final VoidCallback? onTap;
const ArticleListItem({
super.key,
required this.article,
this.onTap,
});
@override
Widget build(BuildContext context) {
return CustomCard(
onTap: onTap,
// 可以在这里覆盖 CustomCard 的默认属性
// margin: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
article.title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Row(
children: [
Icon(Icons.person, size: 16, color: Colors.grey[600]),
SizedBox(width: 4),
Text(
article.author,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
SizedBox(width: 16),
Icon(Icons.calendar_today, size: 16, color: Colors.grey[600]),
SizedBox(width: 4),
Text(
article.date,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
SizedBox(height: 8),
Text(
article.excerpt,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
封装思路解析:
article: 必传参数,接收一个Article模型对象。onTap: 将点击事件暴露出去,由父组件(通常是列表)决定点击后要做什么(如跳转到详情页)。- 组合优于继承 :
ArticleListItem内部使用了CustomCard,而不是继承它。这是 Flutter 中更推荐的做法,更加灵活。 - 使用主题 :
Theme.of(context).textTheme确保了文本样式与应用主题保持一致。 - 细节处理 :
maxLines和overflow确保了在内容过长时 UI 不会错乱。
三、分页逻辑封装
分页的核心逻辑通常包括:
- 当前页码 (
page) - 每页加载数量 (
pageSize) - 数据列表 (
items) - 是否正在加载 (
isLoading) - 是否还有更多数据 (
hasMoreData) - 加载数据的方法 (
fetchData()) - 重置数据的方法 (
reset())
这个逻辑与具体的 UI 组件无关,非常适合用一个 ViewModel 或者 ChangeNotifier 来封装。这里我们使用 flutter_hooks 和 ValueNotifier 来实现一个简单的、响应式的 ViewModel。如果你不使用 flutter_hooks,也可以用 ChangeNotifier 来实现类似的效果。
首先,在 pubspec.yaml 添加依赖:
Dart
dependencies:
flutter_hooks: ^0.18.6
Dart
import 'package:flutter/foundation.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
typedef DataFetcher<T> = Future<List<T>> Function(int page, int pageSize);
class PaginationViewModel<T> {
final ValueNotifier<List<T>> items = ValueNotifier([]);
final ValueNotifier<bool> isLoading = ValueNotifier(false);
final ValueNotifier<bool> hasMoreData = ValueNotifier(true);
final ValueNotifier<Exception?> error = ValueNotifier(null);
final int pageSize;
int _currentPage = 1;
final DataFetcher<T> dataFetcher;
PaginationViewModel({
required this.dataFetcher,
this.pageSize = 10,
});
Future<void> fetchData() async {
if (isLoading.value || !hasMoreData.value) return;
isLoading.value = true;
error.value = null;
try {
final newItems = await dataFetcher(_currentPage, pageSize);
// 如果新获取的数据为空或不足一页,说明没有更多数据了
if (newItems.isEmpty || newItems.length < pageSize) {
hasMoreData.value = false;
}
// 将新数据添加到现有列表中
items.value = [...items.value, ...newItems];
_currentPage++;
} on Exception catch (e) {
error.value = e;
if (kDebugMode) {
print('Error fetching data: $e');
}
} finally {
isLoading.value = false;
}
}
void reset() {
_currentPage = 1;
items.value = [];
hasMoreData.value = true;
// error 和 isLoading 会在下次 fetchData 时被重置
}
// 为了在 HookWidget 中方便地管理生命周期,我们可以提供一个 hook
static PaginationViewModel<T> use<T>({
required DataFetcher<T> dataFetcher,
int pageSize = 10,
bool autoFetch = true,
}) {
final viewModel = useMemoized(() => PaginationViewModel<T>(
dataFetcher: dataFetcher,
pageSize: pageSize,
));
useEffect(() {
if (autoFetch) {
viewModel.fetchData();
}
// 清理函数,虽然这里不需要,但保持习惯
return () {};
}, [viewModel]);
return viewModel;
}
}
封装思路解析:
- 泛型
T: 使这个 ViewModel 可以处理任何类型的数据(如Article,Product等)。 DataFetcher<T>: 这是一个函数签名,要求使用者提供一个根据page和pageSize来获取数据的异步函数。这使得 ViewModel 与具体的数据来源(API、数据库)解耦。- 响应式状态 : 使用
ValueNotifier来持有状态,当状态变化时,UI 可以自动重建。 fetchData(): 核心方法,负责处理加载状态、调用dataFetcher、更新数据、处理错误和判断是否还有更多数据。reset(): 用于下拉刷新等场景,重置所有状态,以便重新开始加载。use<T>()Hook : 这是一个非常优雅的设计,它将PaginationViewModel的创建、初始化(autoFetch)和生命周期管理都封装在了一个自定义 Hook 中,让HookWidget的使用变得极其简洁。
四、高级列表封装
现在,我们将前面封装好的 CustomCard, ArticleListItem (代表具体的列表项), 和 PaginationViewModel 组合起来,打造一个终极的 PagedListView 组件。
Dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:your_app_name/view_models/pagination_view_model.dart';
class PagedListView<T> extends HookWidget {
final DataFetcher<T> dataFetcher;
final Widget Function(T item) itemBuilder;
final int pageSize;
final Widget? header;
final Widget? footer;
const PagedListView({
super.key,
required this.dataFetcher,
required this.itemBuilder,
this.pageSize = 10,
this.header,
this.footer,
});
@override
Widget build(BuildContext context) {
final viewModel = PaginationViewModel.use<T>(
dataFetcher: dataFetcher,
pageSize: pageSize,
);
return RefreshIndicator(
onRefresh: () async {
viewModel.reset();
await viewModel.fetchData();
},
child: ListView.builder(
itemCount: _calculateItemCount(viewModel),
itemBuilder: (context, index) {
// 1. 构建 Header
if (header != null && index == 0) {
return header!;
}
// 调整索引,因为 header 占了一个位置
final dataIndex = header != null ? index - 1 : index;
// 2. 构建数据项
if (dataIndex < viewModel.items.value.length) {
return itemBuilder(viewModel.items.value[dataIndex]);
}
// 3. 构建加载指示器或 "没有更多" 提示
else {
// 如果出错了,显示错误信息和重试按钮
if (viewModel.error.value != null) {
return _buildErrorWidget(context, viewModel);
}
// 如果还有更多数据,显示加载指示器
else if (viewModel.hasMoreData.value) {
return _buildLoadingIndicator();
}
// 如果没有更多数据且列表不为空,显示提示
else if (viewModel.items.value.isNotEmpty) {
return _buildNoMoreDataWidget();
}
// 如果列表为空且没有更多数据(初始加载为空)
else {
return _buildEmptyStateWidget();
}
}
},
controller: useScrollController()
..addListener(() {
final position = useScrollController().position;
if (position.pixels >= position.maxScrollExtent - 200 &&
!viewModel.isLoading.value &&
viewModel.hasMoreData.value) {
viewModel.fetchData();
}
}),
),
);
}
int _calculateItemCount(PaginationViewModel<T> viewModel) {
int count = viewModel.items.value.length;
// 如果正在加载或还有更多数据,或者出错了,需要多一个位置来显示 footer
if (viewModel.isLoading.value || viewModel.hasMoreData.value || viewModel.error.value != null) {
count++;
}
// 如果有 header,也需要加 1
if (header != null) {
count++;
}
return count;
}
Widget _buildLoadingIndicator() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Center(child: CircularProgressIndicator()),
);
}
Widget _buildNoMoreDataWidget() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Center(child: Text('没有更多数据了')),
);
}
Widget _buildEmptyStateWidget() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox, size: 64, color: Colors.grey[400]),
SizedBox(height: 16),
Text(
'列表为空',
style: TextStyle(fontSize: 18, color: Colors.grey[600]),
),
SizedBox(height: 8),
Text(
'暂无相关数据',
style: TextStyle(color: Colors.grey[500]),
),
],
),
),
);
}
Widget _buildErrorWidget(BuildContext context, PaginationViewModel<T> viewModel) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'加载失败: ${viewModel.error.value?.toString()}',
style: TextStyle(color: Colors.red),
),
TextButton(
onPressed: viewModel.fetchData,
child: Text('重试'),
),
],
),
),
);
}
}
封装思路解析:
- 泛型
T: 同样是泛型设计,使其具有通用性。 dataFetcher: 接收数据获取逻辑。itemBuilder: 这是一个关键的回调函数,它接收列表中的一个数据项T,并返回一个Widget。这使得PagedListView完全不关心列表项的具体 UI,只负责管理列表的骨架和分页逻辑。header和footer: 提供了添加头部和底部组件的灵活性。RefreshIndicator: 内置了下拉刷新功能,调用viewModel.reset()和fetchData()。ListView.builder: 核心列表组件。- 复杂的
itemBuilder:- 索引计算 : 由于可能存在
header,需要对索引进行调整。 - 多状态处理 : 根据
index和viewModel的状态(数据、加载、错误、空列表、无更多)来构建不同的 UI。这是整个组件最复杂也最强大的部分。
- 索引计算 : 由于可能存在
_calculateItemCount: 动态计算列表项总数。当需要显示加载指示器、错误信息或 "没有更多" 提示时,列表项总数会比数据列表长度多 1。useScrollController: 使用 Hook 来管理ScrollController,并监听滚动事件,实现滑动到底部自动加载更多。- 状态 UI 模板 :
_buildLoadingIndicator,_buildEmptyStateWidget等方法提供了美观的默认状态 UI,提升了用户体验。
五、完整示例与使用指南
现在,让我们看看如何在一个页面中使用我们精心封装好的 PagedListView。
模拟API:
Dart
import 'package:your_app_name/models/article.dart';
import 'dart:math';
class ApiService {
static Future<List<Article>> fetchArticles(int page, int pageSize) async {
// 模拟网络延迟
await Future.delayed(Duration(seconds: 1));
// 模拟一个错误
if (Random().nextDouble() < 0.2 && page > 1) {
throw Exception('网络错误,请重试');
}
List<Article> articles = [];
final start = (page - 1) * pageSize;
final end = start + pageSize;
// 模拟没有更多数据
if (start >= 50) {
return articles;
}
for (int i = start; i < end; i++) {
articles.add(Article(
id: i.toString(),
title: '这是第 ${i + 1} 篇文章的标题',
author: '作者 ${i % 5 + 1}',
date: '2024-0${i % 12 + 1}-${i % 28 + 1}',
excerpt: '这是文章的摘要部分,用于在列表中预览内容。这是一个很长的摘要,以测试文本截断功能。' * (i % 2 + 1),
));
}
return articles;
}
}
具体使用:
Dart
import 'package:flutter/material.dart';
import 'package:your_app_name/models/article.dart';
import 'package:your_app_name/services/api_service.dart';
import 'package:your_app_name/widgets/article_list_item.dart';
import 'package:your_app_name/widgets/paged_list_view.dart';
class ArticleListPage extends StatelessWidget {
const ArticleListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('文章列表'),
),
body: PagedListView<Article>(
dataFetcher: ApiService.fetchArticles,
itemBuilder: (article) {
return ArticleListItem(
article: article,
onTap: () {
// 导航到详情页
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('点击了: ${article.title}')),
);
},
);
},
header: Container(
padding: EdgeInsets.all(16),
color: Colors.blue[50],
child: Text(
'最新文章',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
),
);
}
}
使用指南总结:
- 准备数据模型 :如
Article。 - 实现数据获取 :如
ApiService.fetchArticles,它需要符合DataFetcher<Article>的签名。 - 封装列表项 :如
ArticleListItem,它接收一个Article并返回一个Widget。 - 使用
PagedListView:在你的页面中,只需调用PagedListView<Article>,并传入dataFetcher和itemBuilder即可。
看看最终的代码有多简洁!ArticleListPage 的 build 方法非常清晰,它只关心 "我要展示文章列表",而不关心 "列表如何分页"、"加载时显示什么"、"出错了怎么办" 这些复杂的细节。
六、总结与扩展
本文详细介绍了从基础卡片到高级分页列表的完整封装过程。我们遵循了单一职责原则 和组合优于继承的思想,将 UI 组件和业务逻辑解耦,最终得到了几个高度灵活、可复用且易于维护的组件。
核心要点:
CustomCard: 封装通用样式。CustomListItem: 封装具体数据的展示。PaginationViewModel: 封装分页逻辑,与 UI 分离。PagedListView: 组合所有部分,提供一站式解决方案。- 泛型: 提高组件的通用性。
- 回调函数: 增强组件的灵活性和可配置性。
flutter_hooks: 简化状态管理和生命周期管理。
进一步扩展:
- 筛选和排序 : 可以在
PaginationViewModel中添加filter和sort参数,并在fetchData时应用它们。当筛选条件改变时,调用reset()重新加载数据。 - 缓存 : 结合
hive或sqflite等本地数据库,在PaginationViewModel中实现数据缓存策略,提高离线体验和加载速度。 - 骨架屏 (Skeleton Screen) : 在
CustomListItem中可以增加一个isLoading属性,当为true时显示一个骨架屏,而不是真实数据,提升加载时的用户体验。 - 动画: 为列表项的出现、消失添加动画效果。
- 依赖注入 : 对于更复杂的项目,可以使用
get_it或provider等依赖注入工具来管理ApiService和ViewModel的实例,而不是在组件内部直接new。