通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97

首页是用户打开应用后最常停留的页面,它的设计直接影响用户对整个应用的印象。微动漫App的首页采用了经典的内容聚合布局:顶部是横向滚动的热门动漫推荐,下方是网格展示的当季新番。本文将完整讲解首页的实现过程,包括数据加载、下拉刷新、骨架屏、空状态处理等实用功能。
请添加图片描述

首页的整体设计思路

在开始写代码之前,我们先梳理一下首页需要实现的功能:

  • 展示热门动漫列表,支持横向滚动浏览
  • 展示当季动漫列表,使用网格布局
  • 支持下拉刷新,重新加载数据
  • 加载过程中显示骨架屏,提升用户体验
  • 数据为空时显示友好的空状态提示
  • 顶部导航栏带搜索入口

这些功能组合在一起,构成了一个完整的首页体验。接下来我们逐步实现。

页面基础结构

首页是一个有状态的组件,因为需要管理数据加载状态、动漫列表等状态。

import 'package:flutter/material.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../services/api_service.dart';
import '../models/anime.dart';
import '../widgets/anime_card.dart';
import '../widgets/shimmer_loading.dart';
import 'search_screen.dart';

首页需要导入的依赖:

  • pull_to_refresh:第三方下拉刷新组件,提供了比原生更丰富的刷新效果
  • ApiService:封装好的接口服务,用于获取动漫数据
  • Anime:动漫数据模型
  • AnimeCard:动漫卡片组件,用于展示单个动漫
  • ShimmerLoading:骨架屏组件,加载时显示
  • SearchScreen:搜索页面,点击搜索图标跳转
class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  
  State<HomeScreen> createState() => _HomeScreenState();
}

标准的 StatefulWidget 定义。首页需要管理多个状态,所以必须使用有状态组件。

状态变量的定义

首页需要管理的状态比较多,我们逐一声明:

class _HomeScreenState extends State<HomeScreen> {
  List<Anime> _topAnime = [];
  List<Anime> _seasonalAnime = [];
  bool _isLoading = true;
  final RefreshController _refreshController = RefreshController();
  final ScrollController _scrollController = ScrollController();
  bool _showBackToTop = false;

状态变量说明

  • _topAnime:热门动漫列表,显示在页面顶部的横向滚动区域
  • _seasonalAnime:当季动漫列表,显示在页面下方的网格区域
  • _isLoading:加载状态标志,控制是否显示骨架屏
  • _refreshController:下拉刷新控制器,用于控制刷新状态
  • _scrollController:滚动控制器,用于实现回到顶部功能
  • _showBackToTop:是否显示回到顶部按钮

变量命名以下划线开头表示私有,这是 Dart 的约定。私有变量只能在当前文件内访问。

初始化与生命周期

组件创建时需要加载数据,销毁时需要释放资源:

  
  void initState() {
    super.initState();
    _loadData();
    _scrollController.addListener(_onScroll);
  }

initState 中做两件事:

  1. 调用 _loadData() 开始加载首页数据
  2. 给滚动控制器添加监听,用于实现回到顶部功能

数据加载放在 initState 而不是构造函数中,是因为 initState 是组件生命周期的正式开始,此时组件已经被插入到 Widget 树中,可以安全地进行各种操作。

  
  void dispose() {
    _refreshController.dispose();
    _scrollController.removeListener(_onScroll);
    _scrollController.dispose();
    super.dispose();
  }

dispose 中释放所有资源:

  1. 销毁刷新控制器
  2. 移除滚动监听
  3. 销毁滚动控制器

资源释放的顺序很重要:先移除监听器,再销毁控制器。如果顺序反了,可能会在销毁后还尝试调用监听器,导致错误。

数据加载逻辑

数据加载是首页最核心的功能。我们需要同时获取热门动漫和当季动漫两个列表。

