Flutter for OpenHarmony 微动漫App实战:分类浏览实现
通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97
动漫的分类五花八门:热血、恋爱、搞笑、悬疑、科幻、奇幻……每个用户都有自己偏好的类型。分类浏览功能让用户可以按类型筛选动漫,快速找到自己喜欢的内容。
这篇文章会实现一个完整的分类浏览模块,包括分类列表页和分类详情页。分类列表用网格展示所有类型,点击后进入详情页显示该类型下的动漫。这是一个典型的"列表-详情"模式,掌握了这个模式,很多类似的功能都能快速实现。
两个页面的关系
分类浏览模块包含两个页面:
GenreScreen:分类列表页,展示所有动漫类型,比如"动作"、“冒险”、"喜剧"等。每个类型是一个卡片,显示类型名称和该类型下有多少部动漫。
GenreDetailScreen:分类详情页,展示某个类型下的所有动漫。从分类列表点击某个类型后跳转到这里。
两个页面通过 Navigator.push 连接,分类列表把类型 ID 和名称传给详情页。
分类列表页的实现
先看分类列表页的基本结构:
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../models/anime.dart';
import '../widgets/anime_card.dart';
import '../widgets/shimmer_loading.dart';
引入的依赖比较常规:ApiService 获取数据,AnimeCard 在详情页展示动漫卡片,ShimmerLoading 是骨架屏。
class GenreScreen extends StatefulWidget {
const GenreScreen({super.key});
State<GenreScreen> createState() => _GenreScreenState();
}
用 StatefulWidget 是因为需要管理加载状态和分类数据。
状态变量的设计
class _GenreScreenState extends State<GenreScreen> {
List<Map<String, dynamic>> _genres = [];
bool _isLoading = true;
_genres 存储分类列表,类型是 List<Map<String, dynamic>>。为什么不定义一个 Genre 模型类?因为分类数据结构很简单,只有 ID、名称、数量三个字段,用 Map 就够了,没必要专门建个类。
_isLoading 控制是否显示加载状态,初始为 true。
初始化时加载数据
void initState() {
super.initState();
_loadGenres();
}
页面创建时立即加载分类数据,用户不需要手动触发。
Future<void> _loadGenres() async {
setState(() => _isLoading = true);
try {
final genres = await ApiService.getGenres();
setState(() {
_genres = genres;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
加载逻辑很标准:开始时设置 loading 状态,成功后更新数据,失败后停止 loading。这个模式在前面的文章里出现过很多次了。
页面的 UI 构建
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('分类')),
body: _isLoading
? const ShimmerLoading(itemCount: 8, isGrid: true)
: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.5,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _genres.length,
itemBuilder: (_, i) => _buildGenreCard(_genres[i]),
),
);
}
加载中显示骨架屏,加载完成显示网格。
GridView.builder 的配置:
crossAxisCount: 2 表示两列布局。
childAspectRatio: 1.5 设置卡片宽高比,宽度是高度的 1.5 倍,卡片比较扁平,适合显示文字内容。
crossAxisSpacing 和 mainAxisSpacing 都是 12,卡片之间的间距。
padding 四周 12 像素,让内容不会贴边。
分类卡片的实现
每个分类是一个可点击的卡片:
Widget _buildGenreCard(Map<String, dynamic> genre) {
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => GenreDetailScreen(
genreId: genre['mal_id'],
genreName: genre['name'],
),
),
),
用 GestureDetector 包裹整个卡片,点击后跳转到详情页。传入两个参数:genreId 用于请求该分类下的动漫,genreName 用于显示页面标题。
卡片的样式设计
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
gradient: LinearGradient(
colors: [
Theme.of(context).primaryColor.withOpacity(0.7),
Theme.of(context).colorScheme.secondary.withOpacity(0.7),
],
),
),
卡片用渐变色背景,从主色到次色。withOpacity(0.7) 让颜色不会太重,看起来更柔和。圆角 12 和其他卡片保持一致。
为什么用渐变色而不是单色?因为分类卡片没有图片,纯色背景会显得单调,渐变色能增加视觉层次感。
卡片内容
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
genre['name'],
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${genre['count']} 部',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
),
);
}
卡片内容居中显示,包含两行文字:
分类名称:白色粗体,16 号字,是卡片的主要信息。
动漫数量:70% 透明度的白色,12 号字,作为补充信息。
textAlign: TextAlign.center 让文字居中,因为有些分类名称比较长,可能会换行。
分类详情页的实现
点击分类卡片后进入详情页:
class GenreDetailScreen extends StatefulWidget {
final int genreId;
final String genreName;
const GenreDetailScreen({
super.key,
required this.genreId,
required this.genreName,
});
State<GenreDetailScreen> createState() => _GenreDetailScreenState();
}
详情页接收两个参数,都是必传的。genreId 用于请求数据,genreName 用于显示标题。
用 required 关键字标记必传参数,如果调用时没传会编译报错,比运行时报错好多了。
详情页的状态管理
class _GenreDetailScreenState extends State<GenreDetailScreen> {
List<Anime> _animes = [];
bool _isLoading = true;
void initState() {
super.initState();
_loadAnimes();
}
状态变量和分类列表页类似,只是数据类型不同。_animes 存储的是 Anime 对象列表。
Future<void> _loadAnimes() async {
setState(() => _isLoading = true);
try {
final animes = await ApiService.getAnimeByGenre(widget.genreId);
setState(() {
_animes = animes;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
调用 getAnimeByGenre 接口,传入分类 ID。注意这里用的是 widget.genreId,因为 genreId 是 Widget 的属性,在 State 里要通过 widget 访问。
详情页的 UI
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.genreName)),
body: _isLoading
? const ShimmerLoading(itemCount: 8, isGrid: true)
: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _animes.length,
itemBuilder: (_, i) => AnimeCard(anime: _animes[i]),
),
);
}
AppBar 标题用分类名称,比如"动作"、“冒险”,用户一眼就知道当前在看什么分类。
网格布局和首页的当季动漫一样,childAspectRatio: 0.7 让卡片高度是宽度的 1.43 倍,适合显示动漫封面。
API 获取分类列表
来看看 ApiService 是怎么获取分类数据的:
static Future<List<Map<String, dynamic>>> getGenres() async {
try {
await _rateLimit();
final url = '$jikanBaseUrl/genres/anime';
print('Fetching genres: $url');
final response = await http.get(Uri.parse(url)).timeout(
const Duration(seconds: 10),
onTimeout: () => http.Response('timeout', 408),
);
调用 /genres/anime 接口获取动漫分类列表。这个接口返回所有可用的分类,包括 ID、名称、数量等信息。
if (response.statusCode == 200) {
final data = json.decode(response.body);
final genres = (data['data'] as List).map((e) => e as Map<String, dynamic>).toList();
print('Got ${genres.length} genres');
return genres;
} else {
print('Error: ${response.statusCode}');
return [];
}
} catch (e) {
print('Error fetching genres: $e');
return [];
}
}
解析 JSON 数据,把每个元素转成 Map<String, dynamic>。这里没有定义专门的模型类,直接用 Map 存储,简单直接。
API 按分类获取动漫
static Future<List<Anime>> getAnimeByGenre(int genreId, {int page = 1}) async {
try {
await _rateLimit();
final url = '$jikanBaseUrl/anime?genres=$genreId&page=$page&limit=25';
print('Fetching by genre: $url');
final response = await http.get(Uri.parse(url)).timeout(
const Duration(seconds: 10),
onTimeout: () => http.Response('timeout', 408),
);
用 genres 参数筛选指定分类的动漫。API 支持传多个分类 ID,用逗号分隔,比如 genres=1,2,3,但这里只传一个。
page 参数支持分页,目前只用到第一页,后续可以加上加载更多功能。
if (response.statusCode == 200) {
final data = json.decode(response.body);
final animes = (data['data'] as List).map((e) => Anime.fromJson(e)).toList();
print('Got ${animes.length} animes by genre');
return animes;
} else {
print('Error: ${response.statusCode}');
return [];
}
} catch (e) {
print('Error fetching anime by genre: $e');
return [];
}
}
解析逻辑和其他接口一样,把 JSON 数组转成 Anime 对象列表。
关于分类数据的缓存
分类列表数据基本不会变化,每次进入页面都请求一次有点浪费。可以考虑加个缓存:
static List<Map<String, dynamic>>? _genresCache;
static Future<List<Map<String, dynamic>>> getGenres() async {
// 如果有缓存,直接返回
if (_genresCache != null) {
print('Using cached genres');
return _genresCache!;
}
try {
await _rateLimit();
final url = '$jikanBaseUrl/genres/anime';
final response = await http.get(Uri.parse(url)).timeout(
const Duration(seconds: 10),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
final genres = (data['data'] as List)
.map((e) => e as Map<String, dynamic>)
.toList();
_genresCache = genres; // 存入缓存
return genres;
}
return [];
} catch (e) {
return [];
}
}
用一个静态变量存储缓存,第一次请求后就不再请求了。这是最简单的内存缓存,App 重启后会失效,但对于分类这种不常变化的数据已经够用了。
空状态处理
目前的实现没有处理数据为空的情况,可以加上:
body: _isLoading
? const ShimmerLoading(itemCount: 8, isGrid: true)
: _animes.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.category, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text('该分类暂无动漫'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadAnimes,
child: const Text('重新加载'),
),
],
),
)
: GridView.builder(...),
空状态页面包含图标、提示文字和重试按钮。虽然分类下不太可能没有动漫,但网络错误时可能返回空列表,加上这个处理更稳妥。
下拉刷新
详情页可以加上下拉刷新功能:
body: _isLoading
? const ShimmerLoading(itemCount: 8, isGrid: true)
: RefreshIndicator(
onRefresh: _loadAnimes,
child: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _animes.length,
itemBuilder: (_, i) => AnimeCard(anime: _animes[i]),
),
),
RefreshIndicator 是 Flutter 自带的下拉刷新组件,比第三方库轻量。下拉时触发 _loadAnimes 重新加载数据。
注意 _loadAnimes 方法返回的是 Future,正好符合 onRefresh 的要求。刷新完成后 RefreshIndicator 会自动隐藏加载动画。
加载更多
目前只加载第一页数据,如果想加载更多,可以这样改:
class _GenreDetailScreenState extends State<GenreDetailScreen> {
List<Anime> _animes = [];
bool _isLoading = true;
bool _isLoadingMore = false;
int _currentPage = 1;
bool _hasMore = true;
final ScrollController _scrollController = ScrollController();
void initState() {
super.initState();
_loadAnimes();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadMore() async {
if (_isLoadingMore || !_hasMore) return;
setState(() => _isLoadingMore = true);
try {
final animes = await ApiService.getAnimeByGenre(
widget.genreId,
page: _currentPage + 1,
);
setState(() {
_animes.addAll(animes);
_currentPage++;
_hasMore = animes.length >= 25;
_isLoadingMore = false;
});
} catch (e) {
setState(() => _isLoadingMore = false);
}
}
监听滚动位置,快到底部时自动加载下一页。_hasMore 判断是否还有更多数据,如果返回的数据少于 25 条,说明已经是最后一页了。
小结
分类浏览功能涉及的知识点:网格布局、渐变色背景、页面间参数传递、widget 属性访问、数据缓存、下拉刷新、加载更多。
这是一个典型的"列表-详情"模式:列表页展示所有选项,点击后进入详情页展示具体内容。这个模式在 App 开发中非常常见,掌握了它,很多类似的功能都能快速实现。
分类浏览让用户可以按自己的喜好探索内容,是提升用户体验的重要功能。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)