HarmonyOS7 ArkUI 歌词滚动播放器 - 歌词同步高亮、进度控制与翻译切换实战:从状态到页面反馈
📅 2026/7/12 19:10:35
👁️ 阅读次数
📝 编程学习
文章目录
- 前言
- 真实页面会关心什么
- 列表、状态和反馈
- 关键片段
- 片段 1:`get currentLineIndex(): number {`
- 片段 2:`get progress(): number {`
- 使用方式
- 完整代码
- 可扩展点
前言
如果刚开始写 HarmonyOS7 页面,我建议多拆这种案例。代码量不夸张,但能把声明式 UI 的味道摸清楚。
它的主线是歌词滚动播放器,细节落在歌词同步高亮、进度控制与翻译切换。这些细节刚好能把页面状态、用户操作和组件刷新串起来。
真实页面会关心什么
LyricPlayerPage不是一个只展示静态内容的页面。它会根据用户点击、输入、切换或计时来改变界面,所以阅读代码时可以先抓三条线:
| 观察点 | 在这个案例里的表现 |
|---|---|
| 页面主题 | 歌词滚动播放器 |
| 主要文案 | 晴天,周杰伦,叶惠美,故事的小黄花,从出生那年就飘着,童年的荡秋千 |
| 常用组件 | Column,ForEach,Row,Scroll,Slider,Stack,Text |
| 适合练习 | 状态驱动 UI、条件样式、列表渲染、事件回调 |
列表、状态和反馈
ArkUI 的页面刷新依赖状态变化。下面这些@State字段就是页面的“开关”和“数据源”,不用手动找 DOM,也不用自己通知某个控件刷新。
| 状态字段 | 类型 | 我会怎么理解它 |
|---|---|---|
currentTime | number | 当前选中项,决定高亮和内容切换 |
isPlaying | boolean | 布尔开关,控制显示隐藏或模式切换 |
|showTranslation|boolean| 布尔开关,控制显示隐藏或模式切换 |
|fontSize|number| 记录页面当前选择、输入或展示状态 |
|timerHandle|number| 记录页面当前选择、输入或展示状态 |
一个经验:先把
@State看完,再看build(),页面逻辑会清楚很多。否则很容易被一长串布局代码带偏。
关键片段
片段 1:get currentLineIndex(): number {
这段代码承担的是页面里的“判断”或“动作”,把它从布局里抽出来后,build()会清爽很多。后面要加新条件,也不会到处翻 UI 代码。
getcurrentLineIndex():number{letidx=0for(leti=0;i<this.lyrics.length;i++){if(this.currentTime>=this.lyrics[i].time){idx=i}}returnidx}片段 2:get progress(): number {
这段代码承担的是页面里的“判断”或“动作”,把它从布局里抽出来后,build()会清爽很多。后面要加新条件,也不会到处翻 UI 代码。
getprogress():number{returnthis.currentTime/this.song.duration}使用方式
把下面代码放进 ArkTS 页面文件中即可运行。用于正式项目时,我会继续把数据模型、卡片 Builder 和页面事件拆出去,页面文件只负责组合。
建议练习顺序:先改状态字段 -> 再改交互事件 -> 最后调整 UI 样式 检查重点:点击是否刷新、列表 key 是否稳定、条件样式是否集中管理这份代码可以继续扩展的方向- 把模拟数据替换成接口数据
- 抽出复用卡片组件
- 增加空状态和异常状态
- 补充深色模式或主题色适配
- 对输入类场景增加校验提示
完整代码
// LyricPlayerPage - 歌词滚动播放器 - 歌词同步高亮、进度控制与翻译切换interfaceLyricLine{time:numbertext:stringtranslation:string}interfaceSongInfo_lnjm{title:stringartist:stringalbum:stringduration:number}@Entry@Componentstruct LyricPlayerPage{@StatecurrentTime:number=45@StateisPlaying:boolean=false@StateshowTranslation:boolean=true@StatefontSize:number=16@StatetimerHandle:number=-1privatesong:SongInfo_lnjm={title:'晴天',artist:'周杰伦',album:'叶惠美',duration:269,}privatelyrics:LyricLine[]=[{time:0,text:'故事的小黄花',translation:'The little dandelion of the story'},{time:5,text:'从出生那年就飘着',translation:'Has been floating since the year of birth'},{time:10,text:'童年的荡秋千',translation:'Swinging on the childhood swing'},{time:15,text:'随记忆一直晃到现在',translation:'Swaying along with memories until now'},{time:20,text:'Rui Bing说他不明白',translation:'Rui Bing says he doesn\'t understand'},{time:25,text:'为什么我吉他总在半夜弹',translation:'Why I always play guitar at midnight'},{time:30,text:'她说她喜欢男生弹吉他的样子',translation:'She said she likes how boys look playing guitar'},{time:35,text:'我说这简单',translation:'I said it\'s simple'},{time:40,text:'等你学会',translation:'Wait till you learn'},{time:45,text:'我再好好说清楚',translation:'I\'ll explain it properly'},{time:50,text:'那等等',translation:'Then wait a moment'},{time:55,text:'我先把歌唱完',translation:'Let me finish the song first'},{time:60,text:'你今天哭了吗',translation:'Did you cry today'},{time:65,text:'不管有没有眼泪',translation:'Whether or not there are tears'},{time:70,text:'在你脸上来回',translation:'Running down your face'},{time:75,text:'你笑了吗',translation:'Did you smile'},{time:80,text:'不管有没有阳光',translation:'Whether or not there is sunlight'},{time:85,text:'那个秋天的童年',translation:'That childhood autumn'},{time:90,text:'印象太深刻了',translation:'The impression is too deep'},{time:95,text:'不知不觉开始忘了',translation:'Gradually started to forget'},{time:100,text:'晴天',translation:'Sunny day'},{time:108,text:'盼望着晴天',translation:'Hoping for a sunny day'},]getcurrentLineIndex():number{letidx=0for(leti=0;i<this.lyrics.length;i++){if(this.currentTime>=this.lyrics[i].time){idx=i}}returnidx}getprogress():number{returnthis.currentTime/this.song.duration}formatTime(seconds:number):string{constm=Math.floor(seconds/60)consts=Math.floor(seconds%60)return`${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`}togglePlay(){this.isPlaying=!this.isPlayingif(this.isPlaying){this.timerHandle=setInterval(()=>{if(this.currentTime<this.song.duration){this.currentTime++}else{this.currentTime=0clearInterval(this.timerHandle)this.isPlaying=false}},1000)}else{clearInterval(this.timerHandle)}}aboutToDisappear(){if(this.timerHandle>=0)clearInterval(this.timerHandle)}build(){Column({space:0}){// 顶部区域Column({space:16}){// 专辑封面Stack(){Column().width(160).height(160).borderRadius(80).linearGradient({angle:135,colors:[['#667eea',0],['#764ba2',0.5],['#f64f59',1.0]]}).shadow({radius:20,color:'#667eea50',offsetX:0,offsetY:8}).rotate({x:0,y:0,z:1,angle:this.isPlaying?360:0}).animation({duration:8000,curve:Curve.Linear,iterations:-1,playMode:PlayMode.Normal})Circle().width(40).height(40).fill('#ffffff')Circle().width(16).height(16).fill('#333333')}Column({space:4}){Text(this.song.title).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1a1a1a')Text(`${this.song.artist}·${this.song.album}`).fontSize(14).fontColor('#888888')}.alignItems(HorizontalAlign.Center)}.width('100%').padding({top:24,bottom:20}).backgroundColor('#ffffff').alignItems(HorizontalAlign.Center)// 歌词显示区域Column({space:0}){// 工具栏Row(){Text('A-').fontSize(14).fontColor('#666666').padding({left:12,right:12,top:6,bottom:6}).backgroundColor('#f0f0f0').borderRadius(16).onClick(()=>{if(this.fontSize>12)this.fontSize-=2})Text(`${this.fontSize}px`).fontSize(13).fontColor('#999999').margin({left:8,right:8})Text('A+').fontSize(14).fontColor('#666666').padding({left:12,right:12,top:6,bottom:6}).backgroundColor('#f0f0f0').borderRadius(16).onClick(()=>{if(this.fontSize<22)this.fontSize+=2})Blank()Row({space:6}){Text('译').fontSize(13).fontColor(this.showTranslation?'#ffffff':'#666666').backgroundColor(this.showTranslation?'#6c5ce7':'#f0f0f0').padding({left:12,right:12,top:6,bottom:6}).borderRadius(16).onClick(()=>{this.showTranslation=!this.showTranslation})}}.padding({left:16,right:16,top:10,bottom:10}).backgroundColor('#ffffff')// 歌词列表Scroll(){Column({space:0}){ForEach(this.lyrics,(line:LyricLine,idx:number)=>{Column({space:4}){Text(line.text).fontSize(idx===this.currentLineIndex?this.fontSize+2:this.fontSize).fontWeight(idx===this.currentLineIndex?FontWeight.Bold:FontWeight.Normal).fontColor(idx===this.currentLineIndex?'#6c5ce7':idx<this.currentLineIndex?'#bbbbbb':'#444444').textAlign(TextAlign.Center).width('100%').animation({duration:300,curve:Curve.EaseOut})if(this.showTranslation){Text(line.translation).fontSize(this.fontSize-3).fontColor(idx===this.currentLineIndex?'#a29bfe':'#cccccc').textAlign(TextAlign.Center).width('100%')}}.padding({top:10,bottom:10,left:24,right:24}).backgroundColor(idx===this.currentLineIndex?'#f5f2ff':'transparent').borderRadius(8).margin({left:8,right:8})})Column().height(40)}.padding({top:16})}.layoutWeight(1).backgroundColor('#fafafa')}.layoutWeight(1)// 播放控制区Column({space:16}){// 进度条Column({space:6}){Slider({value:this.currentTime,min:0,max:this.song.duration,step:1,style:SliderStyle.OutSet,}).width('100%').trackColor('#e0e0e0').selectedColor('#6c5ce7').onChange((v:number)=>{this.currentTime=Math.round(v)})Row(){Text(this.formatTime(this.currentTime)).fontSize(12).fontColor('#999999')Blank()Text(this.formatTime(this.song.duration)).fontSize(12).fontColor('#999999')}.width('100%')}// 播放按钮Row({space:32}){Text('⏮').fontSize(28).fontColor('#444444')Stack(){Circle().width(60).height(60).fill('#6c5ce7')Text(this.isPlaying?'⏸':'▶').fontSize(22).fontColor('#ffffff')}.onClick(()=>this.togglePlay())Text('⏭').fontSize(28).fontColor('#444444')}.width('100%').justifyContent(FlexAlign.Center).alignItems(VerticalAlign.Center)}.padding({left:24,right:24,top:16,bottom:32}).backgroundColor('#ffffff')}.width('100%').height('100%').backgroundColor('#ffffff')}}可扩展点
这个案例可以当成一个小模板:先把页面会变的东西放进状态,再用函数或 Builder 处理重复逻辑,最后让布局只关心展示。写 HarmonyOS7 页面时,这个顺序通常比一上来堆 UI 更稳。
编程学习
技术分享
实战经验