  Future<void> _loadData() async {
    print('🔄 HomeScreen: Starting to load data...');
    setState(() => _isLoading = true);

加载开始时,先将 _isLoading 设为 true,这会触发界面显示骨架屏。

print 语句是调试用的日志,在开发阶段可以帮助我们追踪数据加载的过程。正式发布时可以移除或者使用专门的日志框架。

    try {
      print('📡 HomeScreen: Fetching top anime...');
      final top = await ApiService.getTopAnime();
      print('📡 HomeScreen: Fetching seasonal anime...');
      final seasonal = await ApiService.getSeasonalAnime();

使用 await 依次获取两个列表。这里是串行请求,如果想要并行请求可以使用 Future.wait

final results = await Future.wait([
  ApiService.getTopAnime(),
  ApiService.getSeasonalAnime(),
]);
final top = results[0];
final seasonal = results[1];

并行请求可以减少总的等待时间,但代码稍微复杂一些。根据实际情况选择。

      if (mounted) {
        setState(() {
          _topAnime = top;
          _seasonalAnime = seasonal;
          _isLoading = false;
        });
        print('✅ HomeScreen: Data loaded - Top: ${top.length}, Seasonal: ${seasonal.length}');
      }

mounted 检查非常重要。在异步操作完成时,组件可能已经被销毁(比如用户快速切换页面)。如果不检查就直接调用 setState,会抛出异常。

数据加载成功后,更新状态变量并将 _isLoading 设为 false,界面会从骨架屏切换到实际内容。

    } catch (e) {
      print('❌ HomeScreen: Error loading data: $e');
      if (mounted) {
        setState(() => _isLoading = false);
      }
    }
    _refreshController.refreshCompleted();
  }

错误处理:即使加载失败,也要将 _isLoading 设为 false,否则用户会一直看到骨架屏。

_refreshController.refreshCompleted():通知刷新控制器刷新已完成,让刷新指示器收起。无论成功还是失败都要调用,否则刷新指示器会一直显示。

页面主体结构

首页使用 Scaffold 作为基础框架:

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('微动漫'),
        elevation: 0,
        actions: [
          IconButton(
            icon: const Icon(Icons.search),
            onPressed: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const SearchScreen()),
            ),
          ),
        ],
      ),
      body: _buildHomeContent(),
      floatingActionButton: _showBackToTop
          ? FloatingActionButton(
              mini: true,
              onPressed: _scrollToTop,
              child: const Icon(Icons.arrow_upward),
            )
          : null,
    );
  }

AppBar 配置

  • title:应用名称"微动漫"
  • elevation: 0:去掉阴影,让导航栏看起来更扁平
  • actions:右侧操作按钮,这里放了一个搜索图标

搜索按钮:点击后使用 Navigator.push 跳转到搜索页面。MaterialPageRoute 提供了标准的页面切换动画。

悬浮按钮:根据 _showBackToTop 状态决定是否显示回到顶部按钮。使用三元表达式,当不需要显示时返回 null

内容区域的三种状态

首页内容区域有三种状态:加载中、数据为空、正常显示。我们用一个方法来处理这三种情况:

  Widget _buildHomeContent() {
    if (_isLoading) {
      return const ShimmerLoading(itemCount: 8, isGrid: true);
    }

加载状态:显示骨架屏。ShimmerLoading 是我们自定义的骨架屏组件,itemCount: 8 表示显示 8 个占位卡片,isGrid: true 表示使用网格布局。

骨架屏比简单的加载圈更友好,它让用户预知内容的大致布局,减少等待的焦虑感。

    if (_topAnime.isEmpty && _seasonalAnime.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.movie, size: 64, color: Colors.grey[400]),
            const SizedBox(height: 16),
            const Text('暂无数据'),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: _loadData,
              child: const Text('重新加载'),
            ),
          ],
        ),
      );
    }

空状态:当两个列表都为空时,显示一个友好的空状态提示。

