前言

语音备忘录类页面经常给人一种“功能不多,随便做做就行”的错觉。实际上,这类页面一旦放到真实应用里,马上会碰到几个很具体的问题:录音列表怎么快速浏览,播放状态怎么避免互相打架,分类和搜索怎么同时生效,底部录音入口怎么既醒目又不抢内容。

A hand-drawn doodle illustration on pure white pap

这个案例最像真实产品的地方在哪里

它最像真实产品的地方,不是有播放按钮,而是它已经开始处理媒体类页面几个典型的真实需求:

  • 一批录音需要按标题、分类、时间、大小来管理。
  • 同一时间通常只允许一个条目处于播放中状态。
  • 用户既可能从分类找内容,也可能直接搜索标题。
  • 录音按钮是主入口,必须稳定地停在页面底部。

A hand-drawn doodle illustration on pure white pap

所以这页不是普通列表页,而是“媒体内容管理页”的缩小版。真正有价值的,是它怎么把这些需求用状态组织起来。

先站在用户角度,走一遍主路径

建议你按下面顺序先跑页面:

  1. 打开页面,先看顶部摘要,确认总条数和总时长是不是一眼可读。
  2. 在搜索框里输入关键词,看结果是否立即收窄。
  3. 切换顶部分类标签,观察筛选范围是否进一步缩小。
  4. 点某条录音的播放按钮,再点另一条,确认页面是否始终只保留一个“播放中”。
  5. 注意播放中条目下方的进度条是否只在当前条目出现。
  6. 最后再看底部录音按钮,它是否始终作为主操作留在页面底部。

这一路走下来,你会很容易看出这页的设计重点:用户不是来欣赏卡片的,而是来快速定位、播放、删除、录制语音内容的。

状态设计里最重要的,是把“全局条件”和“单条状态”分开

先看数据入口:

@State voiceNotes: VoiceNote[] = [
  { id: 1, title: '会议记录 - 产品规划', duration: 182, createTime: '2026-06-06 09:30', category: '工作', isPlaying: false, playProgress: 0, size: '2.8 MB' },
  { id: 2, title: '想法:新功能设计', duration: 47, createTime: '2026-06-05 14:22', category: '灵感', isPlaying: false, playProgress: 0, size: '0.7 MB' },
  { id: 3, title: '采购清单', duration: 23, createTime: '2026-06-05 10:00', category: '生活', isPlaying: false, playProgress: 0, size: '0.4 MB' },
  { id: 4, title: '英语口语练习', duration: 310, createTime: '2026-06-04 20:15', category: '学习', isPlaying: false, playProgress: 0, size: '4.8 MB' },
  { id: 5, title: '读书笔记:原则', duration: 128, createTime: '2026-06-04 16:40', category: '学习', isPlaying: false, playProgress: 0, size: '2.0 MB' },
  { id: 6, title: '家庭通话内容', duration: 65, createTime: '2026-06-03 21:00', category: '生活', isPlaying: false, playProgress: 0, size: '1.0 MB' },
  { id: 7, title: '代码思路记录', duration: 93, createTime: '2026-06-03 11:30', category: '工作', isPlaying: false, playProgress: 0, size: '1.4 MB' },
  { id: 8, title: '旅行计划讨论', duration: 240, createTime: '2026-06-02 19:00', category: '灵感', isPlaying: false, playProgress: 0, size: '3.7 MB' },
]
@State activeCategory: string = '全部'
@State isRecording: boolean = false
@State recordSeconds: number = 0
@State searchText: string = ''

private categories: string[] = ['全部', '工作', '学习', '生活', '灵感']
private categoryColors: Record<string, string> = {
  '工作': '#007AFF', '学习': '#34C759', '生活': '#FF9500', '灵感': '#5856D6'
}

A hand-drawn doodle illustration on pure white pap

这一组状态拆得很合理,可以分成三层去理解:

