欢迎光临
我们一直在努力

Flutter For Openharmony盲盒抽奖App应用实战+首页实现

请添加图片描述

写在前面

首页是整个项目的门面。我的想法很简单:用户打开App的第一眼,就要让他产生"想抽一个试试"的冲动。所以在首页上花了不少心思,从动画效果到交互细节,每一处都在传递"惊喜"这个主题。

这篇文章会详细讲解首页的实现过程,包括那些让页面"活"起来的动画技巧。代码都是项目里实际在用的,可以直接拿去跑。

首页要做什么

在动手写代码之前,我先梳理了一下首页需要承载哪些功能。盲盒App的核心是"抽奖",所以首页的设计思路就是:突出盲盒展示,同时提供足够的信息让用户做决策。

主要功能:

  • 顶部显示用户资产(潮盒、潮豆、优惠券)
  • 中奖播报滚动,营造热闹氛围
  • 盲盒卡片展示,带悬浮和发光效果
  • 分类导航,切换不同系列
  • 快捷操作按钮(签到、开箱、预览)
  • 热门盲盒列表

整个页面的视觉重心在中间的盲盒卡片上,其他元素都是辅助。

页面骨架搭建

先定义页面类

首页用的是 StatefulWidget,因为有很多动画和状态需要管理:

class HomePage extends StatefulWidget {
const HomePage({super.key});


State<HomePage> createState() => _HomePageState();
}

这里没什么特别的,就是标准的 StatefulWidget 写法。

状态类的定义

class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
late AnimationController _bgController;
late Animation<double> _bgAnimation;
int _selectedCategoryIndex = 0;

为什么要用 TickerProviderStateMixin?

因为页面里有多个动画需要管理,这个 mixin 提供了 vsync 参数,让动画能跟屏幕刷新同步。如果不加这个,动画可能会掉帧。

late 关键字表示这些变量会在 initState 里初始化,不是在构造函数里。

页面布局结构


Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
const UserInfoBar(),
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
children: [
BlindBoxCard(categoryIndex: _selectedCategoryIndex),
CategoryScroll(
selectedIndex: _selectedCategoryIndex,
onCategoryChanged: (index) {
setState(() {
_selectedCategoryIndex = index;
});
},
),
const ActionButtons(),
const HotBoxList(),
],
),
),
),
],
),
),
);
}

布局思路:

  • 用 SafeArea 包裹,避免内容被刘海屏或状态栏遮挡
  • 最外层是 Column,从上到下排列组件
  • Expanded 让中间的滚动区域占满剩余空间
  • SingleChildScrollView 让内容可以滚动
  • BouncingScrollPhysics 是iOS风格的弹性滚动,手感更好

这种布局很常见,顶部固定,中间可滚动,简单实用。

背景动画的实现

初始化动画控制器


void initState() {
super.initState();
_bgController = AnimationController(
duration: const Duration(seconds: 3),
vsync: this,
)..repeat(reverse: true);

_bgAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(_bgController);
}

动画的原理:

AnimationController 会在3秒内从0变到1,然后因为 repeat(reverse: true) 又会从1变回0,如此循环。

为什么是3秒?试过很多值,3秒是最合适的——太快会显得急躁,太慢又不够明显。

应用背景渐变

AnimatedBuilder(
animation: _bgAnimation,
builder: (context, child) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Color.lerp(const Color(0xFFFFE082), const Color(0xFFFFD54F),
_bgAnimation.value)!,
Color.lerp(const Color(0xFFFFF8E1), const Color(0xFFFFECB3),
_bgAnimation.value)!,
],
),
),
child: child,
);
},
)

Color.lerp 的作用:

Color.lerp 在两个颜色之间做插值。当动画值是0时,显示第一个颜色;是1时,显示第二个颜色;是0.5时,显示两个颜色的中间色。

这样就能得到柔和的颜色过渡效果。用户可能不会注意到背景在动,但会觉得这个页面很舒服。

释放动画资源


void dispose() {
_bgController.dispose();
super.dispose();
}

千万别忘了这一步!

我之前就踩过这个坑。测试时没发现问题,但用户反馈说用久了App会卡。后来发现是动画控制器没释放,内存一直在涨。

记住:创建了几个 AnimationController,就要释放几个。

用户信息栏

资产卡片的封装