空状态的设计要点:

  • 一个相关的图标,让用户知道这是什么类型的内容
  • 简短的文字说明
  • 一个操作按钮,让用户可以尝试重新加载

这比只显示一行"暂无数据"要友好得多。用户看到按钮会知道可以做什么,而不是茫然不知所措。

下拉刷新的实现

正常状态下,内容区域使用 SmartRefresher 包裹,实现下拉刷新功能:

    return SmartRefresher(
      controller: _refreshController,
      onRefresh: _loadData,
      child: SingleChildScrollView(
        controller: _scrollController,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [

SmartRefresherpull_to_refresh 包提供的组件,比 Flutter 原生的 RefreshIndicator 功能更丰富。

  • controller:刷新控制器,用于控制刷新状态
  • onRefresh:下拉刷新时的回调,这里直接调用 _loadData

SingleChildScrollView 让整个内容区域可以滚动。我们把滚动控制器绑定到这里,用于实现回到顶部功能。

crossAxisAlignment: CrossAxisAlignment.start:让 Column 的子组件左对齐。

热门动漫横向列表

首页顶部是热门动漫的横向滚动列表:

            Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text(
                    '热门动漫',
                    style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 12),

每个区块都用 Padding 包裹,保持统一的边距。标题使用 18 号粗体字,与内容形成层次对比。

                  if (_topAnime.isNotEmpty)
                    SizedBox(
                      height: 200,
                      child: ListView.builder(
                        scrollDirection: Axis.horizontal,
                        itemCount: _topAnime.take(5).length,
                        itemBuilder: (_, i) => Padding(
                          padding: const EdgeInsets.only(right: 12),
                          child: SizedBox(
                            width: 120,
                            child: AnimeCard(anime: _topAnime[i], showRank: true),
                          ),
                        ),
                      ),
                    )

横向列表的实现要点

  • SizedBox(height: 200):必须给横向列表一个固定高度,否则 Flutter 不知道该分配多少空间
  • scrollDirection: Axis.horizontal:设置为横向滚动
  • _topAnime.take(5):只取前 5 个,首页不需要显示太多
  • width: 120:每个卡片宽度固定为 120 像素
  • showRank: true:显示排名标签,因为这是热门榜单

卡片之间使用 Padding 添加 12 像素的右边距,让卡片之间有适当的间隔。

                  else
                    const SizedBox(
                      height: 200,
                      child: Center(child: Text('暂无热门动漫')),
                    ),
                ],
              ),
            ),

当热门列表为空时,显示一个占位提示。保持相同的高度,避免布局跳动。

当季动漫网格列表

页面下方是当季动漫的网格展示:

            Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text(
                    '当季动漫',
                    style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 12),

与热门区块相同的标题样式,保持视觉一致性。

                  if (_seasonalAnime.isNotEmpty)
                    GridView.builder(
                      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                        crossAxisCount: 2,
                        childAspectRatio: 0.7,
                        crossAxisSpacing: 12,
                        mainAxisSpacing: 12,
                      ),
                      shrinkWrap: true,
                      physics: const NeverScrollableScrollPhysics(),
                      itemCount: _seasonalAnime.take(6).length,
                      itemBuilder: (_, i) => AnimeCard(anime: _seasonalAnime[i]),
                    )

网格布局的关键配置

  • crossAxisCount: 2:每行显示 2 个卡片
  • childAspectRatio: 0.7:卡片宽高比为 0.7,即高度是宽度的 1.43 倍,适合海报比例
  • crossAxisSpacing: 12:列间距 12 像素
  • mainAxisSpacing: 12:行间距 12 像素

shrinkWrap: true 非常重要。默认情况下,GridView 会尽可能占据所有可用空间。但我们的 GridView 是放在 SingleChildScrollView 里面的,如果不设置 shrinkWrap,会导致布局冲突。设置为 true 后,GridView 只会占据实际内容需要的高度。