状态层次 字段 作用
列表主数据 voiceNotes 承载每条录音的标题、时长、分类、播放状态等信息
全局过滤条件 activeCategorysearchText 决定用户此刻在列表里能看到什么
主操作状态 isRecordingrecordSeconds 服务底部录音入口,不直接参与列表过滤

这是一种很典型的媒体页面状态组织方式。因为“我在看哪类内容”和“我正在播放哪条内容”,本来就是两类不同维度的问题,混在一起会很乱。

formatDuration() 是小函数,但它直接决定页面专业度

formatDuration(seconds: number): string {
  const m = Math.floor(seconds / 60)
  const s = seconds % 60
  return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}

媒体类页面很吃这种细节。因为时长信息会反复出现在多个地方:

  • 顶部总时长摘要。
  • 列表中的单条录音时长。
  • 播放中进度条左侧的当前时间。
  • 进度条右侧的总时长。

如果没有一个统一格式化函数,你后面很容易出现 3:503:05185秒 这种展示口径混乱的问题。看起来只是工具函数,实际上它是在帮整页建立统一的时间表达标准。

togglePlay() 才是这篇代码最值得深看的一段

媒体列表里最常见的交互问题就是:用户点第二条播放时,第一条怎么办?

这个案例的答案很直接:同一时间只保留一条播放中状态。

togglePlay(id: number) {
  this.voiceNotes = this.voiceNotes.map((n: VoiceNote) => {
    if (n.id === id) {
      n.isPlaying = !n.isPlaying
      n.playProgress = n.isPlaying ? n.playProgress : 30
      return n
    }
    n.isPlaying = false
    return n
  })
}

这段逻辑的重点不是“切换布尔值”,而是它同时处理了两件事:

  • 当前点击项切换播放状态。
  • 其他条目统一被置为非播放状态。

这就形成了一个非常清晰的播放互斥模型。你可以把它理解成一个轻量级单选机制,只不过选中的不是标签,而是“当前活跃播放器”。

从产品体验上说,这种处理很重要。否则列表里同时出现多个“暂停中”“进度条展开中”的条目,用户会很难判断自己到底在听哪一条。

这里的搜索和分类,不是二选一,而是叠加过滤

很多教程做到这里就会偷懒:要么能搜,要么能按分类筛。这个案例好一点,它把两种条件接在了一起。

getFilteredNotes(): VoiceNote[] {
  let result = this.voiceNotes
  if (this.activeCategory !== '全部') {
    result = result.filter((n: VoiceNote) => n.category === this.activeCategory)
  }
  if (this.searchText) {
    result = result.filter((n: VoiceNote) => n.title.includes(this.searchText))
  }
  return result
}

这段代码的价值,在于它让用户能进行更接近真实使用场景的筛选:

  • 先切到“工作”分类。
  • 再搜“会议”。
  • 最后看到的是“工作分类里标题包含会议的录音”。

这比单独的搜索或单独的分类都有用得多,因为真实列表数据一旦多起来,用户通常就是这样缩小范围的。

顶部摘要区和分类标签,做的是“快速定位”而不是装饰

看顶部:

  • this.voiceNotes.length 给出总条数。
  • this.getTotalDuration() 给出全部录音总时长。

这两项信息放在标题下方,其实是在告诉用户当前素材库的大致规模。对媒体管理类页面来说,这种摘要信息会让列表显得更“有内容感”。

再看分类标签:

  • categories 提供固定分类集合。
  • categoryColors 统一了每个分类的颜色语义。

这样用户在列表项里看到“工作”“学习”“生活”“灵感”标签时,会天然形成颜色记忆。分类不是单纯的文案,而是在帮助用户做视觉扫描。

列表项为什么读起来比较顺

一条录音卡片里,信息层级安排得比较清楚:

  • 最左边是播放按钮,告诉用户这是一条可操作媒体内容。
  • 中间主区域先放标题,再放时长、时间、大小这些次级信息。
  • 右上角用分类标签标明内容类型。
  • 播放时额外展开进度条,只在当前活跃条目显示。
  • 底部操作行再给分享和删除。

