Flutter for OpenHarmony游戏集合App实战之游戏卡片渐变背景
摘要 本文介绍了Flutter游戏卡片组件的实现方案。卡片采用Material Design风格,包含圆角、阴影和渐变色背景。核心实现要点包括: 使用Card组件作为基础框架,设置elevation阴影和clipBehavior圆角裁剪 通过InkWell实现点击水波纹效果和页面跳转功能 采用LinearGradient实现对角线渐变色背景,使用同色系不同透明度创造层次感 使用Column布局管理
通过网盘分享的文件: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。
渐变背景的实现
重点来了,渐变背景是用Container的decoration属性实现的:
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是右下角
所以这个渐变是从左上角到右下角的对角线方向。
为什么选这个方向?因为对角线渐变比水平或垂直渐变更有动感,视觉上更活泼。你也可以试试其他方向:
topCenter→bottomCenter:从上到下centerLeft→centerRight:从左到右topRight→bottomLeft:从右上到左下
渐变颜色
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实现线性渐变,
begin和end控制方向,colors定义颜色 - withOpacity给颜色加透明度,同色系深浅渐变简单又好看
- Column配合SizedBox实现垂直布局和间距控制
- 文字颜色要和背景形成足够的对比度,保证可读性
- RadialGradient和SweepGradient是其他渐变类型
- 动态渐变可以用AnimationController实现,但消耗性能
- 点击状态可以加缩放、变暗等效果,增强反馈
- 响应式布局根据屏幕宽度调整列数,适配不同设备
渐变是UI设计中很常用的技巧,能让界面更有层次感。掌握了LinearGradient,以后做按钮、背景、进度条这些都能用上。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)