通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97
看完一部动漫,想找类似的作品?推荐功能就是为此而生。微动漫App的推荐页面展示与当前动漫相关的推荐作品,用网格布局展示,点击可以跳转到详情页。
这篇文章会实现推荐动漫页面,重点讲解 GridView 网格布局、动漫卡片组件的封装,以及如何用渐变遮罩让文字在图片上清晰可读。

推荐页面的设计思路
推荐页面从动漫详情页进入,展示与该动漫相关的推荐作品。
网格布局:动漫封面是视觉重点,网格布局能展示更多封面,比列表更适合。
卡片设计:封面图占满卡片,标题和评分叠加在图片上,用渐变遮罩保证可读性。
点击跳转:点击卡片进入动漫详情页,形成浏览闭环。
页面基础结构
dart
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../models/anime.dart';
import '../widgets/anime_card.dart';
import '../widgets/shimmer_loading.dart';
class AnimeRecommendationsScreen extends StatefulWidget {
final int malId;
const AnimeRecommendationsScreen({super.key, required this.malId});
@override
State<AnimeRecommendationsScreen> createState() =>
_AnimeRecommendationsScreenState();
}
引入 AnimeCard 组件,这是一个封装好的动漫卡片,可以在多个页面复用。
malId 是当前动漫的 ID,用于获取相关推荐。
数据加载
dart
class _AnimeRecommendationsScreenState extends State<AnimeRecommendationsScreen> {
late Future<List<Anime>> _recommendationsFuture;
@override
void initState() {
super.initState();
_recommendationsFuture = ApiService.getAnimeRecommendations(widget.malId);
}
在 initState 里发起请求,获取推荐动漫列表。
FutureBuilder 构建页面
dart
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('推荐动漫')),
body: FutureBuilder<List<Anime>>(
future: _recommendationsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const ShimmerLoading(itemCount: 8, isGrid: true);
}
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.recommend, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'暂无推荐',
style: TextStyle(color: Colors.grey[600], fontSize: 16),
),
],
),
);
}
final recommendations = snapshot.data!;
return GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: recommendations.length,
itemBuilder: (_, i) => AnimeCard(anime: recommendations[i]),
);
},
),
);
}
加载中显示网格形式的骨架屏(isGrid: true )。空状态用 Icons.recommend 图标。
有数据时用 GridView.builder 展示网格。
GridView 配置详解
dart
GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: recommendations.length,
itemBuilder: (_, i) => AnimeCard(anime: recommendations[i]),
)
SliverGridDelegateWithFixedCrossAxisCount 是固定列数的网格代理。
crossAxisCount: 2 每行 2 个。
childAspectRatio: 0.7 宽高比,0.7 表示高度是宽度的 1/0.7 ≈ 1.43 倍,适合竖向的动漫封面。
crossAxisSpacing 是列间距,mainAxisSpacing 是行间距。
AnimeCard 组件结构
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});
AnimeCard 是一个无状态组件,接收 Anime 对象和是否显示排名的标志。
showRank 默认为 false,在排行榜页面可以设为 true 显示排名。
卡片点击跳转
dart
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => AnimeDetailScreen(anime: anime)),
),
GestureDetector 包裹整个卡片,点击后跳转到详情页。
把 anime 对象传给详情页,详情页可以直接使用,不需要再请求。
卡片容器样式
dart
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
borderRadius 设置圆角,boxShadow 添加阴影,让卡片有立体感。
阴影向下偏移 4 像素,模拟光从上方照射的效果。
Stack 层叠布局
dart
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
_buildImage(),
// 渐变遮罩和文字
// 排名角标
],
),
),
),
);
}
ClipRRect 裁剪圆角,让图片也有圆角效果。
Stack 层叠图片、渐变遮罩、文字信息。StackFit.expand 让 Stack 填满父容器。
渐变遮罩实现
dart
Positioned(
bottom: 0,
left: 0,
right: 0,
child: 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(
// 文字内容
),
),
),
Positioned 定位在底部,左右撑满。
LinearGradient 从底部到顶部,从深色到透明。这样文字在深色背景上清晰可读,同时不完全遮挡图片。
0.9 的透明度让底部足够暗,文字清晰。
标题和评分
dart
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),
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),
),
],
),
],
),
标题用白色粗体,最多 2 行。
评分用星星图标 + 数字,toStringAsFixed(1) 保留一位小数。
集数放在右边,用稍浅的白色(Colors.white70)。
Spacer 把评分和集数推到两端。
排名角标
dart
if (showRank && anime.rank != null)
Positioned(
top: 8,
left: 8,
child: 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
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 显示电影图标占位。
加载中显示进度指示器,loadingProgress 可以计算加载进度。
加载失败显示破图标,并打印错误日志方便调试。
加载进度计算
dart
CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null,
)
cumulativeBytesLoaded 是已加载的字节数,expectedTotalBytes 是总字节数。
如果服务器返回了总大小,就显示确定的进度;否则显示不确定的转圈动画(value 为 null)。
组件复用
AnimeCard 可以在多个页面使用:
dart
// 推荐页面
AnimeCard(anime: recommendations[i])
// 排行榜页面
AnimeCard(anime: topAnime[i], showRank: true)
// 搜索结果页面
AnimeCard(anime: searchResults[i])
// 收藏页面
AnimeCard(anime: favorites[i])
通过 showRank 参数控制是否显示排名,一个组件适应多种场景。
添加收藏按钮
可以在卡片上加收藏按钮:
dart
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: () {
// 切换收藏状态
},
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: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(anime.title),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (anime.synopsis != null)
Text(
anime.synopsis!,
maxLines: 5,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text('评分: ${anime.score ?? "暂无"}'),
Text('集数: ${anime.episodes ?? "未知"}'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('关闭'),
),
],
),
);
},
child: // 卡片内容
)
长按弹出对话框,显示简介、评分、集数等信息,不用进入详情页就能快速了解。
深色模式适配
卡片在深色模式下需要调整阴影:
dart
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Theme.of(context).brightness == Brightness.dark
? Colors.black.withOpacity(0.3)
: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
// 内容
)
深色模式下阴影更深,让卡片边界更明显。
小结
推荐动漫页面涉及的技术点:GridView.builder 网格布局 、SliverGridDelegate 网格配置 、Stack 层叠布局 、LinearGradient 渐变 、Positioned 定位 、组件封装复用。
AnimeCard 是一个设计良好的组件:封面图占满卡片吸引眼球,渐变遮罩保证文字可读,点击跳转形成浏览闭环,showRank 参数让组件适应多种场景。
渐变遮罩是图片上叠加文字的常用技巧,从深色到透明的渐变既保证了文字清晰,又不完全遮挡图片内容。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net