“今天吃什么"是每个选择困难症患者的终极难题。这个 App 用一个转盘来解决——点击"开始”,emoji 在屏幕上快速轮转,最后停在一个随机美食上。动画的核心不是真旋转,而是 setTimeout 驱动的定时切换——每隔 60ms 切换一个 emoji,速度逐渐变慢,模拟"转盘减速"的视觉效果。这个方案比 Canvas 动画简单得多,但效果足够好。

完整效果
在这里插入图片描述

一、单页四 Tab 架构

Tab 图标 功能
转盘 🎰 核心功能——随机选美食
管理 📋 增删改查美食列表
历史 📜 查看历史选择记录
设置 ⚙️ 主题切换、数据管理

整个 App 只有一个页面——Index.ets。 四个 Tab 用 if-else 条件渲染——和时光胶囊 App 的架构完全一致。这是单页面 App 的标准模式——简单、直接、无路由开销。

二、数据模型

FoodItem 接口

interface FoodItem {
  id: string          // 唯一标识
  name: string        // 美食名称
  emoji: string       // 表情符号
  category: string    // 分类 key
  isFavorite: boolean // 是否收藏
  createdAt: number   // 创建时间
}

在这里插入图片描述

PickRecord 接口

interface PickRecord {
  foodId: string      // 选中的美食 ID
  foodName: string    // 美食名称(冗余,方便展示)
  emoji: string       // 表情(冗余)
  pickedAt: number    // 选择时间
}

CATEGORIES 常量

const CATEGORIES: 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 方法的完整实现

private spin(): void {
  if (!this.db || this.foods.length === 0) return;
  this.spinning = true; this.picked = null;
  let c = 0; const total = 15 + Math.floor(Math.random() * 10);
  const tick = (): void => {
    this.spinIdx = c % this.foods.length; c++;
    if (c < total) { setTimeout(tick, 60 + c * 8); }
    else {
      this.db!.pick(this.pickCat === 'all' ? undefined : this.pickCat)
        .then((f: FoodItem | null) => {
          this.picked = f; this.spinning = false; this.load();
        });
    }
  };
  tick();
}

在这里插入图片描述

动画参数拆解

参数 含义
total 15 + 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 + 🎉 包裹的名称 + 结论文字。 🎉 营造庆祝氛围——“选到了!”。

三种状态的切换逻辑

未开始 → 点击按钮 → spinning=true → 转动中
转动中 → tick 结束 → spinning=false, picked=有值 → 选中结果
选中结果 → 点击按钮 → spinning=true → 转动中(重新开始)

if-else 三选一——同一时刻只显示一种状态。 spinningpicked 两个布尔值控制三种状态的切换。

五、转盘的随机性保证

随机总次数

const total = 15 + Math.floor(Math.random() * 10)

每次转盘的总切换次数不同(15~24 次)——增加不确定性。 用户无法预测"还要转多久"。如果总次数固定,用户转几次就能预判结果。

实际选择的随机性

this.db!.pick(this.pickCat === 'all' ? 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.length > 0 && !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.pickCat === c.key ? '#FFFFFF' : this.gc().text)
        .backgroundColor(this.pickCat === c.key ? this.gc().primary : this.gc().surface)
        .border({ width: 1, color: this.pickCat === c.key ? this.gc().primary : this.gc().border })
        .onClick(() => { this.pickCat = c.key; this.load(); })
    })
  }
}
.scrollable(ScrollDirection.Horizontal)

CATEGORIES.slice(0, 6) 的含义

只显示前 6 个分类——不含"饮品"。 转盘默认显示中餐/西餐/日料/韩餐/甜品/饮品之外的分类?不对——slice(0, 6) 是 index 0 到 5,包含"全部/中餐/西餐/日料/韩餐/甜品",不含"饮品"。

为什么不显示所有 7 个分类? 屏幕宽度有限——7 个标签会太拥挤。前 6 个已经覆盖主要场景。

筛选与数据加载

.onClick(() => { this.pickCat = c.key; this.load(); })

点击分类后立即 load()——更新 foods 数组。 转盘只会从当前筛选的分类中选择——选"中餐"就只在中餐里随机。

load 方法的筛选逻辑

private load(): void {
  if (!this.db) return;
  this.foods = this.db.getFoods(
    this.pickCat === 'all' ? undefined : this.pickCat
  );
  this.history = this.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 变量的分工

变量 类型 作用 Tab
tab string 当前 Tab 全局
theme ThemeType 当前主题 全局
foods FoodItem[] 当前分类的食物列表 转盘/管理
history PickRecord[] 历史记录(最近30条) 历史
pickCat string 当前筛选分类 转盘
picked FoodItem | null 选中的食物 转盘
spinning boolean 是否在转 转盘
spinIdx number 当前轮转的索引 转盘
newName string 新食物名称 管理
newEmoji string 新食物 emoji 管理
newCat string 新食物分类 管理
showClear boolean 是否显示清空弹窗 历史/设置

12 个 @State 变量——比时光胶囊的 20 个少。 因为这个 App 只有 3 个数据模型(FoodItem/PickRecord/Category),比时光胶囊的 Capsule 简单。

十、数据刷新的时机

触发 方法 刷新内容
首次加载 init() → load() foods + history
切换 Tab onClick → 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 秒。用户的心理:点击→等待→看到结果——这个"等待"就是仪式感。

为什么不是立即显示结果

立即显示"麻辣香锅"没有仪式感——用户会觉得"随便选的"。 转盘动画让用户觉得"系统在认真选"——虽然本质是随机数,但动画让随机变得"有过程"。

分类筛选增加控制感

纯随机让用户觉得"系统在选"——分类筛选让用户觉得"我在选范围,系统在范围内选"。 这种"半控制"状态比完全随机更有参与感——用户不是被动接受,而是主动设定范围。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