Flutter for OpenHarmony 微动漫App实战:推荐动漫实现

通过网盘分享的文件:flutter1.zip

链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97

看完一部动漫,想找类似的作品?推荐功能就是为此而生。微动漫App的推荐页面展示与当前动漫相关的推荐作品,用网格布局展示,点击可以跳转到详情页。

这篇文章会实现推荐动漫页面,重点讲解 GridView 网格布局、动漫卡片组件的封装,以及如何用渐变遮罩让文字在图片上清晰可读。


推荐页面的设计思路

推荐页面从动漫详情页进入,展示与该动漫相关的推荐作品。

网格布局:动漫封面是视觉重点,网格布局能展示更多封面,比列表更适合。

卡片设计:封面图占满卡片,标题和评分叠加在图片上,用渐变遮罩保证可读性。

点击跳转:点击卡片进入动漫详情页,形成浏览闭环。


页面基础结构

dart 复制代码
import 'package:flutter/material.dart';
import '../services/api_service.dart';
import '../models/anime.dart';
import '../widgets/anime_card.dart';
import '../widgets/shimmer_loading.dart';

class AnimeRecommendationsScreen extends StatefulWidget {
  final int malId;

  const AnimeRecommendationsScreen({super.key, required this.malId});

  @override
  State<AnimeRecommendationsScreen> createState() =>
      _AnimeRecommendationsScreenState();
}

引入 AnimeCard 组件,这是一个封装好的动漫卡片,可以在多个页面复用。

malId 是当前动漫的 ID,用于获取相关推荐。


数据加载

dart 复制代码
class _AnimeRecommendationsScreenState extends State<AnimeRecommendationsScreen> {
  late Future<List<Anime>> _recommendationsFuture;

  @override
  void initState() {
    super.initState();
    _recommendationsFuture = ApiService.getAnimeRecommendations(widget.malId);
  }

initState 里发起请求,获取推荐动漫列表。


FutureBuilder 构建页面

dart 复制代码
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: const Text('推荐动漫')),
    body: FutureBuilder<List<Anime>>(
      future: _recommendationsFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const ShimmerLoading(itemCount: 8, isGrid: true);
        }

        if (!snapshot.hasData || snapshot.data!.isEmpty) {
          return Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(Icons.recommend, size: 64, color: Colors.grey[400]),
                const SizedBox(height: 16),
                Text(
                  '暂无推荐',
                  style: TextStyle(color: Colors.grey[600], fontSize: 16),
                ),
              ],
            ),
          );
        }

        final recommendations = snapshot.data!;
        return GridView.builder(
          padding: const EdgeInsets.all(12),
          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 2,
            childAspectRatio: 0.7,
            crossAxisSpacing: 12,
            mainAxisSpacing: 12,
          ),
          itemCount: recommendations.length,
          itemBuilder: (_, i) => AnimeCard(anime: recommendations[i]),
        );
      },
    ),
  );
}

加载中显示网格形式的骨架屏(isGrid: true )。空状态用 Icons.recommend 图标。

有数据时用 GridView.builder 展示网格。


GridView 配置详解

dart 复制代码
GridView.builder(
  padding: const EdgeInsets.all(12),
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    childAspectRatio: 0.7,
    crossAxisSpacing: 12,
    mainAxisSpacing: 12,
  ),
  itemCount: recommendations.length,
  itemBuilder: (_, i) => AnimeCard(anime: recommendations[i]),
)

SliverGridDelegateWithFixedCrossAxisCount 是固定列数的网格代理。

crossAxisCount: 2 每行 2 个。

childAspectRatio: 0.7 宽高比,0.7 表示高度是宽度的 1/0.7 ≈ 1.43 倍,适合竖向的动漫封面。

crossAxisSpacing 是列间距,mainAxisSpacing 是行间距。


AnimeCard 组件结构

dart 复制代码
import 'package:flutter/material.dart';
import '../models/anime.dart';
import '../screens/anime_detail_screen.dart';

class AnimeCard extends StatelessWidget {
  final Anime anime;
  final bool showRank;

