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

这个案例最像真实产品的地方在哪里
它最像真实产品的地方,不是有播放按钮,而是它已经开始处理媒体类页面几个典型的真实需求:
- 一批录音需要按标题、分类、时间、大小来管理。
- 同一时间通常只允许一个条目处于播放中状态。
- 用户既可能从分类找内容,也可能直接搜索标题。
- 录音按钮是主入口,必须稳定地停在页面底部。

所以这页不是普通列表页,而是“媒体内容管理页”的缩小版。真正有价值的,是它怎么把这些需求用状态组织起来。
先站在用户角度,走一遍主路径
建议你按下面顺序先跑页面:
- 打开页面,先看顶部摘要,确认总条数和总时长是不是一眼可读。
- 在搜索框里输入关键词,看结果是否立即收窄。
- 切换顶部分类标签,观察筛选范围是否进一步缩小。
- 点某条录音的播放按钮,再点另一条,确认页面是否始终只保留一个“播放中”。
- 注意播放中条目下方的进度条是否只在当前条目出现。
- 最后再看底部录音按钮,它是否始终作为主操作留在页面底部。
这一路走下来,你会很容易看出这页的设计重点:用户不是来欣赏卡片的,而是来快速定位、播放、删除、录制语音内容的。
状态设计里最重要的,是把“全局条件”和“单条状态”分开
先看数据入口:
@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'
}

这一组状态拆得很合理,可以分成三层去理解:
| 状态层次 | 字段 | 作用 |
|---|---|---|
| 列表主数据 | voiceNotes |
承载每条录音的标题、时长、分类、播放状态等信息 |
| 全局过滤条件 | activeCategory、searchText |
决定用户此刻在列表里能看到什么 |
| 主操作状态 | isRecording、recordSeconds |
服务底部录音入口,不直接参与列表过滤 |
这是一种很典型的媒体页面状态组织方式。因为“我在看哪类内容”和“我正在播放哪条内容”,本来就是两类不同维度的问题,混在一起会很乱。
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:5、03:05、185秒 这种展示口径混乱的问题。看起来只是工具函数,实际上它是在帮整页建立统一的时间表达标准。
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')
}
}
最后总结
这篇代码最值得学的,不是它把播放按钮放在哪,而是它把“当前播放的是谁”“当前用户正在看什么范围”“当前主任务是什么”三件事拆得很清楚。对语音备忘录这类页面来说,这种状态组织能力比单独的样式技巧更接近真实开发。
更多推荐

所有评论(0)