顶部左侧显示用户的三种资产:潮盒、潮豆、优惠券。每个资产都是一个小卡片:

Widget _buildAssetItem(String label, String value, IconData icon, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.2),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// 图标和文字
],
),
);
}

设计细节:

  • 圆角卡片(borderRadius: 20)看起来更柔和
  • 阴影颜色跟图标颜色一致,形成呼应
  • 数值用粗体显示,让用户一眼看到重点

这种设计在很多游戏App里都能看到,因为它确实好用。

图标的圆形背景

Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
shape: BoxShape.circle,
),
child: Icon(icon, size: 12, color: color),
)

图标放在圆形背景里,增加层次感。背景色用的是图标颜色的半透明版本(opacity: 0.15),这样既有区分度,又不会太抢眼。

中奖播报的滚动

这个功能是为了营造氛围的。看到别人不断中奖,用户也会想试试手气:

_scrollController = AnimationController(
duration: const Duration(seconds: 10),
vsync: this,
)..repeat();

动画值从0到1循环,每10秒一轮。我用这个值来计算每条消息应该显示在什么位置。

计算消息位置

final offset = (_scrollController.value * _notifications.length index)
% _notifications.length;
final opacity = offset < 0.5 ? 1.0 :
(offset < 1.5 ? 1.0 (offset 0.5) : 0.0);

滚动的实现思路:

offset 决定了消息的垂直位置,opacity 控制透明度。当消息进入可视区域时完全显示,离开时逐渐淡出。

这段计算看起来有点复杂,但效果很好。消息会平滑地从下往上滚动,就像真的有人在不断中奖一样。

盲盒卡片的动画

悬浮效果

盲盒卡片是整个首页的视觉中心,必须做得足够吸引人。我给它加了悬浮动画:

_floatController = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
)..repeat(reverse: true);

_floatAnimation = Tween<double>(begin: 8, end: 8).animate(
CurvedAnimation(parent: _floatController, curve: Curves.easeInOut),
);

为什么是8个像素?

试过很多值,8是最合适的——既能看出在动,又不会晃得让人眼晕。

Curves.easeInOut 让运动有加速和减速的过程,看起来更自然。如果用线性曲线,就会像机器人一样僵硬。

应用悬浮动画

AnimatedBuilder(
animation: _floatAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, _floatAnimation.value),
child: child,
);
},
)

Transform.translate 让盲盒在垂直方向上下移动。这种微妙的动画能让静态的盲盒"活"起来。

发光效果

_glowController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat(reverse: true);

_glowAnimation = Tween<double>(begin: 0.3, end: 0.8).animate(_glowController);

光晕动画的时长是1.5秒,比悬浮动画快一点。这样两个动画不会同步,看起来更自然。

应用发光效果

BoxShadow(
color: AppColors.primary.withOpacity(_glowAnimation.value),
blurRadius: 30,
spreadRadius: 5,
)

阴影的透明度从0.3到0.8循环变化。配合大的 blurRadius,就形成了呼吸般的光晕效果。

这种效果在游戏里经常见到,用来标识重要物品。我借鉴过来用在盲盒上,效果很好。

盲盒切换功能

切换逻辑

盲盒卡片两侧有箭头按钮,点击可以切换不同的盲盒:

void _switchBox(int direction) {
setState(() {
_currentBoxIndex = (_currentBoxIndex + direction) % _boxes.length;
if (_currentBoxIndex < 0) _currentBoxIndex = _boxes.length 1;
});
}

循环切换的技巧:

用取模运算(%)可以实现循环效果。比如有3个盲盒,当前是第2个(索引2),点击右箭头:

  • (2 + 1) % 3 = 0,回到第一个

点击左箭头时,索引会变成-1,所以要特殊处理。这样就能无限循环切换了。

箭头按钮样式

Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.primary, AppColors.primaryDark],
),
shape: BoxShape.circle,
),
child: Icon(Icons.chevron_left, color: Colors.white, size: 24),
)

圆形按钮配渐变色,看起来更有质感。而且圆形按钮在移动端更好点击,不容易点偏。

商品预览

预览图标的设计

在盲盒上方,我展示了可能抽到的商品预览:

Widget _buildProductPreview(IconData icon, String name, Color color) {
return Column(
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withOpacity(0.3),
blurRadius: 12,
),
],
),
child: Icon(icon, size: 24, color: color),
),
Text(name, style: TextStyle(fontSize: 10)),
],
);
}

