通过网盘分享的文件:game_flutter_openharmony.zip
链接: https://pan.baidu.com/s/1ryUS1A0zcvXGrDaStu530w 提取码: tqip

前言

游戏列表做好了,但每个格子里显示什么呢?总不能就放个文字吧,那也太丑了。

我想要的效果是:每个游戏一张卡片,卡片有漂亮的渐变色背景,上面放图标、游戏名、简短描述。用户一眼就能看出这是什么游戏,点击卡片就能进入游戏。

这篇就来聊聊这个游戏卡片怎么做,重点是渐变背景的实现。
请添加图片描述

卡片组件的设计思路

在动手写代码之前,先想清楚这个卡片需要什么:

外观方面:

  • 有圆角,看起来像一张卡片
  • 有阴影,让卡片有立体感
  • 背景是渐变色,不同游戏用不同颜色

内容方面:

  • 顶部一个图标,代表游戏类型
  • 中间是游戏名称,要醒目
  • 底部是简短描述,告诉用户这是什么游戏

交互方面:

  • 点击有水波纹反馈
  • 点击后跳转到对应的游戏页面

想清楚需求,写代码就有方向了。

_GameCard组件结构

先看组件的整体定义:

class _GameCard extends StatelessWidget {
  final _GameInfo game;
  const _GameCard({required this.game});

_GameCard是一个StatelessWidget,因为卡片本身不需要维护状态。它接收一个game参数,包含了游戏的所有信息(名称、图标、颜色、描述、页面)。

参数用required修饰,表示这是必传参数,创建_GameCard时必须提供game,不然编译器会报错。这比运行时才发现少传参数要好得多。

类名前面的下划线_表示私有,只能在当前文件使用。因为这个组件只是给HomeScreen用的,没必要暴露出去。

Card组件打底

build方法返回的最外层是Card


Widget build(BuildContext context) {
  return Card(
    elevation: 4,
    clipBehavior: Clip.antiAlias,

Card是Material Design的卡片组件,自带圆角和阴影效果,非常适合做这种卡片式的UI。

  • elevation: 阴影的高度。数值越大,阴影越明显,卡片看起来越"浮"在页面上。4是个比较适中的值,有立体感但不会太夸张

  • clipBehavior: 裁剪行为。设成Clip.antiAlias表示用抗锯齿的方式裁剪超出卡片边界的内容

💡 为什么要设置clipBehavior? Card默认有圆角,但如果子Widget的背景是纯色或渐变,它会"溢出"圆角区域,导致圆角看不见。设置clipBehavior后,超出圆角的部分会被裁掉,圆角就能正常显示了。antiAlias是抗锯齿裁剪,边缘更平滑;如果不在意边缘质量,用Clip.hardEdge性能更好。

InkWell点击效果

Card里面包了一层InkWell

    child: InkWell(
      onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => game.screen)),

InkWell是Material Design的点击反馈组件,点击时会有水波纹扩散的效果,非常好看。

onTap是点击回调,用户点击卡片时执行。这里用Navigator.push跳转到游戏页面:

  • Navigator是Flutter的路由管理器,负责页面的跳转和返回
  • push方法把新页面压入导航栈,显示新页面
  • MaterialPageRoute创建一个带Material风格过渡动画的路由
  • builder: (_) => game.screen是页面构建函数,返回要显示的Widget

builder的参数是BuildContext,但我们用不到,所以写成下划线_表示忽略。

💡 InkWell vs GestureDetector: 两者都能检测点击,区别是InkWell有水波纹效果,GestureDetector没有。Material Design风格的App推荐用InkWell,视觉反馈更好。如果不需要水波纹,或者要自定义点击效果,用GestureDetector。

渐变背景的实现

重点来了,渐变背景是用Containerdecoration属性实现的:

      child: Container(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topLeft,
            end: Alignment.bottomRight,
            colors: [game.color.withOpacity(0.8), game.color.withOpacity(0.6)],
          ),
        ),

一层层拆解来看。

BoxDecoration装饰器

BoxDecoration是Container的装饰器,可以设置背景色、渐变、图片、边框、圆角、阴影等各种效果。这里我们只用到了gradient属性。

LinearGradient线性渐变

LinearGradient是线性渐变,颜色沿着一条直线从起点过渡到终点。

begin: Alignment.topLeft,
end: Alignment.bottomRight,
  • begin: 渐变起点,Alignment.topLeft是左上角
  • end: 渐变终点,Alignment.bottomRight是右下角

所以这个渐变是从左上角到右下角的对角线方向。

为什么选这个方向?因为对角线渐变比水平或垂直渐变更有动感,视觉上更活泼。你也可以试试其他方向:

  • topCenterbottomCenter:从上到下
  • centerLeftcenterRight:从左到右
  • topRightbottomLeft:从右上到左下

渐变颜色

colors: [game.color.withOpacity(0.8), game.color.withOpacity(0.6)],

colors数组定义渐变经过的颜色,至少要两个。这里用的是同一个颜色的两种透明度

  • 起点颜色:游戏主题色,80%不透明度
  • 终点颜色:游戏主题色,60%不透明度

效果就是从稍深的颜色渐变到稍浅的颜色,有一种光照的感觉,好像左上角有光源照过来。

为什么不用两个完全不同的颜色?比如蓝色渐变到绿色?

试过,效果不好。每个游戏的主题色不一样,如果再配一个渐变色,很难保证所有组合都好看。用同色系的深浅渐变,简单又不容易出错。

💡 withOpacity的作用: Color.withOpacity(0.8)返回一个新的颜色对象,RGB值不变,只是透明度变成了0.8(80%)。原来的颜色对象不会被修改,这是不可变设计。

为什么不直接用纯色?

你可能会问,直接用color: game.color设置纯色背景不行吗?当然可以,但效果差很多。

纯色背景看起来很"平",没有层次感。渐变背景有明暗变化,看起来更有质感,更像一张真实的卡片。

而且渐变的方向暗示了光源位置,所有卡片的光源方向一致(都是左上角),整体看起来更协调。

卡片内容布局

背景搞定了,接下来是卡片上的内容:

        child: Padding(
          padding: const EdgeInsets.all(8.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [

先用Padding给内容加8像素的内边距,让内容不会贴着卡片边缘。

然后用Column垂直排列子组件。mainAxisAlignment: MainAxisAlignment.center让子组件在垂直方向上居中。

游戏图标

              Icon(game.icon, size: 36, color: Colors.white),

Icon组件显示Material Icons图标。

  • game.icon: 图标数据,从游戏信息里取
  • size: 36: 图标大小,36像素。这个大小在卡片里比较合适,太大会挤占文字空间,太小看不清
  • color: Colors.white: 图标颜色,白色。因为背景是彩色的,白色图标对比度高,看得清楚

间距控制

              const SizedBox(height: 8),

SizedBox是一个固定大小的盒子,这里只设置了height,相当于一个垂直方向的间距

图标和标题之间空8像素,不会挤在一起,也不会离得太远。

💡 为什么用SizedBox而不是Padding? 两者都能实现间距,但语义不同。Padding是给某个Widget加内边距,SizedBox是占据一块空间。在Column/Row里做间距,SizedBox更直观。而且SizedBox可以加const,性能更好。

游戏标题

              Text(game.title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white)),

Text组件显示游戏名称。

  • fontSize: 14: 字号14,在小卡片里刚好能看清
  • fontWeight: FontWeight.bold: 加粗,让标题更醒目
  • color: Colors.white: 白色文字,和彩色背景形成对比

标题和描述的间距

              const SizedBox(height: 2),

标题和描述之间只空2像素,因为它们是相关的信息,不需要太大间距。

游戏描述

              Text(game.description, style: TextStyle(fontSize: 9, color: Colors.white.withOpacity(0.9)), textAlign: TextAlign.center),

描述文字的样式和标题不同:

  • fontSize: 9: 字号更小,因为描述是次要信息
  • color: Colors.white.withOpacity(0.9): 白色但透明度90%,比标题稍暗一点,形成视觉层次
  • textAlign: TextAlign.center: 文字居中对齐,因为描述可能换行,居中更好看

注意这里的TextStyle没有加const,因为Colors.white.withOpacity(0.9)不是编译时常量(withOpacity是运行时计算的)。

完整的_GameCard代码

把上面的代码组合起来:

class _GameCard extends StatelessWidget {
  final _GameInfo game;
  const _GameCard({required this.game});

  
  Widget build(BuildContext context) {
    return Card(
      elevation: 4,
      clipBehavior: Clip.antiAlias,
      child: InkWell(
        onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => game.screen)),
        child: Container(
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topLeft,
              end: Alignment.bottomRight,
              colors: [game.color.withOpacity(0.8), game.color.withOpacity(0.6)],
            ),
          ),
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Icon(game.icon, size: 36, color: Colors.white),
                const SizedBox(height: 8),
                Text(game.title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white)),
                const SizedBox(height: 2),
                Text(game.description, style: TextStyle(fontSize: 9, color: Colors.white.withOpacity(0.9)), textAlign: TextAlign.center),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

层级结构是:Card > InkWell > Container(渐变背景)> Padding > Column > 图标/标题/描述。

每一层都有它的职责:

  • Card提供圆角和阴影
  • InkWell提供点击反馈和跳转
  • Container提供渐变背景
  • Padding提供内边距
  • Column垂直排列内容

关于颜色对比度

卡片上的文字都是白色的,这要求背景颜色不能太浅,不然白字看不清。

我在定义游戏数据时,选的都是比较饱和的颜色:

_GameInfo('扫雷', Icons.grid_view, Colors.blue, ...),      // 蓝色
_GameInfo('五子棋', Icons.circle_outlined, Colors.brown, ...),  // 棕色
_GameInfo('炸金花', Icons.casino, Colors.red, ...),        // 红色

这些颜色即使加了透明度(0.8和0.6),也足够深,白字能看清。

如果你想用浅色背景(比如Colors.yellow),就需要把文字改成深色,或者调整透明度让背景更深。

💡 对比度的重要性: 文字和背景的对比度不够,用户看着费劲,体验很差。WCAG(Web内容无障碍指南)建议正文文字的对比度至少4.5:1。虽然这是Web的标准,但移动端也可以参考。

渐变的其他玩法

除了同色系深浅渐变,还有其他玩法:

多色渐变:

colors: [Colors.blue, Colors.purple, Colors.red],

三个颜色会均匀分布在渐变路径上,形成蓝→紫→红的过渡。

指定颜色位置:

colors: [Colors.blue, Colors.purple, Colors.red],
stops: [0.0, 0.3, 1.0],

stops指定每个颜色的位置,0.0是起点,1.0是终点。这样蓝色在0%位置,紫色在30%位置,红色在100%位置,蓝→紫的过渡会更短。

径向渐变:

gradient: RadialGradient(
  center: Alignment.topLeft,
  radius: 1.5,
  colors: [game.color.withOpacity(0.9), game.color.withOpacity(0.5)],
),

RadialGradient是从中心向外扩散的圆形渐变,可以做出类似聚光灯的效果。

不过对于游戏卡片,简单的线性渐变就够了,太花哨反而分散注意力。

扫描渐变

还有一种SweepGradient,像雷达扫描一样的渐变:

gradient: SweepGradient(
  center: Alignment.center,
  colors: [Colors.red, Colors.orange, Colors.yellow, Colors.green, Colors.blue, Colors.red],
),

从中心点开始,颜色沿着圆周变化。这种渐变比较少用,但做一些特殊效果很有意思。

动态渐变

如果想让渐变动起来,可以用AnimationController:

class _AnimatedGradientState extends State<AnimatedGradient> with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  
  
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 3),
      vsync: this,
    )..repeat();
  }
  
  
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Container(
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topLeft,
              end: Alignment.bottomRight,
              colors: [
                HSLColor.fromAHSL(1, _controller.value * 360, 0.7, 0.5).toColor(),
                HSLColor.fromAHSL(1, (_controller.value * 360 + 60) % 360, 0.7, 0.5).toColor(),
              ],
            ),
          ),
        );
      },
    );
  }
}

