Flutter for OpenHarmony 微动漫App实战:动漫卡片组件实现

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

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

动漫卡片是微动漫App中使用最频繁的组件,首页、搜索、收藏、推荐等多个页面都在用。把它封装成独立组件,不仅减少重复代码,还能保证全App的视觉一致性。

这篇文章会详细讲解动漫卡片组件的封装思路,包括组件设计原则、参数设计、样式处理,以及如何让一个组件适应多种使用场景。


组件封装的意义

为什么要封装组件?

减少重复:同样的卡片样式在多个页面使用,不封装就要复制粘贴。

统一风格:修改卡片样式只需要改一处,全App生效。

易于维护:Bug 修复和功能增强集中在一个文件。

提高效率:使用时只需要传入数据,不用关心实现细节。


组件的基本结构

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

StatelessWidget 适合这种纯展示组件,没有内部状态需要管理。

anime 是必需参数,包含动漫的所有信息。showRank 是可选参数,控制是否显示排名。


参数设计原则

设计组件参数时要考虑:

必需 vs 可选:核心数据是必需的,样式控制是可选的。

默认值:可选参数要有合理的默认值,大多数情况下不需要传。

类型安全:用具体类型而不是 dynamic,编译时就能发现错误。

dart 复制代码
class AnimeCard extends StatelessWidget {
  final Anime anime;           // 必需:动漫数据
  final bool showRank;         // 可选:是否显示排名
  final VoidCallback? onTap;   // 可选:自定义点击回调
  final double? width;         // 可选:自定义宽度
  final double? height;        // 可选:自定义高度

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

这样设计后,简单使用只需要传 anime,复杂场景可以传更多参数。


卡片的整体布局

dart 复制代码
@override
Widget build(BuildContext context) {
  return GestureDetector(
    onTap: () => Navigator.push(
      context,
      MaterialPageRoute(builder: (_) => AnimeDetailScreen(anime: anime)),
    ),
    child: Container(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.1),
            blurRadius: 8,
            offset: const Offset(0, 4),
          ),
        ],
      ),

GestureDetector 处理点击事件,跳转到详情页。

Container 的 decoration 设置圆角和阴影,让卡片有立体感。


阴影的设计

dart 复制代码
boxShadow: [
  BoxShadow(
    color: Colors.black.withOpacity(0.1),
    blurRadius: 8,
    offset: const Offset(0, 4),
  ),
],

color 是阴影颜色,用黑色的 10% 透明度,不会太重。

blurRadius 是模糊半径,8 像素让阴影边缘柔和。

offset 是偏移量,向下偏移 4 像素,模拟光从上方照射。

阴影让卡片"浮"在背景上,增加层次感。


Stack 层叠结构

dart 复制代码
      child: ClipRRect(
        borderRadius: BorderRadius.circular(12),
        child: Stack(
          fit: StackFit.expand,
          children: [
            _buildImage(),
            Positioned(
              bottom: 0,
              left: 0,
              right: 0,
              child: _buildInfoOverlay(),
            ),
            if (showRank && anime.rank != null)
              Positioned(
                top: 8,
                left: 8,
                child: _buildRankBadge(),
              ),
          ],
        ),
      ),
    ),
  );
}

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

Stack 层叠三层:底层是图片,中间是信息遮罩,顶层是排名角标。

StackFit.expand 让 Stack 填满父容器。


图片层实现

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:显示电影图标占位。

加载中:显示进度指示器。

加载失败:显示破图标。

BoxFit.cover 让图片填满容器,可能会裁剪边缘,但不会变形。


信息遮罩层

dart 复制代码
Widget _buildInfoOverlay() {
  return 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(
      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),
        _buildMetaRow(),
      ],
    ),
  );
}

LinearGradient 从底部到顶部,从深色到透明。这样文字在深色背景上清晰可读。

mainAxisSize: MainAxisSize.min 让 Column 只占用需要的空间,不会撑满。


渐变遮罩的原理

dart 复制代码
gradient: LinearGradient(
  begin: Alignment.bottomCenter,
  end: Alignment.topCenter,
  colors: [
    Colors.black.withOpacity(0.9),
    Colors.transparent,
  ],
),

渐变从底部开始是深色(0.9 透明度的黑色),到顶部变成完全透明。

这样底部的文字有足够的对比度,而图片的上半部分不受影响。

0.9 的透明度是经过测试的值,既能保证文字清晰,又不会让遮罩太重。


评分和集数

dart 复制代码
Widget _buildMetaRow() {
  return 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),
        ),
    ],
  );
}

Row 水平排列评分和集数。

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

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

集数用 Colors.white70,比标题稍浅,形成层次。


排名角标

