Flutter for OpenHarmony 微动漫App实战:排行榜实现
本文介绍了如何实现动漫App中的排行榜功能,重点讲解了两种不同的状态管理方式。主要内容包括: 排行榜页面需求分析:核心数据展示(封面、标题、评分、排名)、基础交互(点击跳转详情)和加载状态处理(骨架屏优化体验) 手动管理状态实现: 使用StatefulWidget管理加载状态和数据列表 通过initState触发初始数据加载 根据_isLoading状态切换骨架屏和实际内容 FutureBuild
通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97
排行榜是动漫App的核心功能之一,用户想知道"现在什么番最火"、“评分最高的是哪部”,第一反应就是去看排行榜。这个功能看起来简单,不就是一个列表嘛,但要做好还是有不少讲究的。
这篇文章会实现一个完整的排行榜页面,顺便把动漫卡片组件也讲一讲,因为排行榜和卡片是配套使用的。代码都是项目里实际跑着的,可以直接参考。

排行榜页面的需求分析
在写代码之前,先想想排行榜要展示什么:
核心数据:动漫封面、标题、评分、排名。这四个信息缺一不可,用户一眼就能看到这部番的"江湖地位"。
交互:点击某一项跳转到详情页。这是最基本的交互,不需要太花哨。
加载状态:数据从网络获取,需要显示加载中的状态。用骨架屏比转圈圈体验好。
想清楚这些,代码写起来就有方向了。
页面的基本结构
排行榜需要管理加载状态和数据列表,用 StatefulWidget:
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../models/anime.dart';
import '../widgets/anime_list_tile.dart';
import '../widgets/shimmer_loading.dart';
引入的依赖不多:ApiService 负责网络请求,Anime 是数据模型,AnimeListTile 是列表项组件,ShimmerLoading 是骨架屏。
class RankingScreen extends StatefulWidget {
const RankingScreen({super.key});
State<RankingScreen> createState() => _RankingScreenState();
}
标准的 StatefulWidget 定义,没什么特别的。
状态变量的设计
class _RankingScreenState extends State<RankingScreen> {
List<Anime> _topAnime = [];
bool _isLoading = true;
int _currentPage = 1;
三个状态变量各有用途:
_topAnime 存储排行榜数据,初始是空列表。
_isLoading 控制是否显示加载状态,初始为 true,因为页面一打开就要加载数据。
_currentPage 记录当前页码,为后续的分页加载预留。虽然现在只加载第一页,但把这个变量留着,以后加分页功能就方便了。
初始化时加载数据
void initState() {
super.initState();
_loadRanking();
}
initState 里调用 _loadRanking,页面一创建就开始加载数据。这是最常见的做法,用户不需要手动触发。
数据加载逻辑
Future<void> _loadRanking() async {
setState(() => _isLoading = true);
try {
final animes = await ApiService.getTopAnime(page: _currentPage);
setState(() {
_topAnime = animes;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
这段代码做了几件事:
开始加载时,把 _isLoading 设为 true,触发界面显示骨架屏。
调用接口,ApiService.getTopAnime 返回排行榜数据,传入页码参数。
加载成功,更新 _topAnime 并把 _isLoading 设为 false。
加载失败,只把 _isLoading 设为 false,不做其他处理。实际项目中可以加个错误提示,这里为了简洁省略了。
页面的 UI 构建
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('排行榜')),
body: _isLoading
? const ShimmerLoading(itemCount: 8, isGrid: false)
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _topAnime.length,
itemBuilder: (_, i) => AnimeListTile(anime: _topAnime[i]),
),
);
}
用三元表达式根据 _isLoading 决定显示什么:
加载中:显示 8 个骨架屏项,isGrid: false 表示用列表形式而不是网格。
加载完成:用 ListView.builder 展示数据,每一项是一个 AnimeListTile。
ListView.builder 是懒加载的,只渲染屏幕上可见的项,即使有几百条数据也不会卡。
换个思路:用 FutureBuilder
上面的写法是手动管理加载状态,还有另一种写法是用 FutureBuilder,来看看热门角色页面是怎么做的:
class _TopCharactersScreenState extends State<TopCharactersScreen> {
late Future<List<Character>> _charactersFuture;
int _currentPage = 1;
void initState() {
super.initState();
_charactersFuture = ApiService.getTopCharacters(page: _currentPage);
}
把 Future 存成状态变量,在 initState 里赋值。注意用 late 关键字,因为赋值发生在构造函数之后。
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('热门角色')),
body: FutureBuilder<List<Character>>(
future: _charactersFuture,
builder: (context, snapshot) {
FutureBuilder 会自动监听 Future 的状态变化,根据不同状态返回不同的 Widget。
if (snapshot.connectionState == ConnectionState.waiting) {
return const ShimmerLoading(itemCount: 8, isGrid: false);
}
ConnectionState.waiting 表示 Future 还没完成,显示骨架屏。
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text('暂无角色'),
],
),
);
}
数据为空时显示空状态提示,一个大图标加一行文字。
final characters = snapshot.data!;
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: characters.length,
itemBuilder: (_, i) => _buildCharacterCard(characters[i], i + 1),
);
},
),
);
}
有数据时正常渲染列表。i + 1 是排名,因为索引从 0 开始,排名从 1 开始。
FutureBuilder vs 手动管理状态,哪个好?看情况。FutureBuilder 代码更简洁,但不太灵活,比如想实现下拉刷新就比较麻烦。手动管理状态代码多一点,但更灵活。
列表项带排名标签
热门角色的列表项有个排名标签,看看怎么实现的:
Widget _buildCharacterCard(Character character, int rank) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 50,
height: 70,
child: _buildImage(character.imageUrl),
),
),
Stack 可以让多个 Widget 叠加在一起。底层是角色头像,用 ClipRRect 加圆角。
Positioned(
top: 0,
left: 0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: const BorderRadius.only(
bottomRight: Radius.circular(8),
),
),
child: Text(
'#$rank',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 10,
),
),
),
),
],
),
Positioned 把排名标签定位到左上角。标签用主题色背景,只有右下角有圆角,形成一个"角标"的效果。
列表项的其他信息
title: Text(
character.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: character.nameKanji?.isNotEmpty ?? false
? Text(character.nameKanji!, style: const TextStyle(fontSize: 12))
: null,
标题是角色名,最多两行,超出显示省略号。副标题是日文名,如果有的话才显示。
character.nameKanji?.isNotEmpty ?? false 这个写法处理了两种情况:nameKanji 为 null,或者 nameKanji 是空字符串。
trailing: character.favorites != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.favorite, color: Colors.red, size: 16),
const SizedBox(height: 2),
Text(
'${character.favorites}',
style: const TextStyle(fontSize: 11),
),
],
)
: null,
),
);
}
右侧显示收藏数,一个红心图标加数字。如果没有收藏数据就不显示。
动漫卡片组件
排行榜用的是列表形式,但有些地方会用卡片形式展示动漫,比如首页的网格。来看看 AnimeCard 组件:
class AnimeCard extends StatelessWidget {
final Anime anime;
final bool showRank;
const AnimeCard({super.key, required this.anime, this.showRank = false});
两个参数:anime 是必传的动漫数据,showRank 控制是否显示排名标签,默认不显示。
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => AnimeDetailScreen(anime: anime)),
),
整个卡片可点击,点击后跳转到详情页。用 GestureDetector 包裹,比给每个子元素加 onTap 简洁。
卡片的容器样式
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
BoxDecoration 设置圆角和阴影。阴影用 10% 透明度的黑色,模糊半径 8,向下偏移 4 像素,看起来像是卡片浮在页面上。
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
_buildImage(),
ClipRRect 裁剪子元素,让图片也有圆角。Stack 叠加多个图层:底层是封面图,上层是信息遮罩和排名标签。
底部信息遮罩
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,
],
),
),
用 Positioned 把信息区域定位到底部。渐变遮罩从下往上,下面是 90% 透明度的黑色,上面是全透明。这样文字在任何颜色的封面上都能看清。
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,
),
),
标题用白色粗体,最多两行。mainAxisSize: MainAxisSize.min 让 Column 高度自适应内容,不会撑满整个遮罩区域。
评分和集数信息
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),
),
],
评分用金色星星图标加数字,toStringAsFixed(1) 保留一位小数。
const Spacer(),
if (anime.episodes != null)
Text(
'${anime.episodes}集',
style: const TextStyle(color: Colors.white70, fontSize: 10),
),
],
),
],
),
),
),
Spacer 把评分和集数撑开,评分在左,集数在右。集数用 70% 透明度的白色,比标题淡一点,形成层次。
排名标签
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 且有排名数据时才显示。用主题色背景,圆角 12,看起来像个小胶囊。
图片加载处理
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)),
);
}
先检查 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: Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
loadingBuilder 在图片加载过程中显示进度。loadingProgress.expectedTotalBytes 是总大小,cumulativeBytesLoaded 是已加载大小,两者相除就是进度百分比。
如果服务器没返回总大小,expectedTotalBytes 会是 null,这时候进度指示器显示为不确定状态(一直转圈)。
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[300],
child: const Center(child: Icon(Icons.broken_image, size: 40, color: Colors.grey)),
);
},
);
}
errorBuilder 处理加载失败,显示一个破碎图片的图标。网络图片加载失败是常有的事,必须处理好,不然界面会很难看。
趋势页面的轮播图
趋势页面用了轮播图展示热门动漫,效果很炫:
CarouselSlider(
options: CarouselOptions(
height: 200,
autoPlay: true,
enlargeCenterPage: true,
viewportFraction: 0.8,
),
items: _trendingAnime.take(5).map((anime) {
return AnimeCard(anime: anime, showRank: true);
}).toList(),
),
CarouselSlider 是第三方轮播图组件,配置项很丰富:
height: 200 设置轮播图高度。
autoPlay: true 自动播放,不用用户手动滑动。
enlargeCenterPage: true 中间的卡片放大,两边的缩小,有层次感。
viewportFraction: 0.8 每个卡片占视口宽度的 80%,这样能看到两边卡片的一部分。
take(5) 只取前 5 个动漫做轮播,太多了用户也看不过来。
小结
排行榜功能涉及的知识点:列表数据加载和状态管理、FutureBuilder 的使用、Stack 实现图层叠加、Positioned 定位元素、渐变遮罩让文字更清晰、图片加载的三种状态处理、轮播图组件的使用。
这些技术点在其他页面也会用到,掌握了排行榜的实现,首页、发现页、搜索结果页都能举一反三。
排行榜看起来简单,但细节很多。把每个细节都处理好,用户体验就上去了。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)