一、应用概述

随机选择(Random Picker) 是一款解决"选择困难症"的趣味工具应用,帮助用户在多个选项中随机做出决定。应用内置了12个预设选项(涵盖饮食、娱乐、学习等生活场景),用户也可以自由添加和删除自定义选项。点击"帮我选"按钮后,选项会以快速轮播动画的方式跳动,最终定格在选中的选项上。应用基于 HarmonyOS 的 ArkTS 语言和声明式 UI 框架构建,完美展示了 List 列表组件、setInterval 定时器动画、动态数组操作、以及条件渲染等核心技术的综合运用。

对于鸿蒙开发者而言,随机选择是一个典型的交互动画应用案例——它让你在约98行代码中,同时掌握列表渲染定时器动画数组增删状态锁定四大核心能力,这些能力广泛适用于抽奖、投票、问卷、游戏等场景。

1.1 功能特性

  • 一键随机选择:点击按钮以轮播动画方式快速选择
  • 预设12个选项:覆盖日常常见的选择难题
  • 自定义添加选项:通过输入框自由增加新选项
  • 删除选项:每个选项旁提供删除按钮
  • 轮播动画效果:选项快速跳动20次后定格,模拟抽奖效果
  • 选项计数显示:实时显示当前选项总数
  • 空列表保护:选项为空时给出友好提示

1.2 适用场景

  • 中午吃什么的选择困难
  • 周末活动安排决策
  • 团队活动抽奖环节
  • 教学中的随机点名
  • 日常生活中的小决策辅助

二、技术架构

2.1 整体架构概览

┌─────────────────────────────────────────┐
│           数据层                          │
│  ITEMS (预设12个默认选项)                  │
│  currentItems (动态选项列表)               │
├─────────────────────────────────────────┤
│           状态管理层                       │
│  @State currentItems / picked /          │
│         spinning / newItem               │
├─────────────────────────────────────────┤
│           业务逻辑层                       │
│  pick()     —— 随机选择 + 轮播动画        │
│  addItem()  —— 添加新选项                 │
│  removeItem() —— 删除指定选项             │
├─────────────────────────────────────────┤
│           UI 表示层                      │
│  结果显示区 / 选择按钮 / 添加栏 /          │
│  选项列表 (List + ForEach)               │
└─────────────────────────────────────────┘

2.2 状态与动画数据流

用户点击"帮我选"
       ↓
spinning = true (锁定按钮)
       ↓
setInterval(80ms) 启动
       ↓
每80ms随机选取一个选项 → UI 更新 → 视觉跳动
       ↓
count = 20 (循环20次)
       ↓
clearInterval → spinning = false (解锁)
       ↓
最终选项定格显示

三、核心代码深度分析

3.1 完整的页面源码

import { router } from '@kit.ArkUI';

const ITEMS: string[] = [
  '今天吃什么', '去哪里旅游', '看什么电影', '学什么编程语言',
  '读哪本书', '买什么颜色', '周末做什么', '用什么手机',
  '喝什么饮料', '做什么运动', '养什么宠物', '听什么音乐',
];

@Entry
@Component
struct index24 {
  @State currentItems: string[] = [...ITEMS];
  @State picked: string = '';
  @State spinning: boolean = false;
  @State newItem: string = '';
  intervalId: number = -1;

  pick() {
    if (this.spinning) return;
    if (this.currentItems.length === 0) {
      this.picked = '列表是空的!';
      return;
    }
    this.spinning = true;
    let count = 0;
    this.intervalId = setInterval(() => {
      const idx = Math.floor(Math.random() * this.currentItems.length);
      this.picked = this.currentItems[idx];
      count++;
      if (count >= 20) {
        clearInterval(this.intervalId);
        this.spinning = false;
      }
    }, 80);
  }

  addItem() {
    const t = this.newItem.trim();
    if (t === '') return;
    this.currentItems = [...this.currentItems, t];
    this.newItem = '';
  }

  removeItem(idx: number) {
    this.currentItems = this.currentItems.filter((_, i) => i !== idx);
  }

