
写在前面
首页是整个项目的门面。我的想法很简单:用户打开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);
}
为什么要定义颜色常量?
性能优化
动画资源管理
void dispose() {
_bgController.dispose();
_floatController.dispose();
_glowController.dispose();
_scrollController.dispose();
_pulseController.dispose();
super.dispose();
}
千万别忘了释放!
我之前就踩过这个坑。测试时没发现问题,但用户反馈说用久了App会卡。后来发现是动画控制器没释放,内存一直在涨。
记住:创建了几个 AnimationController,就要释放几个。
组件拆分的好处
首页的每个功能都拆成了独立组件:UserInfoBar、BlindBoxCard、CategoryScroll、ActionButtons、HotBoxList。
为什么要拆分?
举个例子:用户点击左箭头切换盲盒,只有 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