这个层级顺序很符合阅读习惯。用户先确认“这是什么内容”,再看“它有多长、何时创建、属于哪类”,最后才决定要不要继续操作。

底部录音入口,其实是在保证主操作始终可达

页面最下面单独留了一个录音区:

Button(this.isRecording ? '⏹ 停止录音' : '🎙 开始录音')
  .width(180).height(50)
  .fontSize(16)
  .fontColor('#FFFFFF')
  .backgroundColor(this.isRecording ? '#FF3B30' : '#007AFF')
  .borderRadius(25)
  .shadow({ radius: 8, color: '#007AFF30', offsetX: 0, offsetY: 4 })
  .onClick(() => { this.isRecording = !this.isRecording })

这块设计很典型,属于工具类页面里的“固定主动作”。

为什么不把录音按钮塞到导航栏?因为录音是高频主任务,应该更大、更稳定、更容易点到。放在底部单独成区,用户一眼就能找到,而且不容易和列表里的次级操作混淆。

这个案例有哪些地方已经有了工程意识

我觉得至少有下面几处:

  • 播放互斥:避免多个条目状态冲突。
  • 条件叠加过滤:分类与搜索不是互相覆盖。
  • 摘要信息前置:用户先看规模,再看明细。
  • 主操作固定:录音入口不会在长列表里丢失。

这些都不是复杂技巧,但它们正是把一个示例页拉近真实产品的关键细节。

如果继续往正式项目走,我会先补什么

这份代码作为教程足够清楚,但如果继续工程化,我会优先处理这些问题:

  • 把播放进度更新改成真实音频状态驱动,而不是示例字段。
  • togglePlay() 增加音频资源释放或暂停逻辑,不只改 UI 状态。
  • 让搜索支持忽略大小写、拼音或模糊匹配。
  • 把录音中的计时逻辑接到定时器,而不是只切换 isRecording
  • 增加空状态页和删除确认,提升列表管理体验。

读完这个案例,最该掌握的是什么

不是某个按钮圆不圆,而是下面这个思路:

媒体类列表页的难点,往往不在渲染本身,而在于如何管理“当前活跃项”和“当前可见范围”。

togglePlay() 管的是活跃项,getFilteredNotes() 管的是可见范围,底部录音按钮管的是主任务入口。把这三件事想清楚,语音备忘录这类页面就会一下子稳很多。

完整代码

下面保留案例完整代码,方便你直接对照学习、复制和重构。

// 语音备忘录页面: 语音备忘录 - 录音列表、播放状态、分类标签

interface VoiceNote {
  id: number
  title: string
  duration: number  // 秒
  createTime: string
  category: string
  isPlaying: boolean
  playProgress: number  // 0~100
  size: string
}

@Entry
@Component
struct VoiceMemoNotebook {
  @State voiceNotes: VoiceNote[] = [
    { id: 1, title: '会议记录 - 产品规划', duration: 182, createTime: '2026-06-06 09:30', category: '工作', isPlaying: false, playProgress: 0, size: '2.8 MB' },
    { id: 2, title: '想法:新功能设计', duration: 47, createTime: '2026-06-05 14:22', category: '灵感', isPlaying: false, playProgress: 0, size: '0.7 MB' },
    { id: 3, title: '采购清单', duration: 23, createTime: '2026-06-05 10:00', category: '生活', isPlaying: false, playProgress: 0, size: '0.4 MB' },
    { id: 4, title: '英语口语练习', duration: 310, createTime: '2026-06-04 20:15', category: '学习', isPlaying: false, playProgress: 0, size: '4.8 MB' },
    { id: 5, title: '读书笔记:原则', duration: 128, createTime: '2026-06-04 16:40', category: '学习', isPlaying: false, playProgress: 0, size: '2.0 MB' },
    { id: 6, title: '家庭通话内容', duration: 65, createTime: '2026-06-03 21:00', category: '生活', isPlaying: false, playProgress: 0, size: '1.0 MB' },
    { id: 7, title: '代码思路记录', duration: 93, createTime: '2026-06-03 11:30', category: '工作', isPlaying: false, playProgress: 0, size: '1.4 MB' },
    { id: 8, title: '旅行计划讨论', duration: 240, createTime: '2026-06-02 19:00', category: '灵感', isPlaying: false, playProgress: 0, size: '3.7 MB' },
  ]
  @State activeCategory: string = '全部'
  @State isRecording: boolean = false
  @State recordSeconds: number = 0
  @State searchText: string = ''

