Flutter for OpenHarmony 动效实战打造一个会“跳”的幸运骰子应用在游戏、决策辅助甚至冥想练习中掷骰子这一古老行为因其随机性与仪式感而历久弥新。而在移动应用时代如何将物理世界的“摇、掷、滚、停”转化为数字屏幕上的沉浸式体验答案在于——精细的动画设计。 加入社区 欢迎加入开源鸿蒙跨平台开发者社区获取最新资源与技术支持 开源鸿蒙跨平台开发者社区完整效果一、核心体验让骰子“活”起来该应用的核心亮点在于其双重动画系统旋转动画Rotation模拟骰子被抛起后高速翻滚弹跳动画Bounce模拟骰子落地时的弹性碰撞与最终静止。 这不是简单的“转圈”而是对真实物理过程的抽象与艺术化再现。当用户点击骰子或按钮骰子会快速旋转多圈12π 弧度即6整圈伴随不规则缩放先放大撞击地面、再压缩反弹、最后轻微回弹至原尺寸动画结束瞬间显示一个1~6之间的随机点数。整个过程耗时1.2秒节奏紧凑而不失趣味完美复刻了“掷骰—滚动—停稳”的心理预期。二、动画系统详解1. 旋转动画CurvedAnimationfastLinearToSlowEaseIn_rotationAnimationTweendouble(begin:0,end:12*pi).animate(CurvedAnimation(parent:_rollController,curve:Curves.fastLinearToSlowEaseIn,),);12 * pi表示旋转6圈2π 为一圈足够产生“混乱感”以强化随机性fastLinearToSlowEaseIn初始阶段线性加速模拟手部快速甩出后期缓慢减速模拟摩擦力作用比匀速旋转更真实。2. 弹跳动画TweenSequence模拟物理回弹_bounceAnimationTweenSequence([TweenSequenceItem(tween:Tween(begin:1.0,end:1.2),weight:30),// 落地冲击放大TweenSequenceItem(tween:Tween(begin:1.2,end:0.9),weight:40),// 主要压缩缩小TweenSequenceItem(tween:Tween(begin:0.9,end:1.05),weight:20),// 第一次回弹TweenSequenceItem(tween:Tween(begin:1.05,end:1.0),weight:10),// 微调归位]).animate(CurvedAnimation(parent:_rollController,curve:Curves.bounceOut));TweenSequence将多个Tween按权重拼接形成复杂的时间轴动画权重weight控制各阶段持续时间比例总和100此处主压缩阶段最长符合物理直觉Curves.bounceOut整体叠加弹性缓动增强“落地感”。设计哲学动画不仅是装饰更是传达状态与反馈的媒介。三、UI/UX 设计细节1. 拟物化骰子设计圆角矩形 双重阴影营造立体浮雕感紫色描边与主题色一致强化品牌识别内部点阵使用StackAlign精准定位6种点数图案符合标准骰子布局如1点居中6点分三列动态占位符滚动时显示“?”暗示结果未定。2. 智能状态反馈状态视觉表现空闲显示上次结果 “轻触开始”提示滚动中骰子变为“?” 文字提示“掷出中…” FAB 显示加载圈完成显示新点数 “上次结果: X • N分钟前”3. 顶部计数器在 AppBar 右侧显示“今日次数”仅在有记录时出现避免界面杂乱数字加粗突出满足用户“成就收集”心理。4. 底部知识彩蛋固定提示“标准骰子相对两面点数之和恒为7”使用浅紫色背景图标既传递知识又不干扰主操作。四、交互逻辑与状态管理核心状态变量int _currentValue1;// 当前显示点数bool _isRollingfalse;// 是否正在动画中int _rollCount0;// 今日摇动次数DateTime?_lastRollTime;// 上次摇动时间关键方法_rollDice()防重复点击if (_isRolling) return启动动画_finalizeRoll()动画结束后生成随机数、更新计数与时间_formatTimeAgo()人性化时间显示“刚刚”、“3分钟前”等。✅健壮性通过_isRolling锁防止用户狂点导致状态错乱。五、技术亮点总结技术点应用场景价值with TickerProviderStateMixin提供 vsync防止后台动画消耗资源AnimatedBuilder驱动 Transform高效重建局部 UITransform.rotateTransform.scale复合动画实现旋转缩放同步TweenSequence多阶段动画精细控制弹跳节奏CurvedAnimation非线性缓动增强物理真实感BoxShadowBorder拟物设计提升视觉质感六、扩展与应用场景可扩展方向多骰子模式支持同时掷2~5颗骰子历史记录页查看所有摇动结果与统计如各点数出现频率音效反馈添加骰子滚动与落地音效震动反馈在结果揭晓时触发设备震动主题切换木质、金属、霓虹等不同风格骰子。应用场景桌游辅助工具替代实体骰子决策助手用于“是/否”或1~6选项的随机选择教学演示概率论入门示例减压玩具简单互动带来即时反馈与放松。七、结语小应用大匠心这个看似简单的骰子应用实则凝聚了动画设计、用户体验、状态管理的多重考量。它证明了即使是最基础的功能只要注入对细节的关注与对用户心理的理解就能创造出令人愉悦的数字体验。正如骰子本身所象征的——在确定的规则中拥抱不确定性开发者也应在严谨的代码框架下大胆探索动效与交互的可能性。而这颗会跳的紫色骰子正是 Flutter 强大表现力的最佳注脚。完整代码importdart:math;importpackage:flutter/material.dart;voidmain(){runApp(const DiceRollerApp());}class DiceRollerApp extends StatelessWidget{const DiceRollerApp({super.key});override Widget build(BuildContext context){returnMaterialApp(debugShowCheckedModeBanner: false, title: 摇骰子, theme: ThemeData(brightness: Brightness.light, primarySwatch: Colors.deepPurple, scaffoldBackgroundColor: const Color(0xFFF5F0FF), appBarTheme: const AppBarTheme(backgroundColor: Colors.deepPurple, foregroundColor: Colors.white, elevation:0,), floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: Colors.deepPurple, foregroundColor: Colors.white, elevation:8,),), home: const DiceRollerScreen(),);}}class DiceRollerScreen extends StatefulWidget{const DiceRollerScreen({super.key});override StateDiceRollerScreencreateState()_DiceRollerScreenState();}class _DiceRollerScreenState extends StateDiceRollerScreenwith TickerProviderStateMixin{late AnimationController _rollController;late Animationdouble_rotationAnimation;late Animationdouble_bounceAnimation;int _currentValue1;bool _isRollingfalse;final Random _randomRandom();int _rollCount0;DateTime? _lastRollTime;override voidinitState(){super.initState();_rollControllerAnimationController(duration: const Duration(milliseconds:1200), vsync: this,)..addStatusListener((status){ if(statusAnimationStatus.completed){ _finalizeRoll();} });_rotationAnimationTweendouble(begin:0,end:12*pi).animate(CurvedAnimation(parent:_rollController,curve:Curves.fastLinearToSlowEaseIn),);_bounceAnimationTweenSequence([ TweenSequenceItem(tween:Tweendouble(begin:1.0,end:1.2),weight:30),TweenSequenceItem(tween:Tweendouble(begin:1.2,end:0.9),weight:40),TweenSequenceItem(tween:Tweendouble(begin:0.9,end:1.05),weight:20),TweenSequenceItem(tween:Tweendouble(begin:1.05,end:1.0),weight:10),]).animate(CurvedAnimation(parent:_rollController,curve:Curves.bounceOut));}void_finalizeRoll(){setState((){ _isRollingfalse;_currentValue_random.nextInt(6)1;_rollCount;_lastRollTimeDateTime.now();});} void _rollDice(){ if(_isRolling)return;setState((){ _isRollingtrue;});//重置并启动动画 _rollController.reset();_rollController.forward();} override void dispose(){ _rollController.dispose();super.dispose();} override Widget build(BuildContext context){ return Scaffold(appBar:AppBar(title:const Text(幸运骰子,style:TextStyle(fontSize:22,fontWeight:FontWeight.bold),),centerTitle:true,actions:[ if(_rollCount0)Padding(padding:const EdgeInsets.only(right:16),child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[ const Text(今日次数,style:const TextStyle(fontSize:12,color:Color(0xE6FFFFFF)),), Text($_rollCount, style: const TextStyle(fontSize:16, fontWeight: FontWeight.bold),),],),),],), body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children:[// 骰子容器 GestureDetector(onTap: _rollDice, child: AnimatedBuilder(animation: _rollController, builder:(context, child){returnTransform.rotate(angle: _rotationAnimation.value, child: Transform.scale(scale: _bounceAnimation.value, child: _buildDiceFace(_isRolling ? null:_currentValue),),);},),), const SizedBox(height:40), // 操作提示 Column(children:[Text(_isRolling ? 掷出中...:轻触骰子或按钮开始摇动, style: TextStyle(fontSize:20, fontWeight: FontWeight.w500, color: _isRolling ? Colors.deepPurple:Colors.grey[700], height:1.4,), textAlign: TextAlign.center,),if(!_isRolling_lastRollTime!null)...[const SizedBox(height:12), Text(上次结果: $_currentValue • ${_formatTimeAgo(_lastRollTime!)}, style: const TextStyle(fontSize:15, color: Colors.deepPurple, fontWeight: FontWeight.w500,),),],],), const Spacer(), // 历史记录提示 Container(width: double.infinity, padding: const EdgeInsets.all(16), margin: const EdgeInsets.only(bottom:30, left:20, right:20), decoration: BoxDecoration(color: Colors.deepPurple.shade50, borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.deepPurple.shade200),), child: Row(children:[const Icon(Icons.history, size:20, color: Colors.deepPurple), const SizedBox(width:12), Expanded(child: Text( 小知识标准骰子相对两面点数之和恒为71-6, 2-5, 3-4, style: TextStyle(fontSize:14, height:1.4, color: Colors.deepPurple.shade900,),),),],),),],),), floatingActionButton: AnimatedScale(scale: _isRolling ?0.9:1.0, duration: const Duration(milliseconds:200), curve: Curves.easeInOut, child: FloatingActionButton.extended(onPressed: _rollDice, icon: _isRolling ? const SizedBox(width:20, height:20, child: CircularProgressIndicator(strokeWidth:2, valueColor: AlwaysStoppedAnimation(Colors.white)),):const Icon(Icons.casino, size:28), label: Text(_isRolling ?摇动中...:摇骰子, style: const TextStyle(fontSize:18, fontWeight: FontWeight.bold),), elevation:8,),),);}Widget _buildDiceFace(int? value){returnContainer(width:220, height:220, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28), boxShadow:[BoxShadow(color: Colors.black.withOpacity(0.18), blurRadius:25, offset: const Offset(0,10),), BoxShadow(color: Colors.deepPurple.withOpacity(0.15), blurRadius:15, offset: const Offset(0,5),),], border: Border.all(color: Colors.deepPurple.shade100, width:2),), child: valuenull ? const Center(child: Text(?, style: TextStyle(fontSize:80, fontWeight: FontWeight.bold, color: Colors.deepPurple,),),):_buildDiceDots(value),);}Widget _buildDiceDots(int value){// 骰子点位定义(使用3x3网格坐标)final Mapint, ListAlignmentpatterns{1:[Alignment.center],2:[Alignment.topLeft, Alignment.bottomRight],3:[Alignment.topLeft, Alignment.center, Alignment.bottomRight],4:[Alignment.topLeft, Alignment.topRight, Alignment.bottomLeft, Alignment.bottomRight],5:[Alignment.topLeft, Alignment.topRight, Alignment.center, Alignment.bottomLeft, Alignment.bottomRight],6:[Alignment.topLeft, Alignment.centerLeft, Alignment.bottomLeft, Alignment.topRight, Alignment.centerRight, Alignment.bottomRight,],};returnStack(alignment: Alignment.center, children:[// 背景装饰 Container(margin: const EdgeInsets.all(16), decoration: BoxDecoration(border: Border.all(color: Colors.deepPurple.shade100, width:1.5), borderRadius: BorderRadius.circular(20),),), // 点阵...?patterns[value]?.map((alignment){returnAlign(alignment: alignment, child: Container(width:28, height:28, decoration: BoxDecoration(color: Colors.deepPurple, shape: BoxShape.circle, boxShadow:[BoxShadow(color: Colors.deepPurple.withOpacity(0.3), blurRadius:6, offset: const Offset(0,2),),],),),);}),],);}String _formatTimeAgo(DateTimetime){final nowDateTime.now();final differencenow.difference(time);if(difference.inMinutes1)return刚刚;if(difference.inMinutes60)return${difference.inMinutes}分钟前;if(difference.inHours24)return${difference.inHours}小时前;if(difference.inDays7)return${difference.inDays}天前;return${time.month}月${time.day}日;}}