  const AnimeCard({super.key, required this.anime, this.showRank = false});

AnimeCard 是一个无状态组件,接收 Anime 对象和是否显示排名的标志。

showRank 默认为 false,在排行榜页面可以设为 true 显示排名。


卡片点击跳转

dart 复制代码
@override
Widget build(BuildContext context) {
  return GestureDetector(
    onTap: () => Navigator.push(
      context,
      MaterialPageRoute(builder: (_) => AnimeDetailScreen(anime: anime)),
    ),

GestureDetector 包裹整个卡片,点击后跳转到详情页。

把 anime 对象传给详情页,详情页可以直接使用,不需要再请求。


卡片容器样式

dart 复制代码
    child: Container(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.1),
            blurRadius: 8,
            offset: const Offset(0, 4),
          ),
        ],
      ),

borderRadius 设置圆角,boxShadow 添加阴影,让卡片有立体感。

阴影向下偏移 4 像素,模拟光从上方照射的效果。


Stack 层叠布局

dart 复制代码
      child: ClipRRect(
        borderRadius: BorderRadius.circular(12),
        child: Stack(
          fit: StackFit.expand,
          children: [
            _buildImage(),
            // 渐变遮罩和文字
            // 排名角标
          ],
        ),
      ),
    ),
  );
}

ClipRRect 裁剪圆角,让图片也有圆角效果。

Stack 层叠图片、渐变遮罩、文字信息。StackFit.expand 让 Stack 填满父容器。


渐变遮罩实现

dart 复制代码
Positioned(
  bottom: 0,
  left: 0,
  right: 0,
  child: Container(
    decoration: BoxDecoration(
      gradient: LinearGradient(
        begin: Alignment.bottomCenter,
        end: Alignment.topCenter,
        colors: [
          Colors.black.withOpacity(0.9),
          Colors.transparent,
        ],
      ),
    ),
    padding: const EdgeInsets.all(8),
    child: Column(
      // 文字内容
    ),
  ),
),

Positioned 定位在底部,左右撑满。

LinearGradient 从底部到顶部,从深色到透明。这样文字在深色背景上清晰可读,同时不完全遮挡图片。

0.9 的透明度让底部足够暗,文字清晰。


标题和评分

dart 复制代码
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: [
        Text(
          anime.title,
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
          style: const TextStyle(
            color: Colors.white,
            fontWeight: FontWeight.bold,
            fontSize: 12,
          ),
        ),
        const SizedBox(height: 4),
        Row(
          children: [
            if (anime.score != null) ...[
              const Icon(Icons.star, color: Colors.amber, size: 14),
              const SizedBox(width: 2),
              Text(
                anime.score!.toStringAsFixed(1),
                style: const TextStyle(color: Colors.white, fontSize: 11),
              ),
            ],
            const Spacer(),
            if (anime.episodes != null)
              Text(
                '${anime.episodes}集',
                style: const TextStyle(color: Colors.white70, fontSize: 10),
              ),
          ],
        ),
      ],
    ),

标题用白色粗体,最多 2 行。

评分用星星图标 + 数字,toStringAsFixed(1) 保留一位小数。

集数放在右边,用稍浅的白色(Colors.white70)。

Spacer 把评分和集数推到两端。


排名角标

dart 复制代码
if (showRank && anime.rank != null)
  Positioned(
    top: 8,
    left: 8,
    child: Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
        color: Theme.of(context).primaryColor,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Text(
        '#${anime.rank}',
        style: const TextStyle(
          color: Colors.white,
          fontWeight: FontWeight.bold,
          fontSize: 11,
        ),
      ),
    ),
  ),

排名角标在左上角,只有 showRank 为 true 且有排名数据时才显示。

用主题色背景,圆角胶囊形状。


图片加载处理

dart 复制代码
Widget _buildImage() {
  final imageUrl = anime.imageUrl;
  if (imageUrl == null || imageUrl.isEmpty) {
    return Container(
      color: Colors.grey[300],
      child: const Center(child: Icon(Icons.movie, size: 40, color: Colors.grey)),
    );
  }

  return Image.network(
    imageUrl,
    fit: BoxFit.cover,
    loadingBuilder: (context, child, loadingProgress) {
      if (loadingProgress == null) return child;
      return Container(
        color: Colors.grey[300],
        child: Center(
          child: CircularProgressIndicator(
            value: loadingProgress.expectedTotalBytes != null
                ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
                : null,
          ),
        ),
      );
    },
    errorBuilder: (context, error, stackTrace) {
      print('❌ Image load error: $error');
      return Container(
        color: Colors.grey[300],
        child: const Center(child: Icon(Icons.broken_image, size: 40, color: Colors.grey)),
      );
    },
  );
}

空 URL 显示电影图标占位。

加载中显示进度指示器,loadingProgress 可以计算加载进度。