颜色会随时间变化,形成彩虹流动的效果。

这种效果比较炫,但会消耗性能,不适合大量使用。游戏卡片用静态渐变就好。

卡片的点击状态

InkWell的水波纹效果很好,但还可以加更多反馈:

按下时缩小

class _GameCard extends StatefulWidget {
  // ...
}

class _GameCardState extends State<_GameCard> {
  bool _isPressed = false;
  
  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: (_) => setState(() => _isPressed = true),
      onTapUp: (_) => setState(() => _isPressed = false),
      onTapCancel: () => setState(() => _isPressed = false),
      child: AnimatedScale(
        scale: _isPressed ? 0.95 : 1.0,
        duration: const Duration(milliseconds: 100),
        child: Card(
          // ... 原来的内容
        ),
      ),
    );
  }
}

按下时卡片缩小到95%,松开恢复,有一个"按下去"的感觉。

按下时变暗

AnimatedContainer(
  duration: const Duration(milliseconds: 100),
  decoration: BoxDecoration(
    gradient: LinearGradient(
      colors: [
        game.color.withOpacity(_isPressed ? 0.6 : 0.8),
        game.color.withOpacity(_isPressed ? 0.4 : 0.6),
      ],
    ),
  ),
)

按下时颜色变暗,松开恢复。

当前实现简化了,只用InkWell的水波纹效果。

卡片的阴影

Card自带阴影,但可以自定义:

Container(
  decoration: BoxDecoration(
    borderRadius: BorderRadius.circular(12),
    boxShadow: [
      BoxShadow(
        color: game.color.withOpacity(0.3),
        blurRadius: 8,
        offset: const Offset(0, 4),
      ),
    ],
  ),
)

用游戏主题色作为阴影颜色,阴影会带有颜色,更有设计感。

offset: const Offset(0, 4)让阴影向下偏移4像素,模拟光源在上方。

响应式布局

当前GridView用固定的crossAxisCount: 4,在不同屏幕上可能不合适。

根据屏幕宽度调整

int getCrossAxisCount(BuildContext context) {
  double width = MediaQuery.of(context).size.width;
  if (width < 400) return 2;
  if (width < 600) return 3;
  if (width < 900) return 4;
  return 5;
}

小屏幕显示2列,大屏幕显示更多列。

固定卡片宽度

gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
  maxCrossAxisExtent: 150, // 每个卡片最大150像素宽
  mainAxisSpacing: 16,
  crossAxisSpacing: 16,
  childAspectRatio: 0.85,
),

SliverGridDelegateWithMaxCrossAxisExtent会根据屏幕宽度自动计算列数,保证每个卡片不超过指定宽度。

当前实现用固定4列,简化了。

小结

这篇讲了游戏卡片的实现,重点是渐变背景。核心知识点:

  • Card组件提供圆角和阴影,clipBehavior控制内容裁剪
  • InkWell提供Material风格的点击水波纹效果,视觉反馈好
  • LinearGradient实现线性渐变,beginend控制方向,colors定义颜色
  • withOpacity给颜色加透明度,同色系深浅渐变简单又好看
  • Column配合SizedBox实现垂直布局和间距控制
  • 文字颜色要和背景形成足够的对比度,保证可读性
  • RadialGradientSweepGradient是其他渐变类型
  • 动态渐变可以用AnimationController实现,但消耗性能
  • 点击状态可以加缩放、变暗等效果,增强反馈
  • 响应式布局根据屏幕宽度调整列数,适配不同设备

渐变是UI设计中很常用的技巧,能让界面更有层次感。掌握了LinearGradient,以后做按钮、背景、进度条这些都能用上。


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

Logo

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

更多推荐