physics: NeverScrollableScrollPhysics() 禁用 GridView 自身的滚动。因为外层已经有 SingleChildScrollView 处理滚动了,如果 GridView 也能滚动,会造成滚动冲突,用户体验很差。

                  else
                    const SizedBox(
                      height: 200,
                      child: Center(child: Text('暂无当季动漫')),
                    ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

同样,当季列表为空时显示占位提示。

骨架屏组件详解

骨架屏是提升加载体验的重要手段。我们来看看 ShimmerLoading 组件的实现:

class ShimmerLoading extends StatelessWidget {
  final int itemCount;
  final bool isGrid;

  const ShimmerLoading({super.key, this.itemCount = 6, this.isGrid = true});

骨架屏组件接收两个参数:

  • itemCount:显示多少个占位卡片
  • isGrid:是否使用网格布局,否则使用列表布局
  
  Widget build(BuildContext context) {
    final isDark = Theme.of(context).brightness == Brightness.dark;
    final baseColor = isDark ? Colors.grey[800]! : Colors.grey[300]!;
    final highlightColor = isDark ? Colors.grey[700]! : Colors.grey[100]!;

适配深色模式:骨架屏的颜色需要根据当前主题调整。深色模式下使用深灰色,浅色模式下使用浅灰色。

Shimmer 效果需要两个颜色:

  • baseColor:基础颜色,占位块的默认颜色
  • highlightColor:高亮颜色,闪烁动画经过时的颜色
    if (isGrid) {
      return GridView.builder(
        padding: const EdgeInsets.all(16),
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          childAspectRatio: 0.7,
          crossAxisSpacing: 12,
          mainAxisSpacing: 12,
        ),
        itemCount: itemCount,
        itemBuilder: (_, __) => Shimmer.fromColors(
          baseColor: baseColor,
          highlightColor: highlightColor,
          child: Container(
            decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(12),
            ),
          ),
        ),
      );
    }

网格布局的骨架屏,配置与实际内容的 GridView 保持一致,这样加载完成后切换时不会有布局跳动。

Shimmer.fromColorsshimmer 包提供的组件,它会给子组件添加闪烁动画效果。子组件是一个圆角矩形,模拟卡片的形状。

回到顶部功能

首页内容较多时,用户可能需要快速回到顶部。这个功能的实现在第02篇已经详细讲过,这里简单回顾:

  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,
    );
  }

滚动超过 300 像素时显示按钮,点击按钮平滑滚动回顶部。

一些优化建议

首页作为应用的门面,有一些优化点值得考虑:

数据缓存

每次进入首页都重新加载数据会消耗流量和时间。可以考虑将数据缓存到本地,下次进入时先显示缓存数据,同时在后台刷新。

图片预加载

横向列表中的图片可以预加载,这样用户滑动时不会看到加载过程。Flutter 提供了 precacheImage 方法可以实现这个功能。

分页加载

当前实现只加载固定数量的数据。如果数据量大,可以实现分页加载,用户滚动到底部时自动加载更多。

错误重试

当前的错误处理比较简单,只是停止加载。可以添加自动重试机制,或者显示更详细的错误信息和重试按钮。

小结

首页的实现涉及到很多 Flutter 开发中的常见模式:

  1. 状态管理:使用 StatefulWidget 管理加载状态和数据
  2. 异步数据加载:使用 async/await 获取数据,注意 mounted 检查
  3. 下拉刷新:使用 SmartRefresher 实现下拉刷新功能
  4. 多种布局:横向列表用 ListView,网格用 GridView
  5. 嵌套滚动shrinkWrapNeverScrollableScrollPhysics 的配合使用
  6. 骨架屏:使用 Shimmer 提升加载体验
  7. 空状态处理:友好的空状态提示和操作引导

这些模式在其他页面的开发中也会反复用到,掌握了首页的实现,其他页面就会轻松很多。


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

Logo

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

更多推荐