加载失败显示破图标,并打印错误日志方便调试。


加载进度计算

dart 复制代码
CircularProgressIndicator(
  value: loadingProgress.expectedTotalBytes != null
      ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
      : null,
)

cumulativeBytesLoaded 是已加载的字节数,expectedTotalBytes 是总字节数。

如果服务器返回了总大小,就显示确定的进度;否则显示不确定的转圈动画(value 为 null)。


组件复用

AnimeCard 可以在多个页面使用:

dart 复制代码
// 推荐页面
AnimeCard(anime: recommendations[i])

// 排行榜页面
AnimeCard(anime: topAnime[i], showRank: true)

// 搜索结果页面
AnimeCard(anime: searchResults[i])

// 收藏页面
AnimeCard(anime: favorites[i])

通过 showRank 参数控制是否显示排名,一个组件适应多种场景。


添加收藏按钮

可以在卡片上加收藏按钮:

dart 复制代码
Positioned(
  top: 8,
  right: 8,
  child: GestureDetector(
    onTap: () {
      // 切换收藏状态
    },
    child: Container(
      padding: const EdgeInsets.all(6),
      decoration: BoxDecoration(
        color: Colors.black.withOpacity(0.5),
        shape: BoxShape.circle,
      ),
      child: Icon(
        isFavorite ? Icons.favorite : Icons.favorite_border,
        color: isFavorite ? Colors.red : Colors.white,
        size: 18,
      ),
    ),
  ),
),

收藏按钮在右上角,圆形半透明背景。收藏后爱心变红色实心。


长按预览

长按卡片可以显示更多信息:

dart 复制代码
GestureDetector(
  onTap: () => Navigator.push(...),
  onLongPress: () {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(anime.title),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            if (anime.synopsis != null)
              Text(
                anime.synopsis!,
                maxLines: 5,
                overflow: TextOverflow.ellipsis,
              ),
            const SizedBox(height: 8),
            Text('评分: ${anime.score ?? "暂无"}'),
            Text('集数: ${anime.episodes ?? "未知"}'),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('关闭'),
          ),
        ],
      ),
    );
  },
  child: // 卡片内容
)

长按弹出对话框,显示简介、评分、集数等信息,不用进入详情页就能快速了解。


深色模式适配

卡片在深色模式下需要调整阴影:

dart 复制代码
Container(
  decoration: BoxDecoration(
    borderRadius: BorderRadius.circular(12),
    boxShadow: [
      BoxShadow(
        color: Theme.of(context).brightness == Brightness.dark
            ? Colors.black.withOpacity(0.3)
            : Colors.black.withOpacity(0.1),
        blurRadius: 8,
        offset: const Offset(0, 4),
      ),
    ],
  ),
  // 内容
)

深色模式下阴影更深,让卡片边界更明显。


小结

推荐动漫页面涉及的技术点:GridView.builder 网格布局SliverGridDelegate 网格配置Stack 层叠布局LinearGradient 渐变Positioned 定位组件封装复用

AnimeCard 是一个设计良好的组件:封面图占满卡片吸引眼球,渐变遮罩保证文字可读,点击跳转形成浏览闭环,showRank 参数让组件适应多种场景。

渐变遮罩是图片上叠加文字的常用技巧,从深色到透明的渐变既保证了文字清晰,又不完全遮挡图片内容。


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

相关推荐
不绝1912 小时前
C#进阶:委托
开发语言·c#
喜欢喝果茶.2 小时前
跨.cs 文件传值(C#)
开发语言·c#
zmzb01032 小时前
C++课后习题训练记录Day74
开发语言·c++
小冷coding2 小时前
【Java】Dubbo 与 OpenFeign 的核心区别
java·开发语言·dubbo
Coder_Boy_2 小时前
基于SpringAI的在线考试系统-智能考试系统-学习分析模块
java·开发语言·数据库·spring boot·ddd·tdd
2401_894828122 小时前
从原理到实战:随机森林算法全解析(附 Python 完整代码)
开发语言·python·算法·随机森林
ujainu2 小时前
Flutter for HarmonyOS 前置知识:Dart语言详解(中)
flutter
玄同7652 小时前
Python「焚诀」:吞噬所有语法糖的终极修炼手册
开发语言·数据库·人工智能·python·postgresql·自然语言处理·nlp
羽翼.玫瑰2 小时前
关于重装Python失败(本质是未彻底卸载Python)的问题解决方案综述
开发语言·python