通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97
动漫卡片是微动漫App中使用最频繁的组件,首页、搜索、收藏、推荐等多个页面都在用。把它封装成独立组件,不仅减少重复代码,还能保证全App的视觉一致性。
这篇文章会详细讲解动漫卡片组件的封装思路,包括组件设计原则、参数设计、样式处理,以及如何让一个组件适应多种使用场景。

组件封装的意义
为什么要封装组件?
减少重复:同样的卡片样式在多个页面使用,不封装就要复制粘贴。
统一风格:修改卡片样式只需要改一处,全App生效。
易于维护:Bug 修复和功能增强集中在一个文件。
提高效率:使用时只需要传入数据,不用关心实现细节。
组件的基本结构
dart
import 'package:flutter/material.dart';
import '../models/anime.dart';
import '../screens/anime_detail_screen.dart';
class AnimeCard extends StatelessWidget {
final Anime anime;
final bool showRank;
const AnimeCard({super.key, required this.anime, this.showRank = false});
StatelessWidget 适合这种纯展示组件,没有内部状态需要管理。
anime 是必需参数,包含动漫的所有信息。showRank 是可选参数,控制是否显示排名。
参数设计原则
设计组件参数时要考虑:
必需 vs 可选:核心数据是必需的,样式控制是可选的。
默认值:可选参数要有合理的默认值,大多数情况下不需要传。
类型安全:用具体类型而不是 dynamic,编译时就能发现错误。
dart
class AnimeCard extends StatelessWidget {
final Anime anime; // 必需:动漫数据
final bool showRank; // 可选:是否显示排名
final VoidCallback? onTap; // 可选:自定义点击回调
final double? width; // 可选:自定义宽度
final double? height; // 可选:自定义高度
const AnimeCard({
super.key,
required this.anime,
this.showRank = false,
this.onTap,
this.width,
this.height,
});
这样设计后,简单使用只需要传 anime,复杂场景可以传更多参数。
卡片的整体布局
dart
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => AnimeDetailScreen(anime: anime)),
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
GestureDetector 处理点击事件,跳转到详情页。
Container 的 decoration 设置圆角和阴影,让卡片有立体感。
阴影的设计
dart
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
color 是阴影颜色,用黑色的 10% 透明度,不会太重。
blurRadius 是模糊半径,8 像素让阴影边缘柔和。
offset 是偏移量,向下偏移 4 像素,模拟光从上方照射。
阴影让卡片"浮"在背景上,增加层次感。
Stack 层叠结构
dart
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
_buildImage(),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: _buildInfoOverlay(),
),
if (showRank && anime.rank != null)
Positioned(
top: 8,
left: 8,
child: _buildRankBadge(),
),
],
),
),
),
);
}
ClipRRect 裁剪圆角,让图片也有圆角。
Stack 层叠三层:底层是图片,中间是信息遮罩,顶层是排名角标。
StackFit.expand 让 Stack 填满父容器。
图片层实现
dart
Widget _buildImage() {
final imageUrl = anime.imageUrl;
if (imageUrl == null || imageUrl.isEmpty) {
return Container(
color: Colors.grey[300],
child: const Center(child: Icon(Icons.movie, size: 40, color: Colors.grey)),
);
}
return Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
color: Colors.grey[300],
child: Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
errorBuilder: (context, error, stackTrace) {
print('❌ Image load error: $error');
return Container(
color: Colors.grey[300],
child: const Center(child: Icon(Icons.broken_image, size: 40, color: Colors.grey)),
);
},
);
}
图片加载处理三种状态:
空 URL:显示电影图标占位。
加载中:显示进度指示器。
加载失败:显示破图标。
BoxFit.cover 让图片填满容器,可能会裁剪边缘,但不会变形。
信息遮罩层
dart
Widget _buildInfoOverlay() {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withOpacity(0.9),
Colors.transparent,
],
),
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
anime.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
const SizedBox(height: 4),
_buildMetaRow(),
],
),
);
}
LinearGradient 从底部到顶部,从深色到透明。这样文字在深色背景上清晰可读。
mainAxisSize: MainAxisSize.min 让 Column 只占用需要的空间,不会撑满。
渐变遮罩的原理
dart
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withOpacity(0.9),
Colors.transparent,
],
),
渐变从底部开始是深色(0.9 透明度的黑色),到顶部变成完全透明。
这样底部的文字有足够的对比度,而图片的上半部分不受影响。
0.9 的透明度是经过测试的值,既能保证文字清晰,又不会让遮罩太重。
评分和集数
dart
Widget _buildMetaRow() {
return Row(
children: [
if (anime.score != null) ...[
const Icon(Icons.star, color: Colors.amber, size: 14),
const SizedBox(width: 2),
Text(
anime.score!.toStringAsFixed(1),
style: const TextStyle(color: Colors.white, fontSize: 11),
),
],
const Spacer(),
if (anime.episodes != null)
Text(
'${anime.episodes}集',
style: const TextStyle(color: Colors.white70, fontSize: 10),
),
],
);
}
Row 水平排列评分和集数。
评分用星星图标 + 数字,toStringAsFixed(1) 保留一位小数。
Spacer 把评分和集数推到两端。
集数用 Colors.white70,比标题稍浅,形成层次。
排名角标
dart
Widget _buildRankBadge() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'#${anime.rank}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
);
}
排名角标用主题色背景,圆角胶囊形状。
只有 showRank 为 true 且有排名数据时才显示。
条件渲染技巧
dart
if (showRank && anime.rank != null)
Positioned(
top: 8,
left: 8,
child: _buildRankBadge(),
),
在 children 列表里可以直接用 if 条件渲染。这是 Dart 2.3 引入的特性,叫做 collection if。
比用三元表达式更清晰:
dart
// 不推荐
showRank && anime.rank != null
? Positioned(...)
: const SizedBox.shrink(),
// 推荐
if (showRank && anime.rank != null)
Positioned(...),
展开操作符
dart
if (anime.score != null) ...[
const Icon(Icons.star, color: Colors.amber, size: 14),
const SizedBox(width: 2),
Text(anime.score!.toStringAsFixed(1), ...),
],
...[] 是展开操作符,把列表里的元素展开到外层列表。
配合 if 使用,可以条件性地添加多个元素。
组件的使用方式
封装好的组件使用起来很简单:
dart
// 基本使用
AnimeCard(anime: anime)
// 显示排名
AnimeCard(anime: anime, showRank: true)
// 在 GridView 中使用
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
),
itemCount: animeList.length,
itemBuilder: (_, i) => AnimeCard(anime: animeList[i]),
)
// 在 ListView 中使用
ListView.builder(
itemCount: animeList.length,
itemBuilder: (_, i) => Padding(
padding: const EdgeInsets.all(8),
child: SizedBox(
height: 200,
child: AnimeCard(anime: animeList[i]),
),
),
)
一个组件,多种场景。
扩展:添加收藏功能
可以给卡片加收藏按钮:
dart
class AnimeCard extends StatelessWidget {
final Anime anime;
final bool showRank;
final bool showFavorite;
final bool isFavorite;
final VoidCallback? onFavoriteToggle;
const AnimeCard({
super.key,
required this.anime,
this.showRank = false,
this.showFavorite = false,
this.isFavorite = false,
this.onFavoriteToggle,
});
新增三个参数:是否显示收藏按钮、是否已收藏、收藏切换回调。
dart
if (showFavorite)
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: onFavoriteToggle,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? Colors.red : Colors.white,
size: 18,
),
),
),
),
收藏按钮在右上角,圆形半透明背景。收藏状态通过外部传入,点击时调用回调。
扩展:添加长按菜单
dart
GestureDetector(
onTap: () => Navigator.push(...),
onLongPress: () {
showModalBottomSheet(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.favorite_border),
title: const Text('收藏'),
onTap: () {
Navigator.pop(context);
// 收藏逻辑
},
),
ListTile(
leading: const Icon(Icons.share),
title: const Text('分享'),
onTap: () {
Navigator.pop(context);
// 分享逻辑
},
),
],
),
);
},
child: // 卡片内容
)
长按弹出底部菜单,提供收藏、分享等操作。
深色模式适配
dart
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return GestureDetector(
onTap: () => Navigator.push(...),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withOpacity(0.3)
: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
// 内容
),
);
}
深色模式下阴影更深,让卡片边界更明显。
图片占位色也可以适配:
dart
Container(
color: isDark ? Colors.grey[800] : Colors.grey[300],
child: const Center(child: Icon(Icons.movie, ...)),
)
性能优化
卡片组件会大量使用,性能很重要:
dart
class AnimeCard extends StatelessWidget {
// 使用 const 构造函数
const AnimeCard({super.key, required this.anime, this.showRank = false});
@override
Widget build(BuildContext context) {
return GestureDetector(
// 内容
);
}
}
const 构造函数让 Flutter 可以复用相同参数的组件实例。
StatelessWidget 比 StatefulWidget 轻量,没有状态管理开销。
图片用 Image.network 的 loadingBuilder 而不是额外的状态管理,减少重建。
小结
动漫卡片组件涉及的技术点:组件封装 、参数设计 、Stack 层叠布局 、LinearGradient 渐变 、条件渲染 、展开操作符 、GestureDetector 手势。
组件设计原则:必需参数用 required,可选参数给默认值,用具体类型保证类型安全。
渐变遮罩是图片上叠加文字的标准做法,从深色到透明的渐变既保证文字清晰,又不完全遮挡图片。
好的组件封装能大幅提高开发效率,修改一处全App生效,是 Flutter 开发的重要技能。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net