  build() {
    Column() {
      Row() {
        Text('‹ 返回').fontSize(16).fontColor('#0077FF').onClick(() => router.back())
        Text('🎲 随机选择').fontSize(18).fontWeight(FontWeight.Bold)
        Blank()
      }.width('92%').padding({ top: 16, bottom: 8 })

      Column() {
        // 结果展示区
        Column() {
          Text(this.picked || '点击按钮帮你做决定!')
            .fontSize(24).fontWeight(FontWeight.Bold)
            .fontColor(this.picked ? '#F44336' : '#888')
            .padding(12).textAlign(TextAlign.Center)
        }
        .width('92%').height(80).backgroundColor('#fff').borderRadius(12)
        .justifyContent(FlexAlign.Center).margin({ bottom: 8 })

        // 选择按钮
        Button('🎯 帮我选!').width(200).height(52).borderRadius(26)
          .backgroundColor('#F44336').fontSize(20).fontWeight(FontWeight.Bold)
          .onClick(() => this.pick())

        // 添加选项
        Row({ space: 8 }) {
          TextInput({ placeholder: '添加选项...', text: this.newItem })
            .layoutWeight(1).height(40).backgroundColor('#fff').borderRadius(8)
            .padding({ left: 12 }).onChange(v => this.newItem = v)
            .onSubmit(() => this.addItem())
          Button('+').width(40).height(40).borderRadius(20).backgroundColor('#0077FF')
            .fontSize(20).fontColor('#fff').onClick(() => this.addItem())
        }.width('92%').margin({ top: 12, bottom: 8 })

        // 选项列表
        Text(`当前选项 (${this.currentItems.length})`).fontSize(14).fontColor('#888').width('92%')
        List() {
          ForEach(this.currentItems, (item: string, idx: number) => {
            ListItem() {
              Row() {
                Text(`${idx + 1}. ${item}`).fontSize(15).layoutWeight(1)
                Text('✕').fontSize(16).fontColor('#F44336')
                  .onClick(() => this.removeItem(idx))
              }.padding(10).backgroundColor('#fff').borderRadius(8).margin({ bottom: 4 })
            }
          })
        }
        .layoutWeight(1).width('92%')
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 12 })
    }
    .width('100%').height('100%').backgroundColor('#f8f8f8')
  }
}

3.2 轮播动画的核心实现

随机选择的动画效果是应用中最具技术含量的部分:

pick() {
  if (this.spinning) return;  // 防止重复触发
  if (this.currentItems.length === 0) {
    this.picked = '列表是空的!';
    return;
  }
  this.spinning = true;
  let count = 0;
  this.intervalId = setInterval(() => {
    const idx = Math.floor(Math.random() * this.currentItems.length);
    this.picked = this.currentItems[idx];
    count++;
    if (count >= 20) {
      clearInterval(this.intervalId);
      this.spinning = false;
    }
  }, 80);
}

动画机制详解:

第一步:状态锁定

if (this.spinning) return;

使用 spinning 标志位防止用户在动画进行中重复点击按钮。这是一种防重入(Reentrancy Guard)机制,确保动画过程的完整性。

第二步:空列表检查

if (this.currentItems.length === 0) {
  this.picked = '列表是空的!';
  return;
}

当选项列表为空时,显示友好提示而非执行无效的随机选择。

第三步:启动定时器

this.intervalId = setInterval(() => { ... }, 80);

setInterval 每80ms触发一次回调,每次回调更新 picked 为随机选项。80ms的间隔约等于12.5fps的刷新率,在人眼感知中形成了流畅的"跳动"效果。

第四步:动画终止

count++;
if (count >= 20) {
  clearInterval(this.intervalId);
  this.spinning = false;
}

20次循环 × 80ms = 1600ms(1.6秒)的总动画时长。这个时长经过了精心的设计——太短则用户感受不到"随机"过程,太长则显得拖沓。1.6秒恰好是让用户感到"有在认真随机"的合理时长。

3.3 列表的动态增删操作

添加选项

addItem() {
  const t = this.newItem.trim();
  if (t === '') return;  // 忽略空输入
  this.currentItems = [...this.currentItems, t];  // 创建新数组
  this.newItem = '';  // 清空输入框
}

