Flutter for OpenHarmony 微动漫App实战 - 发现页实现
本文介绍了微动漫App发现页的实现,重点分析了筛选标签交互与数据切换逻辑。发现页采用"标签+列表"布局,顶部导航栏显示标题,筛选标签栏提供热门、当季、即将上映、随机推荐四种分类切换,下方网格布局展示动漫内容。通过StatefulWidget管理状态,使用FilterChip组件实现标签选择,根据用户选择调用不同API接口获取数据。页面支持回到顶部功能,采用骨架屏优化加载体验,为
通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97
发现页是微动漫App的第二个主要页面,它提供了一种不同于首页的浏览方式。用户可以通过顶部的筛选标签切换不同的内容分类:热门、当季、即将上映、随机推荐。这种设计让用户能够按照自己的兴趣探索动漫内容。本文将详细讲解发现页的实现,重点介绍筛选标签的交互设计和数据切换逻辑。
发现页与首页的区别
虽然发现页和首页都是展示动漫列表,但它们的定位不同:
- 首页:内容聚合,同时展示多个板块(热门、当季),让用户快速了解平台内容
- 发现页:专注浏览,一次只展示一种类型的内容,但提供切换能力
这种设计在很多内容型应用中都能看到。首页负责"推荐",发现页负责"探索"。用户可以根据自己的需求选择使用哪个页面。
页面结构概览
发现页的布局相对简单,从上到下分为三个部分:
- 顶部导航栏:显示页面标题
- 筛选标签栏:横向排列的 FilterChip,用于切换内容类型
- 内容区域:网格布局展示动漫卡片
这种"标签+列表"的布局模式非常经典,用户一眼就能理解如何操作。
组件定义与状态变量
发现页需要管理筛选状态和数据列表,所以使用 StatefulWidget:
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 ExploreScreen extends StatefulWidget {
const ExploreScreen({super.key});
State<ExploreScreen> createState() => _ExploreScreenState();
}
标准的 StatefulWidget 定义,没有什么特别的。
接下来是状态变量的定义:
class _ExploreScreenState extends State<ExploreScreen> {
int _selectedFilter = 0;
List<Anime> _animes = [];
bool _isLoading = true;
int _currentPage = 1;
final ScrollController _scrollController = ScrollController();
bool _showBackToTop = false;
各个状态变量的作用如下:_selectedFilter 是当前选中的筛选项索引,默认为 0 表示热门;_animes 存储当前显示的动漫列表;_isLoading 控制是否显示骨架屏;_currentPage 是当前页码,为后续分页功能预留;_scrollController 是滚动控制器,用于回到顶部功能;_showBackToTop 控制是否显示回到顶部按钮。
注意 _selectedFilter 是一个整数而不是字符串。使用索引来标识选中项比使用字符串更高效,也更不容易出错。
筛选项的定义
筛选标签的文本定义为一个常量列表:
final List<String> _filters = [
'热门',
'当季',
'即将',
'随机',
];
这四个筛选项对应不同的数据来源:热门是评分最高、最受欢迎的动漫;当季是当前季度正在播出的新番;即将是即将上映的动漫,方便用户提前关注;随机是随机推荐一部动漫,增加探索的趣味性。
把筛选项定义为列表而不是硬编码在 UI 中,有几个好处:方便后续添加或修改筛选项,可以通过索引与 _selectedFilter 对应,代码更清晰,UI 构建逻辑更简洁。
生命周期管理
组件的初始化和销毁:
void initState() {
super.initState();
_loadAnimes();
_scrollController.addListener(_onScroll);
}
初始化时做两件事:加载默认筛选项(热门)的数据,注册滚动监听用于回到顶部功能。与首页不同的是,发现页只需要加载一个列表,逻辑更简单。
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
销毁时释放滚动控制器资源。这是一个好习惯,虽然 Dart 有垃圾回收,但显式释放可以避免潜在的内存问题。
回到顶部功能
发现页的内容可能很长,需要回到顶部功能:
void _onScroll() {
if (_scrollController.offset > 300 && !_showBackToTop) {
setState(() => _showBackToTop = true);
} else if (_scrollController.offset <= 300 && _showBackToTop) {
setState(() => _showBackToTop = false);
}
}
void _scrollToTop() {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
这段代码在第02篇已经详细讲过。简单回顾:滚动超过 300 像素时显示按钮,点击按钮平滑滚动回顶部。发现页和首页使用了相同的回到顶部逻辑,这是代码复用的好机会。如果项目中有更多页面需要这个功能,可以考虑封装成 Mixin。
数据加载逻辑
数据加载是发现页的核心逻辑。根据不同的筛选项,调用不同的接口:
Future<void> _loadAnimes() async {
setState(() => _isLoading = true);
加载开始时设置 _isLoading = true,触发界面显示骨架屏。这个模式在首页也用过,是处理加载状态的标准做法。
try {
List<Anime> animes = [];
switch (_selectedFilter) {
case 0:
animes = await ApiService.getTopAnime(page: _currentPage);
break;
case 1:
animes = await ApiService.getSeasonalAnime(page: _currentPage);
break;
case 2:
animes = await ApiService.getUpcomingAnime(page: _currentPage);
break;
case 3:
final random = await ApiService.getRandomAnime();
animes = random != null ? [random] : [];
break;
}
switch 语句根据筛选项调用不同的接口:case 0(热门)调用 getTopAnime,获取评分最高的动漫;case 1(当季)调用 getSeasonalAnime,获取当季新番;case 2(即将)调用 getUpcomingAnime,获取即将上映的动漫;case 3(随机)调用 getRandomAnime,获取一部随机动漫。
随机接口的特殊处理:getRandomAnime 返回的是单个动漫对象而不是列表,所以需要包装成列表。如果返回 null(比如网络错误),就使用空列表。
page 参数:前三个接口都支持分页,传入 _currentPage 可以获取指定页的数据。目前我们只用到第一页,但这个设计为后续的分页加载功能预留了扩展空间。
setState(() {
_animes = animes;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
数据加载完成后更新状态。注意这里没有检查 mounted,因为发现页通常不会在加载过程中被销毁。但如果你想更严谨,加上 mounted 检查也是可以的。错误处理方面,捕获异常后只是停止加载状态,没有显示错误提示。在实际项目中,可以考虑添加错误提示或重试机制。
页面主体结构
发现页使用 Column 布局,将筛选栏和内容区域垂直排列:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('发现')),
body: Column(
children: [
Scaffold 提供了基本的页面框架。AppBar 只有一个标题,没有其他操作按钮,保持简洁。body 使用 Column 而不是 CustomScrollView,因为我们希望筛选栏固定在顶部,只有下方的内容区域可以滚动。
筛选标签栏的实现
筛选标签栏是发现页的特色功能,使用 FilterChip 组件实现:
SizedBox(
height: 50,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: _filters.length,
itemBuilder: (_, i) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: FilterChip(
label: Text(_filters[i]),
selected: _selectedFilter == i,
onSelected: (selected) {
setState(() {
_selectedFilter = i;
_currentPage = 1;
});
_loadAnimes();
},
),
),
),
),
布局结构分析:SizedBox(height: 50) 给筛选栏一个固定高度,这很重要,因为 ListView 在 Column 中需要明确的高度约束;ListView.builder 是横向滚动的列表,当筛选项很多时可以左右滑动;scrollDirection: Axis.horizontal 设置为横向滚动;padding 左右各 8 像素的内边距,让内容不会贴边。
FilterChip 组件详解:FilterChip 是 Material Design 提供的筛选标签组件,非常适合这种场景。它有几个关键属性:label 是标签显示的文本,selected 表示是否选中,这里通过 _selectedFilter == i 判断,onSelected 是点击时的回调。
点击处理逻辑分三步:更新 _selectedFilter 为当前点击的索引,重置 _currentPage 为 1(切换筛选项时从第一页开始),调用 _loadAnimes() 加载新数据。这三步操作放在一起,确保了切换筛选项时的正确行为。
为什么选择 FilterChip 而不是其他组件?
Flutter 提供了多种 Chip 组件:Chip 是基础标签,没有交互;ActionChip 是可点击的标签,适合触发操作;FilterChip 是可选中的标签,适合筛选场景;ChoiceChip 是单选标签,适合互斥选择;InputChip 是可删除的标签,适合输入场景。
FilterChip 最适合我们的场景,因为它有选中/未选中两种状态,选中时会有视觉反馈(背景色变化),符合 Material Design 规范。
内容区域的实现
内容区域根据加载状态显示骨架屏或实际内容:
Expanded(
child: _isLoading
? const ShimmerLoading(itemCount: 8, isGrid: true)
: GridView.builder(
controller: _scrollController,
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]),
),
),
Expanded 的作用是让内容区域占据 Column 中剩余的所有空间。筛选栏高度固定为 50 像素,剩下的空间全部给内容区域。
条件渲染使用三元表达式根据 _isLoading 状态决定显示什么。加载中显示骨架屏,加载完成显示网格列表。
GridView.builder 配置:controller: _scrollController 绑定滚动控制器,用于回到顶部功能;padding 四周 12 像素的内边距;crossAxisCount: 2 每行 2 个卡片;childAspectRatio: 0.7 卡片宽高比,高度是宽度的 1.43 倍;crossAxisSpacing 和 mainAxisSpacing 是卡片之间的间距。
与首页不同的是,这里的 GridView 不需要设置 shrinkWrap 和 NeverScrollableScrollPhysics,因为它是 Expanded 的直接子组件,有明确的高度约束,可以正常滚动。
悬浮按钮
回到顶部按钮的实现:
floatingActionButton: _showBackToTop
? FloatingActionButton(
mini: true,
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward),
)
: null,
);
}
}
与首页完全相同的实现。mini: true 让按钮小一些,不会太抢眼。
筛选切换的用户体验优化
当前的实现在切换筛选项时会显示骨架屏,这是一个合理的做法。但还有一些可以优化的地方:
保持滚动位置
当前实现在切换筛选项后,列表会回到顶部。如果用户切换回之前的筛选项,可能希望回到之前的滚动位置。可以为每个筛选项保存滚动位置:
final Map<int, double> _scrollPositions = {};
void _onFilterChanged(int newFilter) {
// 保存当前位置
_scrollPositions[_selectedFilter] = _scrollController.offset;
// 切换筛选项
_selectedFilter = newFilter;
_loadAnimes();
// 恢复位置(需要在数据加载完成后执行)
}
缓存已加载的数据
每次切换筛选项都重新加载数据会消耗流量。可以缓存每个筛选项的数据:
final Map<int, List<Anime>> _cachedData = {};
Future<void> _loadAnimes() async {
// 如果有缓存,直接使用
if (_cachedData.containsKey(_selectedFilter)) {
setState(() {
_animes = _cachedData[_selectedFilter]!;
_isLoading = false;
});
return;
}
// 否则从网络加载
// ...
}
添加切换动画
可以使用 AnimatedSwitcher 给内容切换添加淡入淡出动画:
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _isLoading
? const ShimmerLoading(key: ValueKey('loading'))
: GridView.builder(key: ValueKey(_selectedFilter), ...),
)
随机推荐的特殊处理
"随机"筛选项与其他三个不同,它只返回一部动漫。当前的实现会在网格中只显示一个卡片,看起来有点空。可以考虑一些优化:
显示更多随机推荐
可以连续调用多次随机接口,获取多部动漫:
case 3:
final futures = List.generate(6, (_) => ApiService.getRandomAnime());
final results = await Future.wait(futures);
animes = results.whereType<Anime>().toList();
break;
使用不同的布局
对于随机推荐,可以使用单卡片大图展示,而不是网格布局。这样一部动漫也能撑满屏幕,用户体验更好。
空状态处理
当前实现没有处理数据为空的情况。如果接口返回空列表,用户会看到一个空白的网格区域。可以添加空状态提示:
Expanded(
child: _isLoading
? const ShimmerLoading(itemCount: 8, isGrid: true)
: _animes.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text('暂无数据'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadAnimes,
child: const Text('重新加载'),
),
],
),
)
: GridView.builder(...),
)
空状态提示包含三个元素:图标、文字说明、重试按钮。这种设计让用户知道发生了什么,并且可以尝试重新加载。
与首页的代码复用
发现页和首页有很多相似的代码,比如骨架屏的使用方式、回到顶部功能、GridView 的配置、加载状态的处理。
如果项目中有更多类似的页面,可以考虑抽取公共逻辑:
创建基础列表页面类
可以创建一个抽象基类,封装通用的加载、刷新、回到顶部等功能,子类只需要实现数据获取和 UI 构建。
提取公共组件
比如把"加载中/空状态/内容"的三态切换封装成一个组件,减少重复代码。
小结
发现页的实现展示了几个重要的开发模式:
- 筛选标签的实现:使用
FilterChip配合状态变量,实现可切换的筛选功能 - 根据状态调用不同接口:使用
switch语句根据筛选项调用对应的 API - 固定头部+可滚动内容:使用
Column+Expanded实现这种常见布局 - 状态驱动 UI:通过
_isLoading和_selectedFilter等状态变量控制界面显示
发现页虽然比首页简单,但它展示了一种不同的交互模式。用户可以主动选择想看的内容类型,而不是被动接受推荐。这种设计给了用户更多的控制感,是内容型应用中常见的设计模式。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)