dart 复制代码
Widget _buildRankBadge() {
  return 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 复制代码
if (showRank && anime.rank != null)
  Positioned(
    top: 8,
    left: 8,
    child: _buildRankBadge(),
  ),

在 children 列表里可以直接用 if 条件渲染。这是 Dart 2.3 引入的特性,叫做 collection if。

比用三元表达式更清晰:

dart 复制代码
// 不推荐
showRank && anime.rank != null
    ? Positioned(...)
    : const SizedBox.shrink(),

// 推荐
if (showRank && anime.rank != null)
  Positioned(...),

展开操作符

dart 复制代码
if (anime.score != null) ...[
  const Icon(Icons.star, color: Colors.amber, size: 14),
  const SizedBox(width: 2),
  Text(anime.score!.toStringAsFixed(1), ...),
],

...[] 是展开操作符,把列表里的元素展开到外层列表。

配合 if 使用,可以条件性地添加多个元素。


组件的使用方式

封装好的组件使用起来很简单:

dart 复制代码
// 基本使用
AnimeCard(anime: anime)

// 显示排名
AnimeCard(anime: anime, showRank: true)

// 在 GridView 中使用
GridView.builder(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    childAspectRatio: 0.7,
  ),
  itemCount: animeList.length,
  itemBuilder: (_, i) => AnimeCard(anime: animeList[i]),
)

// 在 ListView 中使用
ListView.builder(
  itemCount: animeList.length,
  itemBuilder: (_, i) => Padding(
    padding: const EdgeInsets.all(8),
    child: SizedBox(
      height: 200,
      child: AnimeCard(anime: animeList[i]),
    ),
  ),
)

一个组件,多种场景。


扩展:添加收藏功能

可以给卡片加收藏按钮:

dart 复制代码
class AnimeCard extends StatelessWidget {
  final Anime anime;
  final bool showRank;
  final bool showFavorite;
  final bool isFavorite;
  final VoidCallback? onFavoriteToggle;

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

新增三个参数:是否显示收藏按钮、是否已收藏、收藏切换回调。

dart 复制代码
if (showFavorite)
  Positioned(
    top: 8,
    right: 8,
    child: GestureDetector(
      onTap: onFavoriteToggle,
      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: () {
    showModalBottomSheet(
      context: context,
      builder: (context) => Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          ListTile(
            leading: const Icon(Icons.favorite_border),
            title: const Text('收藏'),
            onTap: () {
              Navigator.pop(context);
              // 收藏逻辑
            },
          ),
          ListTile(
            leading: const Icon(Icons.share),
            title: const Text('分享'),
            onTap: () {
              Navigator.pop(context);
              // 分享逻辑
            },
          ),
        ],
      ),
    );
  },
  child: // 卡片内容
)

长按弹出底部菜单,提供收藏、分享等操作。


深色模式适配

dart 复制代码
Widget build(BuildContext context) {
  final isDark = Theme.of(context).brightness == Brightness.dark;
  
  return GestureDetector(
    onTap: () => Navigator.push(...),
    child: Container(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(12),
        boxShadow: [
          BoxShadow(
            color: isDark
                ? Colors.black.withOpacity(0.3)
                : Colors.black.withOpacity(0.1),
            blurRadius: 8,
            offset: const Offset(0, 4),
          ),
        ],
      ),
      // 内容
    ),
  );
}

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

图片占位色也可以适配:

dart 复制代码
Container(
  color: isDark ? Colors.grey[800] : Colors.grey[300],
  child: const Center(child: Icon(Icons.movie, ...)),
)

性能优化

卡片组件会大量使用,性能很重要:

dart 复制代码
class AnimeCard extends StatelessWidget {
  // 使用 const 构造函数
  const AnimeCard({super.key, required this.anime, this.showRank = false});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      // 内容
    );
  }
}

const 构造函数让 Flutter 可以复用相同参数的组件实例。

StatelessWidget 比 StatefulWidget 轻量,没有状态管理开销。

图片用 Image.network 的 loadingBuilder 而不是额外的状态管理,减少重建。


小结

动漫卡片组件涉及的技术点:组件封装参数设计Stack 层叠布局LinearGradient 渐变条件渲染展开操作符GestureDetector 手势

组件设计原则:必需参数用 required,可选参数给默认值,用具体类型保证类型安全。

渐变遮罩是图片上叠加文字的标准做法,从深色到透明的渐变既保证文字清晰,又不完全遮挡图片。

好的组件封装能大幅提高开发效率,修改一处全App生效,是 Flutter 开发的重要技能。


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

相关推荐
董世昌412 小时前
null和undefined的区别是什么?
java·前端·javascript
superman超哥2 小时前
派生宏(Derive Macro)的工作原理:编译时元编程的艺术
开发语言·rust·开发工具·编程语言·rust派生宏·derive macro·rust元编程
easyboot2 小时前
C#使用pythonnet简单示例
开发语言·python·c#
软弹2 小时前
Vue2 的数据响应式原理&&给实例新增响应式属性
前端·javascript·vue.js
晚霞的不甘2 小时前
Flutter 布局核心:构建交互式文档应用
开发语言·javascript·flutter·elasticsearch·正则表达式
少控科技2 小时前
QT新手日记 030
开发语言·qt
小此方2 小时前
Re:从零开始的 C++ STL篇(三)string的疑难问题详细解析:深拷贝,写时拷贝,三个swap
开发语言·c++
知1而N2 小时前
电脑上运行APK文件(Android应用程序包),需要借助特定的软件或功能,因为Windows/macOS/Linux系统无法原生直接运行安卓应用
android·macos·电脑
Linux猿3 小时前
基于Python的图书管理系统(可执行源码+详细报告+详细注释+运行步骤)
开发语言·python·毕业设计·课程设计·管理系统·图书管理系统项目