通过网盘分享的文件: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) 给筛选栏一个固定高度,这很重要,因为 ListViewColumn 中需要明确的高度约束;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 倍;crossAxisSpacingmainAxisSpacing 是卡片之间的间距。

与首页不同的是,这里的 GridView 不需要设置 shrinkWrapNeverScrollableScrollPhysics,因为它是 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 构建。

提取公共组件

比如把"加载中/空状态/内容"的三态切换封装成一个组件,减少重复代码。

小结

发现页的实现展示了几个重要的开发模式:

  1. 筛选标签的实现:使用 FilterChip 配合状态变量,实现可切换的筛选功能
  2. 根据状态调用不同接口:使用 switch 语句根据筛选项调用对应的 API
  3. 固定头部+可滚动内容:使用 Column + Expanded 实现这种常见布局
  4. 状态驱动 UI:通过 _isLoading_selectedFilter 等状态变量控制界面显示

发现页虽然比首页简单,但它展示了一种不同的交互模式。用户可以主动选择想看的内容类型,而不是被动接受推荐。这种设计给了用户更多的控制感,是内容型应用中常见的设计模式。


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

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