通过网盘分享的文件: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 存成状态变量?因为如果直接在 FutureBuilderfuture 参数里调用 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,用逗号分隔,但这里只传一个。


时间表的优化方向

目前的时间表实现比较简单,有几个可以优化的方向:

按星期分组:把动漫按播出日期分组,用 TabBarExpansionTile 展示。用户可以快速找到某一天的更新。

显示播出时间:除了日期,还可以显示具体的播出时间,比如"周一 23:00"。

提醒功能:用户可以设置提醒,到了播出时间推送通知。这个需要用到本地通知功能。

缓存数据:时间表数据一周内不会变化,可以缓存起来,减少网络请求。

这些优化可以根据实际需求逐步添加,先把基础功能做好,再慢慢完善。


小结

时间表功能涉及的知识点:FutureBuilder 的使用late 关键字Dismissible 滑动删除ListTile 列表项网格布局渐变色背景页面间参数传递

时间表和分类页面的实现思路很相似:先展示一个列表或网格,点击后进入详情。掌握了这个模式,很多类似的页面都能快速实现。

追番用户对时间表功能的需求很强,做好这个功能能提升用户粘性。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