通过网盘分享的文件: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 倍,卡片比较扁平,适合显示文字内容。

crossAxisSpacingmainAxisSpacing 都是 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

Logo

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

更多推荐