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

详情页是整个App里最复杂的页面,没有之一。用户从列表点进来,期望看到动漫的完整信息:封面大图、评分排名、剧情简介、播出状态、制作公司……信息量很大,怎么组织这些内容让用户看得舒服,是个技术活。

这篇文章会从头实现一个完整的动漫详情页,包括可折叠的头部大图、信息展示、收藏和分享功能、以及跳转到角色列表和推荐动漫的入口。代码都是项目里实际跑着的,踩过的坑也会一并分享。


请添加图片描述

详情页要解决什么问题

在动手写代码之前,先想清楚详情页要做什么:

信息展示:标题、日文名、评分、排名、类型、状态、集数、年份、季度、简介……这些信息要有层次地展示出来,不能一股脑堆在一起。

交互功能:收藏、分享、查看角色、查看推荐、查看新闻。这些功能要放在用户容易找到的地方。

视觉效果:头部大图要有冲击力,滚动时要有折叠效果,整体要好看。

想清楚这些,代码写起来就有方向了。


页面的基本结构

详情页需要管理加载状态和动漫数据,所以用 StatefulWidget

class AnimeDetailScreen extends StatefulWidget {
  final Anime anime;

  const AnimeDetailScreen({super.key, required this.anime});

  
  State<AnimeDetailScreen> createState() => _AnimeDetailScreenState();
}

构造函数接收一个 Anime 对象,这是从列表页传过来的基础数据。为什么说是"基础数据"?因为列表接口返回的信息不全,详情页需要再调一次详情接口获取完整信息。

