【2025】Flutter 卡片组件封装与分页功能实现:实战指南

在 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('删除'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  },
)

这样的代码在只有一个列表时还能接受,但当你的应用中有多个列表,且每个列表的卡片样式、交互逻辑都有所不同时,问题就会暴露出来:

  • 代码重复 :每个列表都要写一遍 CardPaddingColumn 等结构。
  • 维护困难:如果需要统一修改所有卡片的圆角、边距或阴影,你需要找到所有使用的地方并逐一修改。
  • 可读性差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: 必传参数,是卡片内部要展示的具体内容。
  • marginpadding: 提供默认值,覆盖了大部分场景,同时允许用户根据需要修改。
  • 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 确保了文本样式与应用主题保持一致。
  • 细节处理 : maxLinesoverflow 确保了在内容过长时 UI 不会错乱。

三、分页逻辑封装

分页的核心逻辑通常包括:

  • 当前页码 (page)
  • 每页加载数量 (pageSize)
  • 数据列表 (items)
  • 是否正在加载 (isLoading)
  • 是否还有更多数据 (hasMoreData)
  • 加载数据的方法 (fetchData())
  • 重置数据的方法 (reset())

这个逻辑与具体的 UI 组件无关,非常适合用一个 ViewModel 或者 ChangeNotifier 来封装。这里我们使用 flutter_hooksValueNotifier 来实现一个简单的、响应式的 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> : 这是一个函数签名,要求使用者提供一个根据 pagepageSize 来获取数据的异步函数。这使得 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,只负责管理列表的骨架和分页逻辑。
  • headerfooter: 提供了添加头部和底部组件的灵活性。
  • RefreshIndicator : 内置了下拉刷新功能,调用 viewModel.reset()fetchData()
  • ListView.builder: 核心列表组件。
  • 复杂的 itemBuilder :
    • 索引计算 : 由于可能存在 header,需要对索引进行调整。
    • 多状态处理 : 根据 indexviewModel 的状态(数据、加载、错误、空列表、无更多)来构建不同的 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),
          ),
        ),
      ),
    );
  }
}

使用指南总结:

  1. 准备数据模型 :如 Article
  2. 实现数据获取 :如 ApiService.fetchArticles,它需要符合 DataFetcher<Article> 的签名。
  3. 封装列表项 :如 ArticleListItem,它接收一个 Article 并返回一个 Widget
  4. 使用 PagedListView :在你的页面中,只需调用 PagedListView<Article>,并传入 dataFetcheritemBuilder 即可。

看看最终的代码有多简洁!ArticleListPagebuild 方法非常清晰,它只关心 "我要展示文章列表",而不关心 "列表如何分页"、"加载时显示什么"、"出错了怎么办" 这些复杂的细节。

六、总结与扩展

本文详细介绍了从基础卡片到高级分页列表的完整封装过程。我们遵循了单一职责原则组合优于继承的思想,将 UI 组件和业务逻辑解耦,最终得到了几个高度灵活、可复用且易于维护的组件。

核心要点:

  • CustomCard: 封装通用样式。
  • CustomListItem: 封装具体数据的展示。
  • PaginationViewModel: 封装分页逻辑,与 UI 分离。
  • PagedListView: 组合所有部分,提供一站式解决方案。
  • 泛型: 提高组件的通用性。
  • 回调函数: 增强组件的灵活性和可配置性。
  • flutter_hooks: 简化状态管理和生命周期管理。

进一步扩展:

  • 筛选和排序 : 可以在 PaginationViewModel 中添加 filtersort 参数,并在 fetchData 时应用它们。当筛选条件改变时,调用 reset() 重新加载数据。
  • 缓存 : 结合 hivesqflite 等本地数据库,在 PaginationViewModel 中实现数据缓存策略,提高离线体验和加载速度。
  • 骨架屏 (Skeleton Screen) : 在 CustomListItem 中可以增加一个 isLoading 属性,当为 true 时显示一个骨架屏,而不是真实数据,提升加载时的用户体验。
  • 动画: 为列表项的出现、消失添加动画效果。
  • 依赖注入 : 对于更复杂的项目,可以使用 get_itprovider 等依赖注入工具来管理 ApiServiceViewModel 的实例,而不是在组件内部直接 new
相关推荐
Bervin121381 小时前
Flutter Android环境的搭建
android·flutter
十六年开源服务商6 小时前
房地产WordPress系统最佳解决方案
开源
Days20508 小时前
童梦奇缘博客主题已发布
开源
fouryears_234179 小时前
现代 Android 后台应用读取剪贴板最佳实践
android·前端·flutter·dart
等你等了那么久10 小时前
Flutter国际化语言轻松搞定
flutter·dart
avi911113 小时前
发现一个宝藏Unity开源AVG框架,视觉小说的脚手架
unity·开源·框架·插件·tolua·avg
运维-大白同学13 小时前
2025最全面开源devops运维平台功能介绍
linux·运维·kubernetes·开源·运维开发·devops
神经蛙397115 小时前
settings.gradle' line: 22 * What went wrong: Plugin [id: 'org.jetbrains.kotlin.a
flutter
stringwu16 小时前
一个bug 引发的Dart 与 Java WeakReference 对比探讨
flutter