  private categories: string[] = ['全部', '工作', '学习', '生活', '灵感']
  private categoryColors: Record<string, string> = {
    '工作': '#007AFF', '学习': '#34C759', '生活': '#FF9500', '灵感': '#5856D6'
  }

  formatDuration(seconds: number): string {
    const m = Math.floor(seconds / 60)
    const s = seconds % 60
    return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
  }

  togglePlay(id: number) {
    this.voiceNotes = this.voiceNotes.map((n: VoiceNote) => {
      if (n.id === id) {
        n.isPlaying = !n.isPlaying
        n.playProgress = n.isPlaying ? n.playProgress : 30
        return n
      }
      n.isPlaying = false
    return n
    })
  }

  getFilteredNotes(): VoiceNote[] {
    let result = this.voiceNotes
    if (this.activeCategory !== '全部') {
      result = result.filter((n: VoiceNote) => n.category === this.activeCategory)
    }
    if (this.searchText) {
      result = result.filter((n: VoiceNote) => n.title.includes(this.searchText))
    }
    return result
  }

  deleteNote(id: number) {
    this.voiceNotes = this.voiceNotes.filter((n: VoiceNote) => n.id !== id)
  }

  getTotalDuration(): number {
    return this.voiceNotes.reduce((s: number, n: VoiceNote) => s + n.duration, 0)
  }