class _AnimeDetailScreenState extends State<AnimeDetailScreen> {
  late Anime _anime;
  bool _isLoading = true;

_anime 存储当前显示的动漫数据,初始值是传进来的基础数据,加载完详情后会更新。_isLoading 控制是否显示加载状态。


初始化时做两件事


void initState() {
  super.initState();
  _anime = widget.anime;
  _loadDetails();

先把传进来的数据赋值给 _anime,这样页面能立即显示基础信息,不用等详情接口返回。这是个小技巧,能让用户感觉页面加载很快。

  Future.delayed(const Duration(milliseconds: 500), () {
    if (mounted) {
      context.read<HistoryProvider>().addToHistory(_anime);
    }
  });
}

延迟 500 毫秒后把这个动漫添加到历史记录。为什么要延迟?因为用户可能只是误点进来,马上就退出了,这种情况不应该算作"浏览过"。延迟一下,只有真正停留的才会记录。

mounted 检查很重要,如果用户在 500 毫秒内退出了页面,这时候 context 已经不可用了,直接调用会报错。


加载详情数据

Future<void> _loadDetails() async {
  try {
    final details = await ApiService.getAnimeDetails(_anime.malId);
    if (details != null && mounted) {
      setState(() => _anime = details);
    }
  } catch (e) {
    print('加载详情失败: $e');
  }

调用详情接口,成功后更新 _anime。这里又检查了一次 mounted,异步操作完成时页面可能已经被销毁了。

  if (mounted) {
    setState(() => _isLoading = false);
  }
}

不管成功还是失败,都要把 _isLoading 设为 false,否则页面会一直显示加载状态。


使用 CustomScrollView 实现折叠头部

详情页最酷的效果是头部大图在滚动时会折叠,这个用 CustomScrollView 配合 SliverAppBar 实现:


Widget build(BuildContext context) {
  return Scaffold(
    body: CustomScrollView(
      slivers: [
        SliverAppBar(
          expandedHeight: 300,
          pinned: true,

CustomScrollView 是 Flutter 里做复杂滚动效果的利器,它的 children 必须是 Sliver 系列组件。

expandedHeight: 300 设置头部展开时的高度,pinned: true 让 AppBar 在折叠后固定在顶部,不会完全消失。


头部背景的实现

          flexibleSpace: FlexibleSpaceBar(
            background: Stack(
              fit: StackFit.expand,
              children: [
                _buildHeaderImage(),

FlexibleSpaceBar 是 SliverAppBar 的可伸缩区域,滚动时会自动缩放。用 Stack 叠加多个图层:底层是封面图,上层是渐变遮罩。

                Container(
                  decoration: BoxDecoration(
                    gradient: LinearGradient(
                      begin: Alignment.topCenter,
                      end: Alignment.bottomCenter,
                      colors: [
                        Colors.transparent,
                        Colors.black.withOpacity(0.7),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),

渐变遮罩从上到下,上面透明,下面是 70% 透明度的黑色。这样做有两个好处:让底部的文字更清晰让图片和下方内容有个过渡


AppBar 上的操作按钮

          actions: [
            Consumer<FavoritesProvider>(
              builder: (context, provider, _) {
                final isFav = provider.isFavorite(_anime.malId);
                return IconButton(
                  icon: Icon(
                    isFav ? Icons.favorite : Icons.favorite_border,
                    color: Colors.red,
                  ),

收藏按钮用 Consumer 包裹,这样收藏状态变化时按钮会自动更新。isFavorite 方法返回这个动漫是否已收藏,根据结果显示实心或空心爱心。

                  onPressed: () {
                    provider.toggleFavorite(_anime);
                    final newState = provider.isFavorite(_anime.malId);
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(
                        content: Text(newState ? '已添加到收藏' : '已从收藏移除'),
                        duration: const Duration(seconds: 1),
                      ),
                    );
                  },
                );
              },
            ),

点击后调用 toggleFavorite 切换收藏状态,然后用 SnackBar 给用户反馈。SnackBar 是 Material Design 的轻量级提示组件,从底部弹出,1 秒后自动消失。


分享按钮

            IconButton(
              icon: const Icon(Icons.share),
              onPressed: () async {
                final success = await ShareService.shareText(
                  title: _anime.title,
                  content: '${_anime.synopsis ?? '暂无简介'}\n\n来自微动漫App',
                );
                
                if (!success && mounted) {
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(content: Text('分享失败')),
                  );
                }
              },
            ),
          ],

分享功能调用 ShareService,这是我们封装的分享服务,底层会调用系统的分享能力。分享内容包括动漫标题和简介,末尾加上 App 名称做个小广告。

分享失败时显示提示,成功时不需要提示,因为系统分享面板本身就是反馈。


页面主体内容

        SliverToBoxAdapter(
          child: _isLoading
              ? const ShimmerLoading(itemCount: 1, isGrid: false)
              : _buildContent(),
        ),
      ],
    ),
  );
}

SliverToBoxAdapter 可以把普通 Widget 放进 CustomScrollView 里。加载中显示骨架屏,加载完成显示实际内容。


封面图片的加载处理

Widget _buildHeaderImage() {
  final imageUrl = _anime.imageUrl;
  if (imageUrl == null || imageUrl.isEmpty) {
    return Container(
      color: Colors.grey[300],
      child: const Center(child: Icon(Icons.movie, size: 64, color: Colors.grey)),
    );
  }

先检查图片 URL 是否有效,无效就显示占位图。这种防御性编程很重要,API 返回的数据不一定靠谱。

  return Image.network(
    imageUrl,
    fit: BoxFit.cover,
    loadingBuilder: (context, child, loadingProgress) {
      if (loadingProgress == null) return child;
      return Container(
        color: Colors.grey[300],
        child: const Center(child: CircularProgressIndicator()),
      );
    },

loadingBuilder 在图片加载过程中显示进度指示器。loadingProgress == null 表示加载完成,这时候返回实际图片。

    errorBuilder: (context, error, stackTrace) {
      return Container(
        color: Colors.grey[300],
        child: const Center(child: Icon(Icons.broken_image, size: 64, color: Colors.grey)),
      );
    },
  );
}

errorBuilder 处理加载失败的情况,显示一个破碎图片的图标。网络图片加载失败是常有的事,必须处理好。


内容区域的布局

Widget _buildContent() {
  return Padding(
    padding: const EdgeInsets.all(16),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          _anime.title,
          style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
        ),

标题用 24号字体加粗,是页面上最大的文字。crossAxisAlignment: CrossAxisAlignment.start 让所有内容左对齐。

        if (_anime.titleJapanese.isNotEmpty) ...[
          const SizedBox(height: 4),
          Text(
            _anime.titleJapanese,
            style: TextStyle(color: Colors.grey[600], fontSize: 14),
          ),
        ],

如果有日文名就显示出来,用灰色小字。...[] 是 Dart 的展开操作符,可以在列表里条件性地添加多个元素。


评分和排名标签

Widget _buildInfoRow() {
  return Row(
    children: [
      if (_anime.score != null) ...[
        Container(
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
          decoration: BoxDecoration(
            color: Theme.of(context).primaryColor,
            borderRadius: BorderRadius.circular(20),
          ),

评分标签用主题色背景,圆角 20 让它看起来像个胶囊。

          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              const Icon(Icons.star, color: Colors.white, size: 16),
              const SizedBox(width: 4),
              Text(
                _anime.score!.toStringAsFixed(1),
                style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
              ),
            ],
          ),
        ),
        const SizedBox(width: 8),
      ],

星星图标加评分数字,白色文字在彩色背景上很显眼。toStringAsFixed(1) 保留一位小数。

      if (_anime.rank != null)
        Container(
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
          decoration: BoxDecoration(
            color: Colors.amber,
            borderRadius: BorderRadius.circular(20),
          ),
          child: Text(
            '#${_anime.rank}',
            style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
          ),
        ),
    ],
  );
}

排名标签用琥珀色背景,和评分标签形成对比。#${_anime.rank} 显示为 “#1”、“#100” 这样的格式。


详情信息网格

动漫有很多属性要展示,用网格布局比较整齐:

Widget _buildDetailsGrid() {
  final items = <Widget>[
    _buildDetailItem('类型', _anime.type ?? 'N/A'),
    _buildDetailItem('状态', _anime.status ?? 'N/A'),
    _buildDetailItem('集数', _anime.episodes?.toString() ?? 'N/A'),
    _buildDetailItem('评分', _anime.rating ?? 'N/A'),
  ];

先把固定的几个属性加进去,?? 'N/A' 处理空值情况。

  if (_anime.year != null) {
    items.add(_buildDetailItem('年份', _anime.year.toString()));
  }
  if (_anime.season != null) {
    items.add(_buildDetailItem('季度', _anime.season!));
  }

年份和季度不是所有动漫都有,有的话才显示。

  return GridView.count(
    crossAxisCount: 2,
    shrinkWrap: true,
    physics: const NeverScrollableScrollPhysics(),
    childAspectRatio: 2.5,
    children: items,
  );
}

GridView.count 创建固定列数的网格,crossAxisCount: 2 表示两列。shrinkWrap: true 让网格高度自适应内容,NeverScrollableScrollPhysics 禁用网格自身的滚动,因为外层已经有 CustomScrollView 了。


单个详情项的构建

Widget _buildDetailItem(String label, String value) {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      Text(
        label,
        style: TextStyle(color: Colors.grey[600], fontSize: 12),
      ),

标签用灰色小字,作为说明。

      const SizedBox(height: 2),
      Text(
        value,
        style: const TextStyle(fontWeight: FontWeight.w600),
        maxLines: 1,
        overflow: TextOverflow.ellipsis,
      ),
    ],
  );
}

值用粗体显示,maxLines: 1overflow: TextOverflow.ellipsis 防止文字太长溢出,超出部分显示省略号。


操作按钮区域

详情页底部有几个功能按钮:

Widget _buildActionButtons() {
  return Column(
    children: [
      SizedBox(
        width: double.infinity,
        child: ElevatedButton.icon(
          icon: const Icon(Icons.people),
          label: const Text('角色'),
          onPressed: () => Navigator.push(
            context,
            MaterialPageRoute(
              builder: (_) => AnimeCharactersScreen(malId: _anime.malId),
            ),
          ),
        ),
      ),

ElevatedButton.icon 是带图标的按钮,width: double.infinity 让按钮撑满宽度。点击跳转到角色列表页,传入动漫 ID。

      const SizedBox(height: 8),
      SizedBox(
        width: double.infinity,
        child: ElevatedButton.icon(
          icon: const Icon(Icons.recommend),
          label: const Text('推荐'),
          onPressed: () => Navigator.push(
            context,
            MaterialPageRoute(
              builder: (_) => AnimeRecommendationsScreen(malId: _anime.malId),
            ),
          ),
        ),
      ),

推荐按钮跳转到推荐动漫页,显示和当前动漫相似的作品。

      const SizedBox(height: 8),
      SizedBox(
        width: double.infinity,
        child: ElevatedButton.icon(
          icon: const Icon(Icons.newspaper),
          label: const Text('新闻'),
          onPressed: () => Navigator.push(
            context,
            MaterialPageRoute(
              builder: (_) => AnimeNewsScreen(malId: _anime.malId),
            ),
          ),
        ),
      ),
    ],
  );
}

新闻按钮跳转到动漫新闻页,显示这部动漫的相关资讯。

三个按钮垂直排列,每个之间有 8 像素间距,整体看起来很整齐。


Anime 数据模型

详情页展示的数据来自 Anime 模型,看看它的结构:

class Anime {
  final int malId;
  final String title;
  final String titleJapanese;
  final String? imageUrl;
  final String? synopsis;
  final double? score;
  final int? episodes;
  final String? status;
  final int? rank;
  final String? season;
  final int? year;
  final String? type;

字段很多,大部分是可空的,因为 API 返回的数据不一定完整。malId 是动漫的唯一标识,title 是英文/中文标题,titleJapanese 是日文原名。


JSON 解析

factory Anime.fromJson(Map<String, dynamic> json) {
  try {
    final images = json['images'] as Map<String, dynamic>?;
    final jpg = images?['jpg'] as Map<String, dynamic>?;

API 返回的图片数据嵌套了好几层,需要一层层取出来。用 as Map<String, dynamic>? 做类型转换,加问号表示可能为空。

    return Anime(
      malId: json['mal_id'] ?? 0,
      title: json['title'] ?? 'Unknown',
      titleJapanese: json['title_japanese'] ?? '',
      imageUrl: jpg?['large_image_url'] ?? jpg?['image_url'],
      synopsis: json['synopsis'],
      score: (json['score'] as num?)?.toDouble(),

?? 操作符提供默认值,as num? 处理数字类型,因为 JSON 里的数字可能是 int 也可能是 double。

      genres: (json['genres'] as List<dynamic>?)
              ?.map((g) {
                if (g is Map<String, dynamic>) {
                  return g['name']?.toString() ?? '';
                }
                return '';
              })
              .where((name) => name.isNotEmpty)
              .toList() ?? [],

genres 是个数组,每个元素是个对象,需要取出 name 字段。where 过滤掉空字符串,?? [] 处理整个数组为空的情况。

这种防御性的解析代码看起来啰嗦,但能避免很多运行时错误。API 返回的数据格式可能随时变化,多做检查总没错。


小结

详情页是整个 App 里代码量最大的页面,涉及的知识点也最多:CustomScrollView 和 SliverAppBar 实现折叠头部Stack 和渐变实现图片遮罩Consumer 监听收藏状态异步加载和 mounted 检查GridView 展示详情信息防御性的 JSON 解析

这些技术点单独拿出来都不难,但组合在一起就需要花点心思了。写详情页的时候,建议先把布局画出来,想清楚每个区域要显示什么,然后再动手写代码。

详情页做好了,整个 App 的体验会上一个台阶。


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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