文章目录
- 数据里预定义主题色
- 主题色的四个应用点
- 背景渐变实现
- 封面光晕阴影
- 完整代码
- 动画切换:让颜色变化不突兀
- 颜色透明度的取值经验
- 小结
做过播放器的人都知道,把播放、暂停、进度条堆上去是最容易的部分。真正让界面"好看"的,是当你切换到下一首歌时,整个 UI 的色调跟着一起变,而且过渡自然不突兀。
HarmonyOS PC 端的音乐播放器用了一个很常见的设计手法:从歌曲数据里拿主题色,把背景渐变、进度条、按钮、阴影全部换成这个颜色。这篇就把这套颜色贯穿逻辑从数据到 UI 完整走一遍。
数据里预定义主题色
不做实时颜色提取(那需要图像处理算法),在歌曲数据里直接预定义每首歌的主题色:
interfaceSongItem{id:numbertitle:stringthemeColor:string// 该歌曲的主题色coverGradient:string[]// 封面渐变色,[起始色, 结束色]}constsongs:SongItem[]=[{id:1,title:'星辰大海',themeColor:'#6366F1',coverGradient:['#818CF8','#4F46E5']},{id:2,title:'少年',themeColor:'#3B82F6',coverGradient:['#60A5FA','#2563EB']},{id:3,title:'起风了',themeColor:'#10B981',coverGradient:['#34D399','#059669']},{id:4,title:'平凡之路',themeColor:'#F59E0B',coverGradient:['#FCD34D','#D97706']},]主题色的四个应用点
切歌时,以下四处需要联动更新:
- 背景渐变:整个播放器背景,从主题色浅色渐变到白色
- 进度条颜色:
selectedColor换成主题色 - 播放按钮颜色:主按钮用主题色
- 阴影颜色:封面图片的阴影换成主题色,有"光晕"感
背景渐变实现
不用背景图,用linearGradient实现主题色到白色的渐变:
.linearGradient({angle:160,colors:[[`${this.currentSong.themeColor}20`,0],// 主题色 12% 透明度,起点['#FFFFFF',0.6],// 纯白,60% 位置['#F9FAFB',1]// 浅灰,终点]})themeColor+20(十六进制透明度,约 12%)让背景有主题色晕染感,但不会太浓。
封面光晕阴影
封面的阴影颜色加主题色半透明,形成向下扩散的"光晕":
.shadow({radius:40,color:`${this.currentSong.themeColor}50`,// 主题色 31% 透明度offsetY:16// 向下偏移})切歌时themeColor变了,阴影颜色也跟着变,不需要额外代码。
完整代码
interfaceSongItem{id:numbertitle:stringartist:stringalbum:stringduration:numberthemeColor:string}constDEFAULT_SONG:SongItem={id:1,title:'星辰大海',artist:'黄霄雲',album:'破晓',duration:243,themeColor:'#6366F1'}@Entry@Componentstruct PcMusicPlayerPage{@StatecurrentIndex:number=0@StatecurrentSong:SongItem=DEFAULT_SONG@StateisPlaying:boolean=false@Stateprogress:number=0.35@Statevolume:number=0.7@StateisFavorite:boolean=false@StateshowLyrics:boolean=true@StateplayMode:'sequence'|'random'|'loop'='sequence'songs:SongItem[]=[DEFAULT_SONG,{id:2,title:'少年',artist:'梦然',album:'少年',duration:258,themeColor:'#3B82F6'},{id:3,title:'起风了',artist:'买辣椒也用券',album:'起风了',duration:312,themeColor:'#10B981'},{id:4,title:'平凡之路',artist:'朴树',album:'猎户星座',duration:296,themeColor:'#F59E0B'},{id:5,title:'追光者',artist:'岑宁儿',album:'你好,旧时光',duration:267,themeColor:'#EF4444'},]syncCurrentSong(){this.currentSong=this.songs[this.currentIndex]||DEFAULT_SONG}formatTime(seconds:number):string{constm=Math.floor(seconds/60)consts=Math.floor(seconds%60)return`${m}:${s<10?'0':''}${s}`}prevSong(){this.currentIndex=(this.currentIndex-1+this.songs.length)%this.songs.lengththis.syncCurrentSong()this.progress=0}nextSong(){this.currentIndex=(this.currentIndex+1)%this.songs.lengththis.syncCurrentSong()this.progress=0}@BuildersongListItem(song:SongItem,index:number){Row({space:12}){Text(`${index+1}`).fontSize(12).fontColor('#9CA3AF').width(20).textAlign(TextAlign.Center)Column({space:2}){Text(song.title).fontSize(14).fontColor(index===this.currentIndex?song.themeColor:'#1F2937').fontWeight(index===this.currentIndex?FontWeight.Medium:FontWeight.Normal).maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis})Text(song.artist).fontSize(11).fontColor('#9CA3AF')}.layoutWeight(1).alignItems(HorizontalAlign.Start)Text(this.formatTime(song.duration)).fontSize(12).fontColor('#9CA3AF')}.width('100%').padding({left:16,right:16,top:12,bottom:12}).backgroundColor(index===this.currentIndex?`${song.themeColor}10`:Color.Transparent).onClick(()=>{this.currentIndex=indexthis.syncCurrentSong()this.progress=0})}build(){Column({space:0}){// 主体区域(左侧歌单 + 中间播放 + 右侧歌词)Row({space:0}){// 左侧歌单列表Column({space:0}){Row(){Text('播放列表').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#111827')Text(`${this.songs.length}首`).fontSize(12).fontColor('#9CA3AF')}.width('100%').justifyContent(FlexAlign.SpaceBetween).padding({left:20,right:20,top:20,bottom:16})Divider().strokeWidth(1).color('#F3F4F6')Scroll(){Column(){ForEach(this.songs,(song:SongItem,index:number)=>{this.songListItem(song,index)})}}.layoutWeight(1)}.width(240).height('100%').backgroundColor(Color.White).border({width:{right:1},color:'#F3F4F6'})// 中间播放主区Column({space:0}){Scroll(){Column({space:32}){// 封面Text('').aspectRatio(1).width(240).borderRadius(20).backgroundColor(this.currentSong.themeColor).shadow({radius:40,color:`${this.currentSong.themeColor}50`,offsetY:16})// 歌曲信息Column({space:6}){Row({space:12}){Text(this.currentSong.title).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#111827').layoutWeight(1)Text(this.isFavorite?'❤️':'🤍').fontSize(22).onClick(()=>{this.isFavorite=!this.isFavorite})}Text(this.currentSong.artist+' · '+this.currentSong.album).fontSize(14).fontColor('#6B7280')}.width('100%').alignItems(HorizontalAlign.Start)// 进度条Column({space:8}){Slider({value:this.progress*100,min:0,max:100}).width('100%').selectedColor(this.currentSong.themeColor).onChange((v)=>{this.progress=v/100})Row(){Text(this.formatTime(Math.floor(this.progress*this.currentSong.duration))).fontSize(12).fontColor('#9CA3AF')Text(this.formatTime(this.currentSong.duration)).fontSize(12).fontColor('#9CA3AF')}.width('100%').justifyContent(FlexAlign.SpaceBetween)}// 控制按钮Row(){Text(this.playMode==='random'?'🔀':this.playMode==='loop'?'🔂':'🔁').fontSize(20).fontColor(this.playMode!=='sequence'?this.currentSong.themeColor:'#6B7280').onClick(()=>{constmodes:Array<'sequence'|'random'|'loop'>=['sequence','random','loop']constidx=modes.indexOf(this.playMode)this.playMode=modes[(idx+1)%3]})Text('⏮').fontSize(28).fontColor('#374151').onClick(()=>{this.prevSong()})Text(this.isPlaying?'⏸':'▶').fontSize(44).fontColor(this.currentSong.themeColor).onClick(()=>{this.isPlaying=!this.isPlaying})Text('⏭').fontSize(28).fontColor('#374151').onClick(()=>{this.nextSong()})Text('🔊').fontSize(20).fontColor('#6B7280')}.width('100%').justifyContent(FlexAlign.SpaceBetween).padding({left:16,right:16})// 音量Row({space:12}){Text('🔈').fontSize(16).fontColor('#9CA3AF')Slider({value:this.volume*100,min:0,max:100}).layoutWeight(1).selectedColor(this.currentSong.themeColor).onChange((v)=>{this.volume=v/100})Text('🔊').fontSize(16).fontColor('#9CA3AF')}.width('100%')}.padding({left:48,right:48,top:40,bottom:40}).alignItems(HorizontalAlign.Center)}.layoutWeight(1)}.layoutWeight(1).height('100%').backgroundColor('#FAFAFA')// 右侧歌词面板if(this.showLyrics){Column({space:0}){Text('歌词').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#111827').padding({left:20,top:20,bottom:16}).width('100%')Divider().strokeWidth(1).color('#F3F4F6')Scroll(){Column({space:8}){ForEach(['前方的路虽然遥远','我也要一步一步走下去','星辰大海是我的方向','即使迷雾遮住了视线','心中有光指引我前行','每一步都算数','不停歇不放弃','终将到达彼岸',''],(line:string)=>{Text(line).fontSize(13).fontColor('#6B7280').lineHeight(28).textAlign(TextAlign.Center).width('100%')})}.padding({left:16,right:16,top:24,bottom:24})}.layoutWeight(1)}.width(280).height('100%').backgroundColor(Color.White).border({width:{left:1},color:'#F3F4F6'})}}.layoutWeight(1).width('100%')}.width('100%').height('100%').constraintSize({minWidth:800})}}动画切换:让颜色变化不突兀
直接改颜色会瞬间跳变,加.animation()让渐变背景有过渡:
.linearGradient({...}).animation({duration:600,curve:Curve.EaseInOut})duration: 600大约是 0.6 秒,刚好能感知到颜色在"流动",不会因为太快而感觉突兀,也不会因为太慢而让用户等待。
颜色透明度的取值经验
- 背景渐变起点:主题色 + 全不透明(
FF),然后往白色过渡 - 封面阴影:主题色 +
60(约 38% 透明度),阴影感够但不过浓 - 进度条轨道(未播放部分):白色 +
40(约 25% 透明度) - 按钮次要色:白色 +
cc(约 80% 透明度)
这套取值没有绝对的标准,但保持系统性(同类元素用同档透明度)比随意取值更容易整齐。
小结
主题色贯穿的核心是:颜色值集中存储在数据里,UI 层只引用,不重复定义。${this.song.themeColor}这个表达式出现在背景、阴影、进度条多处,但颜色来源只有一个。这样切歌时只改一个变量,整个界面的颜色系统自动跟着变,不会漏掉任何一处。