  build() {
    Column() {
      // 顶部
      Row() {
        Column() {
          Text('语音备忘录').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
          Text(`${this.voiceNotes.length} 条 · 总时长 ${this.formatDuration(this.getTotalDuration())}`)
            .fontSize(12).fontColor('#888888').margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        Blank()
        Text('🔍').fontSize(20)
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 20, bottom: 12 })

      // 搜索框
      Row() {
        Text('🔍').fontSize(14)
        TextInput({ placeholder: '搜索录音...', text: this.searchText })
          .layoutWeight(1).height(36).backgroundColor('transparent').fontSize(13)
          .margin({ left: 8 })
          .onChange((v: string) => { this.searchText = v })
      }
      .width('100%').height(44)
      .backgroundColor('#F5F5F5').borderRadius(22)
      .padding({ left: 14, right: 14 })
      .margin({ left: 20, right: 20, bottom: 12 })

      // 分类筛选
      Scroll() {
        Row({ space: 8 }) {
          ForEach(this.categories, (cat: string) => {
            Text(cat)
              .fontSize(13)
              .fontColor(this.activeCategory === cat ? '#FFFFFF' : '#666666')
              .backgroundColor(this.activeCategory === cat
                ? (this.categoryColors[cat] ?? '#333333')
                : '#F5F5F5')
              .borderRadius(14)
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .onClick(() => { this.activeCategory = cat })
          })
        }
        .padding({ left: 20, right: 20 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .margin({ bottom: 12 })

      // 录音列表
      List({ space: 8 }) {
        ForEach(this.getFilteredNotes(), (note: VoiceNote) => {
          ListItem() {
            Column() {
              Row() {
                // 播放按钮
                Button(note.isPlaying ? '⏸' : '▶')
                  .width(44).height(44)
                  .fontSize(18)
                  .backgroundColor(note.isPlaying ? '#FF3B30' : '#007AFF')
                  .fontColor('#FFFFFF')
                  .borderRadius(22)
                  .onClick(() => this.togglePlay(note.id))

                Column() {
                  Row() {
                    Text(note.title)
                      .fontSize(14).fontWeight(FontWeight.Medium).fontColor('#1A1A1A')
                      .layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
                    Text(note.category)
                      .fontSize(10)
                      .fontColor(this.categoryColors[note.category] ?? '#888888')
                      .backgroundColor(`${this.categoryColors[note.category] ?? '#888888'}20`)
                      .borderRadius(8)
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .margin({ left: 8 })
                  }
                  .width('100%')

                  Row({ space: 12 }) {
                    Text(this.formatDuration(note.duration)).fontSize(12).fontColor('#888888')
                    Text(note.createTime).fontSize(11).fontColor('#BBBBBB')
                    Text(note.size).fontSize(11).fontColor('#BBBBBB')
                  }
                  .margin({ top: 4 })
                }
                .layoutWeight(1)
                .margin({ left: 12 })
                .alignItems(HorizontalAlign.Start)
              }
              .width('100%')

              // 进度条(仅播放中显示)
              if (note.isPlaying) {
                Column({ space: 4 }) {
                  Slider({ value: note.playProgress, min: 0, max: 100 })
                    .width('100%')
                    .selectedColor('#007AFF')
                    .trackColor('#E0E0E0')
                    .blockColor('#007AFF')
                    .margin({ top: 8 })
                  Row() {
                    Text(this.formatDuration(Math.round(note.duration * note.playProgress / 100)))
                      .fontSize(10).fontColor('#AAAAAA')
                    Blank()
                    Text(this.formatDuration(note.duration)).fontSize(10).fontColor('#AAAAAA')
                  }
                }
              }

              // 操作行
              Row() {
                Text(note.createTime).fontSize(11).fontColor('#CCCCCC')
                Blank()
                Text('分享').fontSize(12).fontColor('#007AFF').margin({ right: 16 })
                Text('删除').fontSize(12).fontColor('#FF3B30')
                  .onClick(() => this.deleteNote(note.id))
              }
              .width('100%')
              .margin({ top: 10 })
            }
            .width('100%')
            .padding(14)
            .backgroundColor('#FFFFFF')
            .borderRadius(14)
          }
        })
      }
      .layoutWeight(1)
      .padding({ left: 20, right: 20 })

      // 录音按钮
      Column() {
        if (this.isRecording) {
          Text(`正在录音 ${this.formatDuration(this.recordSeconds)}`)
            .fontSize(13).fontColor('#FF3B30').margin({ bottom: 10 })
        }
        Button(this.isRecording ? '⏹ 停止录音' : '🎙 开始录音')
          .width(180).height(50)
          .fontSize(16)
          .fontColor('#FFFFFF')
          .backgroundColor(this.isRecording ? '#FF3B30' : '#007AFF')
          .borderRadius(25)
          .shadow({ radius: 8, color: '#007AFF30', offsetX: 0, offsetY: 4 })
          .onClick(() => { this.isRecording = !this.isRecording })
      }
      .width('100%')
      .padding({ top: 12, bottom: 24 })
      .alignItems(HorizontalAlign.Center)
      .backgroundColor('#FFFFFF')
      .border({ width: { top: 0.5 }, color: { top: '#F0F0F0' } })
    }
    .width('100%').height('100%')
    .backgroundColor('#F8F8F8')
  }
}

最后总结

这篇代码最值得学的,不是它把播放按钮放在哪,而是它把“当前播放的是谁”“当前用户正在看什么范围”“当前主任务是什么”三件事拆得很清楚。对语音备忘录这类页面来说,这种状态组织能力比单独的样式技巧更接近真实开发。

Logo

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

更多推荐