Flutter for OpenHarmony 微动漫App实战:时间表实现
通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97
追番的人都知道,每周最期待的就是等更新。周一有什么番、周三有什么番、周末又有什么番,这些信息对追番用户来说非常重要。时间表功能就是为了解决这个需求,让用户一目了然地知道每天有哪些动漫更新。
这篇文章会实现一个完整的时间表页面,同时也会讲讲列表项组件的设计,因为时间表和列表项是配套使用的。代码都是项目里实际跑着的。

时间表的产品思考
在写代码之前,先想想时间表应该怎么设计。
最直观的方式是按星期几分组,周一的番放一起,周二的放一起,以此类推。用户想看某一天的更新,直接找到对应的分组就行。
但 API 返回的数据可能不是按星期分组的,需要我们自己处理。这篇文章先实现一个简单版本,把所有数据放在一个列表里展示,后续可以再优化成分组形式。
页面的基本结构
时间表页面用 FutureBuilder 来处理异步数据:
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 ScheduleScreen extends StatefulWidget {
const ScheduleScreen({super.key});
State<ScheduleScreen> createState() => _ScheduleScreenState();
}
虽然用了 StatefulWidget,但状态管理很简单,主要是为了在 initState 里初始化 Future。
用 late 声明 Future
class _ScheduleScreenState extends State<ScheduleScreen> {
late Future<Map<String, List<Anime>>> _scheduleFuture;
void initState() {
super.initState();
_scheduleFuture = ApiService.getSchedule();
}
late 关键字告诉 Dart 这个变量会在使用前被赋值,不需要在声明时初始化。这样可以在 initState 里赋值,而不是在构造函数里。
为什么要把 Future 存成状态变量?因为如果直接在 FutureBuilder 的 future 参数里调用 ApiService.getSchedule(),每次 build 方法执行时都会创建新的 Future,导致重复请求。把 Future 存起来,就只会请求一次。
FutureBuilder 的使用
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('时间表')),
body: FutureBuilder<Map<String, List<Anime>>>(
future: _scheduleFuture,
builder: (context, snapshot) {
FutureBuilder 会自动监听 Future 的状态变化,根据不同状态返回不同的 Widget。泛型参数 Map<String, List<Anime>> 指定了 Future 的返回类型。
if (snapshot.connectionState == ConnectionState.waiting) {
return const ShimmerLoading(itemCount: 8, isGrid: false);
}
ConnectionState.waiting 表示 Future 还没完成,显示骨架屏。8 个占位项,列表形式。
空状态处理
if (!snapshot.hasData || snapshot.data!.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.schedule, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text('暂无时间表'),
],
),
);
}
数据为空时显示空状态页面。用日程图标配合提示文字,让用户知道这里会显示什么内容。
!snapshot.hasData 检查数据是否存在,snapshot.data!.isEmpty 检查数据是否为空。两个条件用 || 连接,任一满足就显示空状态。
渲染列表数据
final schedule = snapshot.data!;
final animes = schedule['all'] ?? [];
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: animes.length,
itemBuilder: (_, i) => AnimeListTile(anime: animes[i]),
);
},
),
);
}
从 schedule 里取出 all 键对应的列表,如果不存在就用空列表。然后用 ListView.builder 渲染,每一项是一个 AnimeListTile。
这里的数据结构是 Map<String, List<Anime>>,设计成这样是为了后续支持按星期分组。目前 API 返回的数据都放在 all 键下面。
AnimeListTile 组件详解
列表项组件在很多地方都会用到,值得仔细讲讲:
class AnimeListTile extends StatelessWidget {
final Anime anime;
final VoidCallback? onDelete;
const AnimeListTile({super.key, required this.anime, this.onDelete});
两个参数:anime 是必传的动漫数据,onDelete 是可选的删除回调。有删除回调时支持滑动删除,没有时禁用滑动。
这种设计让组件可以在不同场景复用:时间表不需要删除功能,收藏列表和历史记录需要。
滑动删除的实现
Widget build(BuildContext context) {
return Dismissible(
key: Key(anime.malId.toString()),
direction: onDelete != null ? DismissDirection.endToStart : DismissDirection.none,
onDismissed: (_) => onDelete?.call(),
Dismissible 是 Flutter 提供的滑动删除组件。key 必须唯一,用动漫 ID 转成字符串。
direction 控制滑动方向,endToStart 是从右往左滑。根据 onDelete 是否为 null 决定是否启用滑动,这样同一个组件在不同场景下表现不同。
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
color: Colors.red,
child: const Icon(Icons.delete, color: Colors.white),
),
滑动时显示的背景,红色配白色删除图标,给用户明确的视觉反馈。图标靠右对齐,因为是从右往左滑。
ListTile 的使用
child: ListTile(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => AnimeDetailScreen(anime: anime)),
),
ListTile 是 Material Design 的标准列表项组件,自带 leading、title、subtitle、trailing 四个插槽。点击跳转到详情页。
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 50,
height: 70,
child: _buildImage(),
),
),
左侧是封面图,用 ClipRRect 加圆角。固定宽高 50x70,比例接近海报的比例。
标题和副标题
title: Text(
anime.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
标题最多两行,超出显示省略号。fontWeight: FontWeight.w600 是半粗体,比普通字体重一点但不会太粗。
subtitle: Row(
children: [
if (anime.score != null) ...[
const Icon(Icons.star, color: Colors.amber, size: 14),
const SizedBox(width: 2),
Text(anime.score!.toStringAsFixed(1)),
const SizedBox(width: 8),
],
if (anime.type != null) Text(anime.type!),
],
),
副标题显示评分和类型。用 if 语句处理可能为 null 的情况,有评分才显示星星和分数,有类型才显示类型。
...[] 是 Dart 的展开操作符,可以在列表里条件性地添加多个元素。
trailing: const Icon(Icons.chevron_right),
),
);
}
右侧是箭头图标,提示用户可以点击进入详情。
封面图片的加载
Widget _buildImage() {
final imageUrl = anime.imageUrl;
if (imageUrl == null || imageUrl.isEmpty) {
return Container(
color: Colors.grey[300],
child: const Icon(Icons.movie),
);
}
先检查 URL 是否有效,无效就显示占位图。灰色背景配电影图标,简洁明了。
return Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(color: Colors.grey[300]);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[300],
child: const Icon(Icons.movie),
);
},
);
}
loadingBuilder 在图片加载过程中显示灰色背景,errorBuilder 在加载失败时显示占位图。这样不管网络状况如何,界面都不会出现空白或报错。
API 获取时间表数据
来看看 ApiService 是怎么获取时间表数据的:
static Future<Map<String, List<Anime>>> getSchedule() async {
try {
await _rateLimit();
final url = '$jikanBaseUrl/schedules';
print('Fetching schedule: $url');
final response = await http.get(Uri.parse(url)).timeout(
const Duration(seconds: 10),
onTimeout: () => http.Response('timeout', 408),
);
调用 /schedules 接口获取时间表数据。设置 10 秒超时,超时后返回假的 408 响应。
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} scheduled animes');
return {'all': animes};
} else {
print('Error: ${response.statusCode}');
return {};
}
} catch (e) {
print('Error fetching schedule: $e');
return {};
}
}
解析 JSON 数据,把所有动漫放在 all 键下面返回。失败时返回空 Map。
分类页面的实现思路
时间表和分类页面有相似之处,都是先展示一个列表,点击后进入详情。来看看分类页面是怎么做的:
class _GenreScreenState extends State<GenreScreen> {
List<Map<String, dynamic>> _genres = [];
bool _isLoading = true;
void initState() {
super.initState();
_loadGenres();
}
分类页面用手动管理状态的方式,而不是 FutureBuilder。两种方式各有优劣,FutureBuilder 代码更简洁,手动管理更灵活。
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。
分类卡片的网格布局
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]),
),
分类用网格展示,两列布局。childAspectRatio: 1.5 让卡片宽度是高度的 1.5 倍,比较扁平,适合显示分类名称。
分类卡片的样式
Widget _buildGenreCard(Map<String, dynamic> genre) {
return GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => GenreDetailScreen(
genreId: genre['mal_id'],
genreName: genre['name'],
),
),
),
点击卡片跳转到分类详情页,传入分类 ID 和名称。
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),
],
),
),
卡片用渐变色背景,从主色到次色,70% 透明度让颜色不会太重。圆角 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),
),
],
),
),
),
);
}
卡片内容居中显示,分类名称用白色粗体,下面显示该分类有多少部动漫。Colors.white70 是 70% 透明度的白色,比纯白淡一点,形成层次。
分类详情页
class GenreDetailScreen extends StatefulWidget {
final int genreId;
final String genreName;
const GenreDetailScreen({
super.key,
required this.genreId,
required this.genreName,
});
分类详情页接收两个参数:分类 ID 用于请求数据,分类名称用于显示标题。
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]),
),
);
}
详情页用网格展示该分类下的动漫,和首页的当季动漫布局一样。childAspectRatio: 0.7 让卡片高度是宽度的 1.43 倍,适合显示动漫封面。
按分类获取动漫
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),
);
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 [];
}
}
用 genres 参数筛选指定分类的动漫。API 支持传多个分类 ID,用逗号分隔,但这里只传一个。
时间表的优化方向
目前的时间表实现比较简单,有几个可以优化的方向:
按星期分组:把动漫按播出日期分组,用 TabBar 或 ExpansionTile 展示。用户可以快速找到某一天的更新。
显示播出时间:除了日期,还可以显示具体的播出时间,比如"周一 23:00"。
提醒功能:用户可以设置提醒,到了播出时间推送通知。这个需要用到本地通知功能。
缓存数据:时间表数据一周内不会变化,可以缓存起来,减少网络请求。
这些优化可以根据实际需求逐步添加,先把基础功能做好,再慢慢完善。
小结
时间表功能涉及的知识点:FutureBuilder 的使用、late 关键字、Dismissible 滑动删除、ListTile 列表项、网格布局、渐变色背景、页面间参数传递。
时间表和分类页面的实现思路很相似:先展示一个列表或网格,点击后进入详情。掌握了这个模式,很多类似的页面都能快速实现。
追番用户对时间表功能的需求很强,做好这个功能能提升用户粘性。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)