Flutter for OpenHarmony 微动漫App实战:动漫详情实现
本文介绍了Flutter动漫详情页的实现过程。详情页作为App中最复杂的页面,需要展示封面大图、评分、简介等多层次信息,并提供收藏、分享等功能交互。文章详细讲解了如何使用CustomScrollView和SliverAppBar实现头部大图的折叠效果,通过Stack布局实现渐变遮罩优化视觉效果。代码展示了状态管理、异步数据加载、收藏状态切换等核心功能的实现,包括使用Consumer监听收藏状态、S
通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97
详情页是整个App里最复杂的页面,没有之一。用户从列表点进来,期望看到动漫的完整信息:封面大图、评分排名、剧情简介、播出状态、制作公司……信息量很大,怎么组织这些内容让用户看得舒服,是个技术活。
这篇文章会从头实现一个完整的动漫详情页,包括可折叠的头部大图、信息展示、收藏和分享功能、以及跳转到角色列表和推荐动漫的入口。代码都是项目里实际跑着的,踩过的坑也会一并分享。

详情页要解决什么问题
在动手写代码之前,先想清楚详情页要做什么:
信息展示:标题、日文名、评分、排名、类型、状态、集数、年份、季度、简介……这些信息要有层次地展示出来,不能一股脑堆在一起。
交互功能:收藏、分享、查看角色、查看推荐、查看新闻。这些功能要放在用户容易找到的地方。
视觉效果:头部大图要有冲击力,滚动时要有折叠效果,整体要好看。
想清楚这些,代码写起来就有方向了。
页面的基本结构
详情页需要管理加载状态和动漫数据,所以用 StatefulWidget:
class AnimeDetailScreen extends StatefulWidget {
final Anime anime;
const AnimeDetailScreen({super.key, required this.anime});
State<AnimeDetailScreen> createState() => _AnimeDetailScreenState();
}
构造函数接收一个 Anime 对象,这是从列表页传过来的基础数据。为什么说是"基础数据"?因为列表接口返回的信息不全,详情页需要再调一次详情接口获取完整信息。
class _AnimeDetailScreenState extends State<AnimeDetailScreen> {
late Anime _anime;
bool _isLoading = true;
_anime 存储当前显示的动漫数据,初始值是传进来的基础数据,加载完详情后会更新。_isLoading 控制是否显示加载状态。
初始化时做两件事
void initState() {
super.initState();
_anime = widget.anime;
_loadDetails();
先把传进来的数据赋值给 _anime,这样页面能立即显示基础信息,不用等详情接口返回。这是个小技巧,能让用户感觉页面加载很快。
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
context.read<HistoryProvider>().addToHistory(_anime);
}
});
}
延迟 500 毫秒后把这个动漫添加到历史记录。为什么要延迟?因为用户可能只是误点进来,马上就退出了,这种情况不应该算作"浏览过"。延迟一下,只有真正停留的才会记录。
mounted 检查很重要,如果用户在 500 毫秒内退出了页面,这时候 context 已经不可用了,直接调用会报错。
加载详情数据
Future<void> _loadDetails() async {
try {
final details = await ApiService.getAnimeDetails(_anime.malId);
if (details != null && mounted) {
setState(() => _anime = details);
}
} catch (e) {
print('加载详情失败: $e');
}
调用详情接口,成功后更新 _anime。这里又检查了一次 mounted,异步操作完成时页面可能已经被销毁了。
if (mounted) {
setState(() => _isLoading = false);
}
}
不管成功还是失败,都要把 _isLoading 设为 false,否则页面会一直显示加载状态。
使用 CustomScrollView 实现折叠头部
详情页最酷的效果是头部大图在滚动时会折叠,这个用 CustomScrollView 配合 SliverAppBar 实现:
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
pinned: true,
CustomScrollView 是 Flutter 里做复杂滚动效果的利器,它的 children 必须是 Sliver 系列组件。
expandedHeight: 300 设置头部展开时的高度,pinned: true 让 AppBar 在折叠后固定在顶部,不会完全消失。
头部背景的实现
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
_buildHeaderImage(),
FlexibleSpaceBar 是 SliverAppBar 的可伸缩区域,滚动时会自动缩放。用 Stack 叠加多个图层:底层是封面图,上层是渐变遮罩。
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.7),
],
),
),
),
],
),
),
渐变遮罩从上到下,上面透明,下面是 70% 透明度的黑色。这样做有两个好处:让底部的文字更清晰,让图片和下方内容有个过渡。
AppBar 上的操作按钮
actions: [
Consumer<FavoritesProvider>(
builder: (context, provider, _) {
final isFav = provider.isFavorite(_anime.malId);
return IconButton(
icon: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: Colors.red,
),
收藏按钮用 Consumer 包裹,这样收藏状态变化时按钮会自动更新。isFavorite 方法返回这个动漫是否已收藏,根据结果显示实心或空心爱心。
onPressed: () {
provider.toggleFavorite(_anime);
final newState = provider.isFavorite(_anime.malId);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(newState ? '已添加到收藏' : '已从收藏移除'),
duration: const Duration(seconds: 1),
),
);
},
);
},
),
点击后调用 toggleFavorite 切换收藏状态,然后用 SnackBar 给用户反馈。SnackBar 是 Material Design 的轻量级提示组件,从底部弹出,1 秒后自动消失。
分享按钮
IconButton(
icon: const Icon(Icons.share),
onPressed: () async {
final success = await ShareService.shareText(
title: _anime.title,
content: '${_anime.synopsis ?? '暂无简介'}\n\n来自微动漫App',
);
if (!success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('分享失败')),
);
}
},
),
],
分享功能调用 ShareService,这是我们封装的分享服务,底层会调用系统的分享能力。分享内容包括动漫标题和简介,末尾加上 App 名称做个小广告。
分享失败时显示提示,成功时不需要提示,因为系统分享面板本身就是反馈。
页面主体内容
SliverToBoxAdapter(
child: _isLoading
? const ShimmerLoading(itemCount: 1, isGrid: false)
: _buildContent(),
),
],
),
);
}
SliverToBoxAdapter 可以把普通 Widget 放进 CustomScrollView 里。加载中显示骨架屏,加载完成显示实际内容。
封面图片的加载处理
Widget _buildHeaderImage() {
final imageUrl = _anime.imageUrl;
if (imageUrl == null || imageUrl.isEmpty) {
return Container(
color: Colors.grey[300],
child: const Center(child: Icon(Icons.movie, size: 64, color: Colors.grey)),
);
}
先检查图片 URL 是否有效,无效就显示占位图。这种防御性编程很重要,API 返回的数据不一定靠谱。
return Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
color: Colors.grey[300],
child: const Center(child: CircularProgressIndicator()),
);
},
loadingBuilder 在图片加载过程中显示进度指示器。loadingProgress == null 表示加载完成,这时候返回实际图片。
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[300],
child: const Center(child: Icon(Icons.broken_image, size: 64, color: Colors.grey)),
);
},
);
}
errorBuilder 处理加载失败的情况,显示一个破碎图片的图标。网络图片加载失败是常有的事,必须处理好。
内容区域的布局
Widget _buildContent() {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_anime.title,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
标题用 24号字体加粗,是页面上最大的文字。crossAxisAlignment: CrossAxisAlignment.start 让所有内容左对齐。
if (_anime.titleJapanese.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
_anime.titleJapanese,
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
],
如果有日文名就显示出来,用灰色小字。...[] 是 Dart 的展开操作符,可以在列表里条件性地添加多个元素。
评分和排名标签
Widget _buildInfoRow() {
return Row(
children: [
if (_anime.score != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(20),
),
评分标签用主题色背景,圆角 20 让它看起来像个胶囊。
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star, color: Colors.white, size: 16),
const SizedBox(width: 4),
Text(
_anime.score!.toStringAsFixed(1),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
],
),
),
const SizedBox(width: 8),
],
星星图标加评分数字,白色文字在彩色背景上很显眼。toStringAsFixed(1) 保留一位小数。
if (_anime.rank != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.amber,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'#${_anime.rank}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
],
);
}
排名标签用琥珀色背景,和评分标签形成对比。#${_anime.rank} 显示为 “#1”、“#100” 这样的格式。
详情信息网格
动漫有很多属性要展示,用网格布局比较整齐:
Widget _buildDetailsGrid() {
final items = <Widget>[
_buildDetailItem('类型', _anime.type ?? 'N/A'),
_buildDetailItem('状态', _anime.status ?? 'N/A'),
_buildDetailItem('集数', _anime.episodes?.toString() ?? 'N/A'),
_buildDetailItem('评分', _anime.rating ?? 'N/A'),
];
先把固定的几个属性加进去,?? 'N/A' 处理空值情况。
if (_anime.year != null) {
items.add(_buildDetailItem('年份', _anime.year.toString()));
}
if (_anime.season != null) {
items.add(_buildDetailItem('季度', _anime.season!));
}
年份和季度不是所有动漫都有,有的话才显示。
return GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 2.5,
children: items,
);
}
GridView.count 创建固定列数的网格,crossAxisCount: 2 表示两列。shrinkWrap: true 让网格高度自适应内容,NeverScrollableScrollPhysics 禁用网格自身的滚动,因为外层已经有 CustomScrollView 了。
单个详情项的构建
Widget _buildDetailItem(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
标签用灰色小字,作为说明。
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
值用粗体显示,maxLines: 1 和 overflow: TextOverflow.ellipsis 防止文字太长溢出,超出部分显示省略号。
操作按钮区域
详情页底部有几个功能按钮:
Widget _buildActionButtons() {
return Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.people),
label: const Text('角色'),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AnimeCharactersScreen(malId: _anime.malId),
),
),
),
),
ElevatedButton.icon 是带图标的按钮,width: double.infinity 让按钮撑满宽度。点击跳转到角色列表页,传入动漫 ID。
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.recommend),
label: const Text('推荐'),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AnimeRecommendationsScreen(malId: _anime.malId),
),
),
),
),
推荐按钮跳转到推荐动漫页,显示和当前动漫相似的作品。
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.newspaper),
label: const Text('新闻'),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AnimeNewsScreen(malId: _anime.malId),
),
),
),
),
],
);
}
新闻按钮跳转到动漫新闻页,显示这部动漫的相关资讯。
三个按钮垂直排列,每个之间有 8 像素间距,整体看起来很整齐。
Anime 数据模型
详情页展示的数据来自 Anime 模型,看看它的结构:
class Anime {
final int malId;
final String title;
final String titleJapanese;
final String? imageUrl;
final String? synopsis;
final double? score;
final int? episodes;
final String? status;
final int? rank;
final String? season;
final int? year;
final String? type;
字段很多,大部分是可空的,因为 API 返回的数据不一定完整。malId 是动漫的唯一标识,title 是英文/中文标题,titleJapanese 是日文原名。
JSON 解析
factory Anime.fromJson(Map<String, dynamic> json) {
try {
final images = json['images'] as Map<String, dynamic>?;
final jpg = images?['jpg'] as Map<String, dynamic>?;
API 返回的图片数据嵌套了好几层,需要一层层取出来。用 as Map<String, dynamic>? 做类型转换,加问号表示可能为空。
return Anime(
malId: json['mal_id'] ?? 0,
title: json['title'] ?? 'Unknown',
titleJapanese: json['title_japanese'] ?? '',
imageUrl: jpg?['large_image_url'] ?? jpg?['image_url'],
synopsis: json['synopsis'],
score: (json['score'] as num?)?.toDouble(),
?? 操作符提供默认值,as num? 处理数字类型,因为 JSON 里的数字可能是 int 也可能是 double。
genres: (json['genres'] as List<dynamic>?)
?.map((g) {
if (g is Map<String, dynamic>) {
return g['name']?.toString() ?? '';
}
return '';
})
.where((name) => name.isNotEmpty)
.toList() ?? [],
genres 是个数组,每个元素是个对象,需要取出 name 字段。where 过滤掉空字符串,?? [] 处理整个数组为空的情况。
这种防御性的解析代码看起来啰嗦,但能避免很多运行时错误。API 返回的数据格式可能随时变化,多做检查总没错。
小结
详情页是整个 App 里代码量最大的页面,涉及的知识点也最多:CustomScrollView 和 SliverAppBar 实现折叠头部、Stack 和渐变实现图片遮罩、Consumer 监听收藏状态、异步加载和 mounted 检查、GridView 展示详情信息、防御性的 JSON 解析。
这些技术点单独拿出来都不难,但组合在一起就需要花点心思了。写详情页的时候,建议先把布局画出来,想清楚每个区域要显示什么,然后再动手写代码。
详情页做好了,整个 App 的体验会上一个台阶。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)