为什么不用真实图片?

因为图标加载更快,而且在这么小的尺寸下,图标比图片更清晰。每个商品用一个圆形图标表示,配上彩色阴影,一眼就能区分。

分类导航

分类数据定义

static const List<Map<String, dynamic>> categories = [
{'name': '尝鲜袋', 'icon': Icons.card_giftcard},
{'name': '潮玩袋', 'icon': Icons.toys},
{'name': '乐玩喜袋', 'icon': Icons.celebration},
{'name': '3C福袋', 'icon': Icons.devices},
{'name': '乐享袋', 'icon': Icons.star},
];

用 static const 声明数据,这样在编译时就确定了,运行时不会重复创建,能节省内存。

分类栏容器

Container(
height: 60,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFFF8C00), Color(0xFFFFD700)],
),
),
child: ListView.builder(
scrollDirection: Axis.horizontal,
// …
),
)

分类栏用了金色渐变背景,跟整体主题一致。固定高度60,横向滚动。

分类项的动画

AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: isSelected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
if (isSelected) Icon(categories[index]['icon']),
Text(categories[index]['name']),
],
),
)

为什么用 AnimatedContainer?

因为它能自动处理属性变化的动画。当 isSelected 改变时,背景色会平滑过渡,不会突然跳变。

选中的分类显示白色背景和图标,未选中的只显示文字。这样对比很明显,用户不会搞混。

状态同步


void didUpdateWidget(BlindBoxCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.categoryIndex != widget.categoryIndex) {
setState(() {
_currentBoxIndex = 0;
});
}
}

为什么要重置索引?

假设"尝鲜袋"有3个盲盒,用户切到第3个(索引2)。然后切换到"潮玩袋",如果"潮玩袋"只有2个盲盒,索引2就越界了。

所以每次切换分类,都把索引重置为0。这样既安全,也符合用户习惯。

快捷操作按钮

主按钮的脉冲动画

中间的"立即开箱"按钮是最重要的操作入口,我给它加了个脉冲动画:

_pulseController = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
)..repeat(reverse: true);

_pulseAnimation = Tween<double>(begin: 1.0, end: 1.08).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);

为什么是1.08?

因为太大会显得夸张,太小又看不出效果。1.08是我测试了很多次后定下来的,刚好能吸引注意力又不会太突兀。

应用脉冲效果

AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
return Transform.scale(
scale: _pulseAnimation.value,
child: child,
);
},
)

按钮的缩放比例在1.0到1.08之间循环变化,看起来就像在"呼吸"。这种动画能有效吸引用户的注意力,引导他们点击。

主按钮样式

Container(
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 18),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF4CAF50), Color(0xFF2E7D32)],
),
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: const Color(0xFF4CAF50).withOpacity(0.5),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Text('立即开箱'),
)

绿色的选择:

用绿色是因为它代表"开始"、“行动”。配合渐变和阴影,让按钮看起来很有质感,用户会忍不住想点。

侧边按钮

主按钮两侧是"每日签到"和"盒内预览"按钮:

Widget _buildSideButton(String label, IconData icon, Color color) {
return Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.3),
blurRadius: 12,
),
],
),
child: Icon(icon, color: color, size: 26),
),
Text(label),
],
);
}

侧边按钮用白色背景,配上彩色阴影。每个按钮的阴影颜色跟图标颜色一致,形成视觉呼应。这种设计让按钮看起来像是"浮"在页面上的。

签到弹窗

点击"每日签到"会弹出一个对话框:

showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check, color: Colors.white, size: 40),
Text('签到成功!'),
Text('获得 100 潮豆'),
],
),
),
);

弹窗的设计很简单:一个大大的对勾图标,告诉用户签到成功了。然后显示获得的奖励。简单直接,用户一看就懂。

玩法说明

按钮下方有个玩法说明和价格展示:

Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
),
child: Row(
children: [
Icon(Icons.help_outline),
Text('玩法说明'),
Text('¥59.00'),
],
),
)

把玩法说明和价格放在一起,用户既能了解规则,又能看到价格。点击"玩法说明"会弹出详细的规则介绍。

热门盲盒列表

列表标题

Row(
children: [
Container(
width: 4,
height: 18,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.primary, AppColors.primaryDark],
),
),
),
Text('热门福袋'),
Text('查看更多'),
],
)

