“今天吃什么是每个选择困难症患者的终极难题。这个 App 用一个转盘来解决——点击开始”emoji 在屏幕上快速轮转最后停在一个随机美食上。动画的核心不是真旋转而是 setTimeout 驱动的定时切换——每隔 60ms 切换一个 emoji速度逐渐变慢模拟转盘减速的视觉效果。这个方案比 Canvas 动画简单得多但效果足够好。完整效果一、单页四 Tab 架构Tab图标功能转盘核心功能——随机选美食管理增删改查美食列表历史查看历史选择记录设置⚙️主题切换、数据管理整个 App 只有一个页面——Index.ets。四个 Tab 用 if-else 条件渲染——和时光胶囊 App 的架构完全一致。这是单页面 App 的标准模式——简单、直接、无路由开销。二、数据模型FoodItem 接口interfaceFoodItem{id:string// 唯一标识name:string// 美食名称emoji:string// 表情符号category:string// 分类 keyisFavorite:boolean// 是否收藏createdAt:number// 创建时间}PickRecord 接口interfacePickRecord{foodId:string// 选中的美食 IDfoodName:string// 美食名称冗余方便展示emoji:string// 表情冗余pickedAt:number// 选择时间}CATEGORIES 常量constCATEGORIES:FoodCategory[][{key:all,name:全部,emoji:️},{key:chinese,name:中餐,emoji:},{key:western,name:西餐,emoji:},{key:japanese,name:日料,emoji:},{key:korean,name:韩餐,emoji:},{key:dessert,name:甜品,emoji:},{key:drink,name:饮品,emoji:}]7 个分类——覆盖主要餐饮类型。全部分类是特殊的——它不是真正的分类而是不限分类的选项。三、转盘动画的核心算法spin 方法的完整实现privatespin():void{if(!this.db||this.foods.length0)return;this.spinningtrue;this.pickednull;letc0;consttotal15Math.floor(Math.random()*10);consttick():void{this.spinIdxc%this.foods.length;c;if(ctotal){setTimeout(tick,60c*8);}else{this.db!.pick(this.pickCatall?undefined:this.pickCat).then((f:FoodItem|null){this.pickedf;this.spinningfalse;this.load();});}};tick();}动画参数拆解参数值含义total15 random(0~9)总切换次数15~24 次初始间隔60ms第一次切换的速度间隔增量8ms/次每次切换后间隔增加最终间隔60 24×8 252ms最后一次切换的速度速度变化曲线第 1 次60ms ← 最快 第 2 次68ms 第 3 次76ms ... 第 15 次180ms ← 中速 ... 第 24 次252ms ← 最慢停止间隔从 60ms 逐渐增加到 252ms——模拟物理转盘的减速效果。用户看到的是emoji 越来越慢最后咔嗒一声停在某个美食上。这比匀速切换更有真实感。为什么用 setTimeout 而不是动画框架方案复杂度控制力适用场景setTimeout 链低高精确控制每帧简单定时切换Animation API中中平滑过渡动画Canvas高最高复杂图形动画转盘不需要平滑过渡——只需要快速切换 emoji。setTimeout 链是最简单的方案——每一帧手动切换不需要动画框架的插值计算。四、转盘的三种视觉状态状态一准备中未开始Text().fontSize(80)Text(点击下方按钮).fontSize(FontSize.md).fontColor(this.gc().textSecondary).margin({top:Spacing.md})大号 emoji 引导文字。用户一眼就知道该做什么——点按钮。状态二转动中Text(this.foods[this.spinIdx]?.emoji||).fontSize(80)Text(this.foods[this.spinIdx]?.name||...).fontSize(FontSize.xxl).fontColor(this.gc().text).fontWeight(FontWeight.Bold).margin({top:Spacing.md})显示当前快速切换的 emoji 名称。用户看到 emoji 在变——知道正在选。spinIdx每 60ms 更新一次——视觉上就是 emoji 在快速轮转。状态三选中结果Text(this.picked.emoji).fontSize(80)Text( this.picked.name ).fontSize(FontSize.xxl).fontColor(this.gc().text).fontWeight(FontWeight.Bold).margin({top:Spacing.md})Text(就决定吃这个了).fontSize(FontSize.md).fontColor(this.gc().textSecondary).margin({top:Spacing.sm})大 emoji 包裹的名称 结论文字。 营造庆祝氛围——“选到了”。三种状态的切换逻辑未开始 → 点击按钮 → spinningtrue → 转动中 转动中 → tick 结束 → spinningfalse, picked有值 → 选中结果 选中结果 → 点击按钮 → spinningtrue → 转动中重新开始if-else 三选一——同一时刻只显示一种状态。spinning和picked两个布尔值控制三种状态的切换。五、转盘的随机性保证随机总次数consttotal15Math.floor(Math.random()*10)每次转盘的总切换次数不同15~24 次——增加不确定性。用户无法预测还要转多久。如果总次数固定用户转几次就能预判结果。实际选择的随机性this.db!.pick(this.pickCatall?undefined:this.pickCat).then((f:FoodItem|null){...})最终结果由db.pick()决定——不是由spinIdx决定。spinIdx只是视觉效果——快速切换的 index。实际选中是数据库层面的随机——两者独立。为什么要分离视觉随机和结果随机视觉随机保证看起来在选——结果随机保证真的随机。如果用 spinIdx 的最终值作为结果用户可能观察到规律比如每次都停在第 3 个。分离后视觉和结果完全独立——不可能被预测。六、按钮的状态设计Row(){Text( 开始).fontSize(FontSize.xl).fontColor(#FFFFFF).fontWeight(FontWeight.Bold)}.width(80%).height(56).justifyContent(FlexAlign.Center).backgroundColor(this.foods.length0!this.spinning?this.gc().primary:this.gc().border).borderRadius(BorderRadius.xl).onClick(()this.spin()).margin({bottom:Spacing.xl})按钮的两种状态状态条件背景色可点击活跃有美食 未在转primary主色是禁用无美食或正在转border灰色否用颜色变化暗示可点击状态——不需要禁用 onClick。用户看到灰色就知道点不了——即使点了也不会触发spin 方法开头有if (this.db.foods.length 0) return保护。七、分类筛选的设计筛选标签的结构Scroll(){Row(){ForEach(CATEGORIES.slice(0,6),(c:FoodCategory){Text(c.emoji c.name).fontColor(this.pickCatc.key?#FFFFFF:this.gc().text).backgroundColor(this.pickCatc.key?this.gc().primary:this.gc().surface).border({width:1,color:this.pickCatc.key?this.gc().primary:this.gc().border}).onClick((){this.pickCatc.key;this.load();})})}}.scrollable(ScrollDirection.Horizontal)CATEGORIES.slice(0, 6) 的含义只显示前 6 个分类——不含饮品。转盘默认显示中餐/西餐/日料/韩餐/甜品/饮品之外的分类不对——slice(0, 6) 是 index 0 到 5包含全部/中餐/西餐/日料/韩餐/甜品不含饮品。为什么不显示所有 7 个分类屏幕宽度有限——7 个标签会太拥挤。前 6 个已经覆盖主要场景。筛选与数据加载.onClick((){this.pickCatc.key;this.load();})点击分类后立即 load()——更新 foods 数组。转盘只会从当前筛选的分类中选择——选中餐就只在中餐里随机。load 方法的筛选逻辑privateload():void{if(!this.db)return;this.foodsthis.db.getFoods(this.pickCatall?undefined:this.pickCat);this.historythis.db.getHistory().slice(0,30);}pickCat all时传 undefined——数据库返回全部食物。这是全部分类的特殊语义——不传分类参数等于不过滤。八、空状态的设计转盘 Tab 的空状态if (this.foods.length 0) → 按钮灰色 → 点击 spin() 直接 return → 不显示动画没有美食时不能转盘——按钮变灰点击无效。这比点了弹出提示更好——用户不需要尝试就知道没东西可选。美食数量提示Text(可选 this.foods.length 种).fontSize(FontSize.xs).fontColor(this.gc().textSecondary).margin({bottom:Spacing.md})告诉用户有多少种选择——增加转盘的期待感。可选 12 种比可选 0 种更有吸引力。九、State 变量的分工变量类型作用Tabtabstring当前 Tab全局themeThemeType当前主题全局foodsFoodItem[]当前分类的食物列表转盘/管理historyPickRecord[]历史记录最近30条历史pickCatstring当前筛选分类转盘pickedFoodItem | null选中的食物转盘spinningboolean是否在转转盘spinIdxnumber当前轮转的索引转盘newNamestring新食物名称管理newEmojistring新食物 emoji管理newCatstring新食物分类管理showClearboolean是否显示清空弹窗历史/设置12 个 State 变量——比时光胶囊的 20 个少。因为这个 App 只有 3 个数据模型FoodItem/PickRecord/Category比时光胶囊的 Capsule 简单。十、数据刷新的时机触发方法刷新内容首次加载init() → load()foods history切换 TabonClick → load()foods history点击分类onClick → load()foods转盘结束spin() → load()foods history新增食物addFood() → load()foods删除食物del() → load()foods切换收藏fav() → load()foods清空历史clr() → load()history恢复默认reset() → load()foods每次数据变化后都调用 load()——保证 UI 和数据同步。load() 是刷新的统一入口——不需要针对每种操作写不同的刷新逻辑。十一、转盘的用户体验设计随机性的仪式感用户点击开始 → emoji 快速轮转60ms/次 → 逐渐减速8ms/次 → 最终停在某个美食上 → 庆祝效果整个过程约 1~2 秒——足够营造期待感不会太长让用户不耐烦。15~24 次切换 × 平均 120ms ≈ 2~3 秒。用户的心理点击→等待→看到结果——这个等待就是仪式感。为什么不是立即显示结果立即显示麻辣香锅没有仪式感——用户会觉得随便选的。转盘动画让用户觉得系统在认真选——虽然本质是随机数但动画让随机变得有过程。分类筛选增加控制感纯随机让用户觉得系统在选——分类筛选让用户觉得我在选范围系统在范围内选。这种半控制状态比完全随机更有参与感——用户不是被动接受而是主动设定范围。