这里的关键是 [...this.currentItems, t]——展开运算符(Spread Operator)创建了一个全新的数组,而非修改原数组。在 ArkTS 中,@State 只能检测到引用类型变量本身的赋值操作,对于数组的内部修改(如 pushsplice)无法触发 UI 更新。因此必须通过创建新数组的方式来更新状态。

删除选项

removeItem(idx: number) {
  this.currentItems = this.currentItems.filter((_, i) => i !== idx);
}

Array.filter() 方法同样返回一个新数组,只保留索引不等于 idx 的所有元素。这种函数式编程风格既简洁又安全,避免了直接操作原数组可能带来的副作用。

3.4 List 组件的高效列表渲染

List() {
  ForEach(this.currentItems, (item: string, idx: number) => {
    ListItem() {
      Row() {
        Text(`${idx + 1}. ${item}`).fontSize(15).layoutWeight(1)
        Text('✕').fontSize(16).fontColor('#F44336')
          .onClick(() => this.removeItem(idx))
      }
      .padding(10).backgroundColor('#fff').borderRadius(8).margin({ bottom: 4 })
    }
  })
}
.layoutWeight(1).width('92%')

List 是 HarmonyOS 中用于渲染长列表的高性能组件,其核心优势包括:

  • 懒加载:只渲染当前可见区域的列表项,而非全部渲染
  • 滚动优化:内置滚动惯性效果和性能优化
  • 复用机制:离屏的列表项可以被回收复用

ForEach 循环遍历 currentItems 数组,为每个元素创建一个 ListItem。每个列表项包含:

  1. 序号 + 文本:使用 ${idx + 1}. ${item} 格式化显示
  2. 删除按钮:红色 ✕ 符号,点击时调用 removeItem(idx)

layoutWeight(1) 让文本占据删除按钮之外的所有剩余空间,确保长文本不会被截断。


四、HarmonyOS 特性深度解析

4.1 setInterval 定时器与资源管理

setInterval 是 HarmonyOS 中创建周期性定时器的标准 API,原型与 Web API 保持一致:

setInterval(callback: Function, delay: number): number

返回值为定时器的唯一标识符(整数),可用于后续的 clearInterval 调用。

在本应用中,定时器的使用遵循了以下最佳实践:

  1. 保存句柄:将返回值存储在 intervalId
  2. 计数终止:通过计数器 count 在指定次数后停止定时器
  3. 状态同步:停止定时器后同步更新 spinning 标志位

注意事项:虽然本应用中没有在页面销毁时清理定时器(因为动画完成后定时器自动停止),但在需要持续运行的场景中,必须在 aboutToDisappear 生命周期回调中调用 clearInterval,防止内存泄漏。

4.2 @State 与数组的不可变性

@State currentItems: string[] = [...ITEMS];

@State 装饰的数组变量,其赋值操作=)能够触发 UI 更新,但变异方法pushpopsplice 等)不会触发更新。这是 ArkTS 响应式系统的一个重要约束:

// ❌ 不会触发 UI 更新
this.currentItems.push(newItem);

// ✅ 会触发 UI 更新
this.currentItems = [...this.currentItems, newItem];

这个约束源于 JavaScript 引擎无法高效追踪数组内部变化的设计选择。因此,在 ArkTS 开发中,应始终使用不可变更新(Immutability Update)模式来操作数组和对象。

4.3 TextInput 的 onSubmit 事件

TextInput({ placeholder: '添加选项...', text: this.newItem })
  .onChange(v => this.newItem = v)
  .onSubmit(() => this.addItem())

onSubmit 事件在用户按下键盘的"确认/回车"键时触发。本应用巧妙地将 onSubmit 与添加逻辑绑定,让用户既可以通过点击"+"按钮添加,也可以通过键盘回车添加——两种操作方式覆盖了不同用户的操作习惯。

4.4 条件渲染的状态驱动

结果区域的文字颜色根据状态动态变化:

Text(this.picked || '点击按钮帮你做决定!')
  .fontColor(this.picked ? '#F44336' : '#888')

picked 为空字符串(未选择)时,显示灰色的提示文字;当 picked 有值(已选择或正在选择)时,显示红色的结果文字。这种状态驱动的样式切换让 UI 与数据状态保持同步。


五、UI/UX 设计模式

5.1 页面布局结构