左边用一个竖条装饰,右边是"查看更多"链接。这种设计很常见,因为它确实好用。

列表项布局

Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
// 图片
NetworkImageWithLoading(imageUrl: box['image']),
// 信息
Expanded(
child: Column(
children: [
Text(box['name']),
Text(box['desc']),
Text('¥${box['price']}'),
],
),
),
// 开箱按钮
Container(child: Text('开箱')),
],
),
)

列表项的布局:

  • 左边是盲盒图片,带一个角标(爆款、热卖、新品)
  • 中间是盲盒信息:名称、描述、价格、销量
  • 右边是开箱按钮

这种布局很常见,因为它能在有限的空间里展示足够多的信息。

图片角标

Stack(
children: [
NetworkImageWithLoading(imageUrl: box['image']),
Positioned(
top: 0,
left: 0,
child: Container(
decoration: BoxDecoration(
color: box['tagColor'],
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
bottomRight: Radius.circular(8),
),
),
child: Text(box['tag']),
),
),
],
)

用 Stack 和 Positioned 把角标放在图片左上角。角标的圆角只有左上和右下,这样看起来更有设计感。

数据结构

final List<Map<String, dynamic>> _hotBoxes = [
{
'name': '数码福袋',
'desc': '手机、耳机、平板等你来',
'price': 99,
'sold': 2888,
'tag': '爆款',
'tagColor': Color(0xFFFF6B6B),
},
];

每个盲盒都有自己的标签和标签颜色,这样可以突出不同的卖点。实际项目中,这些数据应该从后端API获取。

一些细节优化

滚动手感

SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(children: [...]),
)

为什么用 BouncingScrollPhysics?

这是iOS风格的弹性滚动。滚到顶部或底部时会有回弹效果,手感很好。虽然是个小细节,但用户能感受到。

如果用默认的 ClampingScrollPhysics(Android风格),滚到边界就会硬停,没那么舒服。

图片加载

NetworkImageWithLoading(
imageUrl: box['image'],
width: 80,
height: 80,
borderRadius: BorderRadius.circular(12),
backgroundColor: AppColors.lightGrey,
)

这个组件会在图片加载时显示占位符,加载失败时显示错误提示。这样用户不会看到空白或者报错。

而且它支持圆角和背景色,可以直接在组件里配置,不用再套一层 ClipRRect。

颜色系统

class AppColors {
static const Color primary = Color(0xFFFFD700);
static const Color primaryDark = Color(0xFFFFA500);
static const Color grey = Color(0xFF999999);
static const Color red = Color(0xFFFF4444);
}