┌─────────────────────────────────┐
│  Row: 导航栏                      │
├─────────────────────────────────┤
│  Column: 结果展示区 (80px)        │ ← 显示选中结果或提示
│  Button: "🎯 帮我选!"           │ ← 核心操作按钮
│  Row: 添加输入栏                  │ ← 扩展功能
│  Text: 选项计数                   │
│  List: 选项列表 (可滚动)          │ ← layoutWeight=1
└─────────────────────────────────┘

5.2 视觉焦点设计

页面中最醒目的设计元素是红色主色调

  • 按钮颜色:#F44336(Material Design Red)
  • 结果文字颜色:#F44336
  • 删除按钮颜色:#F44336

红色在心理学上代表"注意"和"行动",恰好契合"帮我做决定"的核心功能。搭配白色卡片和灰色背景,形成了清晰的视觉层次。

5.3 交互反馈设计

操作 视觉反馈 状态变化
点击"帮我选" 结果区域开始快速切换文字 spinning = true
动画进行中 按钮点击无效(防重入) spinning = true
动画结束 结果文字定格,红色高亮 spinning = false
添加选项 选项列表新增一项,计数更新 currentItems 更新
删除选项 选项列表移除一项,计数更新 currentItems 更新

六、最佳实践与经验总结

6.1 动画流畅度的参数调优

轮播动画的效果取决于两个关键参数:

参数 效果
间隔时间 80ms 约12.5fps刷新率,兼顾流畅度和性能
循环次数 20次 总时长1.6秒,适中

调优建议:对于更真实的"抽奖"效果,可以设计减速动画——开始时切换快,逐渐变慢:

// 减速动画示例
pick() {
  if (this.spinning) return;
  this.spinning = true;
  let count = 0;
  const totalSteps = 30;
  const animate = () => {
    const idx = Math.floor(Math.random() * this.currentItems.length);
    this.picked = this.currentItems[idx];
    count++;
    if (count < totalSteps) {
      // 逐渐增加间隔时间(从50ms逐渐增加到300ms)
      const delay = 50 + (count / totalSteps) * 250;
      setTimeout(animate, delay);
    } else {
      this.spinning = false;
    }
  };
  animate();
}

6.2 数组展开运算符的正确使用

// 初始化时复制数组
@State currentItems: string[] = [...ITEMS];

使用 [...ITEMS] 而非直接赋值 = ITEMS 的原因:

  1. 避免引用共享:直接赋值会使 currentItemsITEMS 指向同一个数组对象
  2. 保持预设纯净:用户对 currentItems 的增删操作不会影响预设数据 ITEMS
  3. 重置便利:如果需要"重置到默认",只需再次 = [...ITEMS]

6.3 输入框的即时清空

addItem() {
  const t = this.newItem.trim();
  if (t === '') return;
  this.currentItems = [...this.currentItems, t];
  this.newItem = '';  // 添加后立即清空输入框
}

添加选项后立即清空输入框,这是表单设计中的标准做法——用户可以立即开始输入下一个选项,无需手动删除已输入的内容。trim() 方法去除首尾空格,避免添加纯空格的无效选项。

6.4 索引删除的安全实践

removeItem(idx: number) {
  this.currentItems = this.currentItems.filter((_, i) => i !== idx);
}

使用 filter 而非 splice 的好处:

  1. 不可变性:返回新数组而非修改原数组
  2. 安全性:不会因为索引越界而出错
  3. 声明式:更清晰地表达"排除指定索引"的意图

七、总结

随机选择应用在约98行代码中,完美展示了 ArkTS 声明式 UI 开发的多个核心概念:

  • setInterval 定时器动画:80ms间隔的快速轮播效果
  • List + ForEach 列表渲染:高性能的可滚动选项列表
  • 数组不可变更新:展开运算符和 filter 的安全操作
  • 防重入机制:动画过程中的按钮锁定
  • onSubmit 键盘事件:输入框的回车提交功能

这个应用以"解决选择困难"这一趣味需求为切入点,展示了如何在 ArkTS 中实现流畅的定时器动画和动态列表管理。它不仅是日常生活的好帮手,更是学习 ArkTS 动画编程和列表操作的优秀教学案例。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