为什么要定义颜色常量?

  • 统一性:整个App用同一套颜色,看起来更协调
  • 可维护性:要改颜色只需要改一个地方
  • 可读性:AppColors.primary 比 Color(0xFFFFD700) 更容易理解
  • 性能优化

    动画资源管理


    void dispose() {
    _bgController.dispose();
    _floatController.dispose();
    _glowController.dispose();
    _scrollController.dispose();
    _pulseController.dispose();
    super.dispose();
    }

    千万别忘了释放!

    我之前就踩过这个坑。测试时没发现问题,但用户反馈说用久了App会卡。后来发现是动画控制器没释放,内存一直在涨。

    记住:创建了几个 AnimationController,就要释放几个。

    组件拆分的好处

    首页的每个功能都拆成了独立组件:UserInfoBar、BlindBoxCard、CategoryScroll、ActionButtons、HotBoxList。

    为什么要拆分?

  • 代码更好维护:每个组件只管自己的事,改一个不会影响其他的
  • 性能更好:Flutter的刷新机制是局部的,只有状态变化的组件会重新构建
  • 举个例子:用户点击左箭头切换盲盒,只有 BlindBoxCard 会重新构建,顶部的 UserInfoBar 和底部的 HotBoxList 都不会动。

    常量数据

    static const List<Map<String, dynamic>> categories = [
    {'name': '尝鲜袋', 'icon': Icons.card_giftcard},
    ];

    const 表示这个数据在编译时就确定了,运行时不会重复创建。static 表示这个数据属于类本身,不是某个实例的。

    这样做能节省内存。如果不加 const,每次构建 Widget 都会创建新的 List,那就太浪费了。

    视觉设计思考

    为什么选金黄色

    首页的主色调是金黄色渐变。这不是随便选的:

    • 金色代表财富和好运,跟盲盒的"中奖"主题很搭
    • 暖色调让人感觉兴奋,能激发用户的购买欲望
    • 渐变比纯色更有层次感,不会显得单调

    不同功能用不同的强调色:潮盒用红色、潮豆用黄色、优惠券用绿色。这些颜色都是精心挑选的,太鲜艳会刺眼,太暗淡又不够醒目。

    阴影的使用

    页面里到处都是阴影,但深浅不一:

    • 盲盒卡片的阴影最深(blurRadius: 30),因为它是视觉焦点
    • 资产卡片的阴影较浅(blurRadius: 6),起辅助作用
    • 按钮的阴影适中(blurRadius: 10),让它看起来可点击

    我的经验:

    阴影不是越多越好。用得好能增加层次感,用得不好就会显得脏乱。

    主角用深阴影,配角用浅阴影,背景不用阴影。

    圆角的统一

    所有卡片和按钮都用了圆角,而且圆角半径是统一的:

    • 大卡片用 BorderRadius.circular(16)
    • 小卡片用 BorderRadius.circular(12)
    • 按钮用 BorderRadius.circular(20)

    统一的圆角能让整个App看起来更协调。如果这里用16,那里用8,再来个24,就会显得很乱。

    建议在项目开始时就定好几个标准值,然后全局使用。

    踩过的坑

    动画卡顿

    一开始我把所有动画都放在一个 AnimationController 里管理,结果发现页面会卡顿。

    原因:

    如果多个动画共用一个控制器,它们的时长和曲线都必须一样。这样很不灵活,而且容易出现性能问题。

    解决方案:

    每个动画用自己的控制器,这样可以独立控制时长、曲线和播放状态。

    图片加载失败

    用户反馈说有些盲盒图片显示不出来。检查后发现是网络图片加载失败,但我没有做错误处理。

    解决方案:

    封装了一个 NetworkImageWithLoading 组件,加载失败时显示占位图。这样用户至少能看到一个灰色的占位符,不会觉得是Bug。

    滚动冲突

    首页用了 SingleChildScrollView,里面又有一个横向滚动的分类栏。一开始会出现滚动冲突。

    解决方案:

    给分类栏设置了固定高度(height: 60),并且用 ListView.builder 而不是 SingleChildScrollView。这样Flutter能正确识别滚动方向,不会冲突。

    内存泄漏

    测试时没发现问题,但用户反馈说用久了App会卡。后来发现是动画控制器没释放。

    解决方案:

    在 dispose 方法里释放所有的 AnimationController。这是Flutter开发的基本功,千万不能忘。

    写在最后

    首页是整个App的门面,必须做得足够吸引人。但吸引人不等于花里胡哨,关键是要把握好度。

    我的设计原则:

  • 动画要有,但不能太多。背景呼吸、盲盒悬浮、光晕闪烁、按钮脉冲,这些都是微妙的动画,不会让人觉得眼花缭乱。

  • 信息要全,但不能太挤。用户资产、中奖播报、盲盒展示、分类导航、快捷按钮、热门列表,每个都有自己的位置,不会互相干扰。

  • 交互要顺,但不能太复杂。点击跳转、左右切换、分类筛选,都是用户熟悉的操作,不需要学习成本。

  • 做完首页后,我拿给几个朋友试用,反馈都不错。有人说"看起来很专业",有人说"想点进去看看",这就够了。

    一些经验:

    • 动画时长很重要。太快会显得急躁,太慢会显得拖沓。我测试了很多次,最后定在1.5-3秒之间。

    • 颜色不要太多。一开始我用了七八种颜色,看起来很乱。后来精简到三四种,反而更舒服。

    • 性能优化不能忽视。首页是用户打开App后第一个看到的页面,如果卡顿,印象就毁了。

    下一步:

    首页只是开始,后面还有商城列表、商品详情、购物车等页面要做。不过有了首页的基础,后面的页面会更容易。

    如果你也在做类似的项目,希望这篇文章能给你一些启发。代码不是最重要的,重要的是理解背后的设计思路。


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

    赞(0)
    未经允许不得转载:171主机测评 » Flutter For Openharmony盲盒抽奖App应用实战+首页实现
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址