简介:一份Android自定义View源码,实现歌词风格的文字颜色渐变效果,面向需要为TextView添加动态渐变文字的移动开发者。项目通过GradientTextView对文本着色,以两种颜色平滑过渡,适合音乐播放器歌词、字幕强调等场景,基础与进阶Android学习者均可参考。压缩包共39个文件,包含13个xml布局与配置、6个java核心逻辑、5张png示意图、4个gradle构建脚本等,整体仅101KB,结构轻量。已有494人学习下载。资源内附完整Gradle配置方式,支持通过JitPack引入依赖,同时提供源码工程、ProGuard规则及使用示例,便于直接整合到自己的项目,也可作为自定义View与梯度绘制的学习范本。
1. 歌词风格歌词逐行滚动显示,用渐变画出一条“时间线”
先聊点实际的。做音乐类App或者K歌类项目时,歌词展示是绕不开的模块。常规做法是整段文字堆上去,当前句子高亮一下,顶多再做个滚动动画。但用户真正要的是“歌词感”:正在唱的那一句亮起来,唱过的句子慢慢变暗,未唱到的句子还在下面等着,最好还有一条视觉上的时间轴,让人一眼就知道进度。
这个需求如果只靠TextView换个颜色,效果很生硬。我做这类组件时习惯用Gradient(渐变)来做核心的视觉区分,原理很简单:让歌词文字本身带上一段从左到右或从上到下的颜色渐变,再用滚动进度去控制渐变的位置,这样唱到哪、唱了多少,都能通过颜色过渡直观表达出来。说白了,就是把歌词当成一条有颜色的“进度条”来画,文字只是这条进度条的可视化载体。
这篇文章就围绕“歌词风格的TextView + Gradient渐变”展开,适合正在做播放器界面、K歌歌词页、或者单纯想给TextView加点视觉表现力的Android开发者。你不需要额外引入复杂的三方库,原生Canvas + LinearGradient就能搞定,我会从原理讲到完整实现,最后把我在真机调试中踩过的坑一并列出来。
2. 整体设计思路:与其用遮罩,不如让文字自带渐变
2.1 为什么不用传统的“裁剪/遮罩”方案
我早期做歌词高亮时,用的是最常见的“双层TextView叠加遮罩”方案:底层文字全亮色,上层文字灰色,再用一个裁切区域(clipRect)按进度把上层露出来。逻辑上没问题,实际效果却很死板——高亮和未高亮之间是一条硬边,看起来像两段文字硬拼在一起,完全没有“逐渐唱完”的顺滑感。
后来换成“文字颜色本身做渐变”之后,效果完全不一样。核心思路是:不再把歌词高亮视为“两个状态的切换”,而是当成一段连续的颜色过渡。唱到30%,那这行字就从左到右渐变出30%的亮色;唱到80%,亮色的比例随之右移。这个过程是像素级的,没有任何跳变。
2.2 Gradient选型:LinearGradient最适合做时间轴
Android里可用的渐变方式有好几种,比如 RadialGradient(辐射渐变)、SweepGradient(扫描渐变),但歌词进度这种需求,LinearGradient(线性渐变)是最对口的。
原因有两个。第一,歌词阅读方向是固定的,要么从左到右,要么从上到下,线性渐变天然贴合这种方向性;第二,LinearGradient支持通过矩阵变换去“移动”渐变的位置,而我们监控播放进度时,只需要把渐变起始位置和结束位置当作变量传进去即可,更新成本极低。
// 构造一个从左到右的线性渐变 Shader shader = new LinearGradient( 0, 0, viewWidth, 0, // 起点到终点 new int[]{highlightColor, normalColor}, // 颜色数组 new float[]{0f, 1f}, // 颜色分布位置 Shader.TileMode.CLAMP );这里的核心在于:把渐变颜色数组设定成“高亮色”和“普通色”的组合,字母越靠左越接近高亮色,越靠右越接近普通色。再通过Matrix把整个渐变整体平移,就实现了“进度感”。
2.3 核心计算公式:进度如何映射成渐变偏移量
要真正做出歌词效果,我们需要把播放进度转换成渐变在文字上的偏移距离。先定义几个变量:
progress:当前播放进度,范围 0f ~ 1f。viewWidth:控件可视区域宽度。highlightWidth:高亮区段的宽度,这个可以按文字长度或控件宽度计算。
渐变偏移量的计算方式如下:
float translateX = -viewWidth * (1 - progress); Matrix matrix = new Matrix(); matrix.setTranslate(translateX, 0); linearGradient.setLocalMatrix(matrix);这里为什么要乘以viewWidth?因为LinearGradient的默认范围是整个控件宽度,假设进度为0时,渐变完全在可视区域左侧;进度为1时,渐变右移了整个宽度。这样当前唱到哪个字,哪个字位置的颜色就恰好是高亮色。
如果你希望高亮段只是一个固定宽度(比如一行歌词的前30%),那可以调整计算逻辑:
// 高亮区宽度 = 控件宽度 * 单行高亮比例 float highlightWidth = viewWidth * 0.3f; // 渐变起始点偏移 = 进度 * (控件宽度 - 高亮区宽度) float startOffset = progress * (viewWidth - highlightWidth); Shader shader = new LinearGradient( startOffset, 0, startOffset + highlightWidth, 0, new int[]{highlightColor, normalColor}, new float[]{0f, 1f}, Shader.TileMode.CLAMP );这种写法对“卡拉OK逐字高亮”很实用,每个字的颜色从左到右逐渐变亮,并且高亮段的右边缘就是当前进度。
3. 完整实现:打造一个LrcTextView
光说原理容易,下面给出一个可直接用的自定义View,支持单行歌词的渐变进度效果,也预留了多行歌词的扩展空间。
3.1 自定义属性定义
先在res/values/attrs.xml里声明自定义属性,方便布局中直接配置颜色和进度。
<resources> <declare-styleable name="GradientLrcTextView"> <attr name="lrc_highlight_color" format="color" /> <attr name="lrc_normal_color" format="color" /> <attr name="lrc_progress" format="float" /> <attr name="lrc_highlight_width_ratio" format="float" /> </declare-styleable> </resources>lrc_highlight_color:高亮歌词颜色。lrc_normal_color:未高亮歌词颜色。lrc_progress:当前进度,0f ~ 1f。lrc_highlight_width_ratio:高亮区段占控件宽度的比例,卡拉OK模式建议设 0.3 左右。
3.2 自定义View核心代码
public class GradientLrcTextView extends AppCompatTextView { private int highlightColor = Color.WHITE; private int normalColor = Color.GRAY; private float progress = 0f; private float highlightWidthRatio = 0.5f; private Paint paint; private LinearGradient linearGradient; private int viewWidth; public GradientLrcTextView(Context context) { this(context, null); } public GradientLrcTextView(Context context, AttributeSet attrs) { super(context, attrs); TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.GradientLrcTextView); highlightColor = ta.getColor(R.styleable.GradientLrcTextView_lrc_highlight_color, Color.WHITE); normalColor = ta.getColor(R.styleable.GradientLrcTextView_lrc_normal_color, Color.GRAY); progress = ta.getFloat(R.styleable.GradientLrcTextView_lrc_progress, 0f); highlightWidthRatio = ta.getFloat(R.styleable.GradientLrcTextView_lrc_highlight_width_ratio, 0.5f); ta.recycle(); paint = getPaint(); paint.setShader(createShader()); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); viewWidth = w; paint.setShader(createShader()); } @Override protected void onDraw(Canvas canvas) { // 使用设置了渐变的paint绘制文本 canvas.drawText(getText().toString(), getPaddingLeft(), getBaseline(), paint); } private Shader createShader() { float highlightWidth = viewWidth * highlightWidthRatio; float startOffset = progress * (viewWidth - highlightWidth); return new LinearGradient( startOffset, 0, startOffset + highlightWidth, 0, new int[]{highlightColor, normalColor}, new float[]{0f, 1f}, Shader.TileMode.CLAMP ); } public void setProgress(float progress) { this.progress = Math.max(0f, Math.min(1f, progress)); paint.setShader(createShader()); invalidate(); } }有几点提醒一下:
首先,我没有使用super.onDraw(canvas),而是自己调canvas.drawText,原因在于TextView默认的绘制流程会重新生成Shader,导致自定义渐变被覆盖。直接用paint的shader去drawText,是确保渐变生效的最稳妥方式。
其次,onSizeChanged里必须重新创建Shader,因为渐变范围依赖控件真实宽度。如果在构造函数里就创建,此时控件宽度还是0,渐变效果会消失。
3.3 在播放器中的用法
布局文件中配置:
<com.yourpackage.GradientLrcTextView android:id="@+id/lrc_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:textSize="18sp" android:text="这首歌是我对你的全部记忆" app:lrc_highlight_color="#FFCC00" app:lrc_normal_color="#666666" app:lrc_progress="0" />播放进度回调里调用:
GradientLrcTextView lrcText = findViewById(R.id.lrc_text); // 播放器进度回调中,计算百分比 player.addProgressListener(progress -> { float percent = progress / player.getDuration(); lrcText.setProgress(percent); });这里有个小细节:如果你用的是MediaPlayer,进度回调频率是系统自带的,通常几百毫秒一次,视觉上已经足够流畅。但如果你在自定义编解码器里回调过快,建议在setProgress里做一下进度差值判断,相同值直接 return,避免无意义的重绘开销。
4. 常见问题与排查技巧实录
4.1 TextView文本垂直居中偏移
用canvas.drawText()自绘后,很多人会碰到文字往上偏或往下偏的问题。原因是drawText的基准线是baseline,不是文字中心。处理办法是手动计算baseline:
FontMetrics fm = paint.getFontMetrics(); float baseline = (getHeight() - fm.bottom - fm.top) / 2; canvas.drawText(text, paddingLeft, baseline, paint);4.2 渐变颜色数组是“从高亮到普通”,还是“从普通到高亮”?
这个特别容易写反。线性渐变的颜色数组顺序是从起点到终点。如果想让进度越往右颜色越亮,起点必须是普通色,终点是高亮色。我上面的写法其实是反过来的:渐变起点位置随进度移动,渐变内部是从“高亮到普通”,高亮色始终紧贴进度边缘的左侧。这符合卡拉OK那种“唱过的部分亮着、没唱的部分暗着”的直觉。
具体用哪种顺序,取决于你想表达的风格:
- 唱过变亮:渐变数组
new int[]{normalColor, highlightColor}。 - 唱过变暗:渐变数组
new int[]{highlightColor, normalColor}。 - 唱过从亮到暗、当前字最亮:可以加三个颜色的渐变,中间色作为峰值。
4.3 歌词对不齐文字宽度
如果你把LinearGradient的范围设为整个View宽度,但文字并没有撑满整个View,会出现渐变提前结束的情况,导致后半段文字全部变成同一个颜色。解决办法有两种:
第一种,在创建Shader前测量文本真实宽度:
Rect bounds = new Rect(); paint.getTextBounds(text, 0, text.length(), bounds); float textWidth = bounds.width();然后把LinearGradient的终点设成textWidth。但要注意后续平移计算也要基于textWidth而不是viewWidth。
第二种,直接让TextView宽度等于内容宽度,配合Gravity.CENTER。这种方式简单,但在长歌词换行时会比较麻烦。
4.4 性能优化:避免每帧重建Shader
一开始的版本里,我在setProgress中直接new LinearGradient,高频回调时会频繁触发对象创建,GC压力不小。优化方式是复用Shader,只更新矩阵:
private LinearGradient gradient; private Matrix matrix = new Matrix(); private void updateGradient() { float highlightWidth = viewWidth * highlightWidthRatio; float startOffset = progress * (viewWidth - highlightWidth); if (gradient == null) { gradient = new LinearGradient( 0, 0, viewWidth, 0, new int[]{highlightColor, normalColor}, new float[]{0f, 1f}, Shader.TileMode.CLAMP ); } matrix.setTranslate(startOffset, 0); gradient.setLocalMatrix(matrix); paint.setShader(gradient); invalidate(); }这里有个前提:渐变颜色不随进度变化,只移动位置,所以Shader本身只用创建一次,后续只改矩阵。实测后真机上帧率稳定,连续滑动进度条也没有掉帧。
4.5 多行歌词怎么处理
上面的实现是单行歌词。多行歌词通常有两种场景:一种是当前行和其他行的颜色对比,另一种是当前行内部逐字高亮。
如果是第一种,更推荐用RecyclerView + 每行一个歌词TextView,当前行用渐变色,其他行固定普通色。这样做滚动回收都很自然。
如果是当前行内部逐字高亮,可以实时计算每个文字的起始坐标,循环drawText,每个文字单独设置透明度或颜色。这种方法控制粒度更细,能做出“一字一句”的卡拉OK效果,但开销比Shader大,需要控制歌词文本长度。
5. 歌词渐变的细节打磨与进阶玩法
5.1 渐变过渡带宽度怎么调
如果想要更自然的过渡效果,可以把渐变的颜色数组增加一个中间过渡色:
new int[]{normalColor, middleColor, highlightColor}, new float[]{0f, 0.7f, 1f}0.7f位置之前保持普通色,最后30%的区域完成渐变过渡。这样歌词看起来不是生硬地从暗变亮,而是有一条柔和的过渡光带,视觉效果更像专业音乐App。
5.2 字体大小适配屏幕
歌词字体会直接影响渐变可视效果。字体过大时,一行文字可能超出屏幕宽度,导致进度还没到一半就已经跑出可视区域。我的经验是:高亮区宽度比例不要写死,综合考虑字体大小和最长歌词字数。
float highlightWidth = Math.min(textWidth * 0.3f, viewWidth);这样既保证高亮区足够醒目,又不会在超长歌词时溢出。
5.3 锁屏页歌词与桌面歌词的交互
如果你做的App需要锁屏歌词或桌面悬浮窗歌词,这套渐变TextView同样适用,唯一要注意的是WindowManager添加View时不走正常的LayoutInflater流程,必须手动设置宽度。
WindowManager.LayoutParams params = new WindowManager.LayoutParams( WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT, ...); GradientLrcTextView lrcView = new GradientLrcTextView(context); lrcView.setLayoutParams(new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ));在onSizeChanged回调前,viewWidth还是0,这时如果恰好有进度回调进来,Shader会创建失败。可以加个判断:
if (viewWidth == 0) return;等布局稳定后再走正常流程。
6. 亲自踩坑后的一些经验总结
歌词渐变看起来高级,实现逻辑本身不复杂,真正的坑全在细节里。我这里把容易栽跟头的地方再集中说一遍。
第一,不要在主线程里频繁做new LinearGradient。你以为一行歌词最多几十个字,性能无关紧要,但配上Activity转场动画或列表滑动时,重绘频率会成倍放大,老机型能明显感受到卡顿。复用Shader + Matrix平移是性价比最高的方案。
第二,自定义View后一定要处理onSizeChanged和onDraw的生命周期。绕开TextView的默认draw流程,意味着你失去了TextView自带的一部分能力,比如行间距、字体阴影、自动换行的绘制优化。如果发现渐变出现在错误的位置,先从View的实际宽度入手排查。
第三,渐变效果和字体颜色透明度有很大关系。如果你的普通色和高亮色都是半透明颜色,叠加在不同背景上视觉差异会缩小,甚至看不清渐变边界。实际上深色背景用#FFCC00这类亮色做高亮、#666666做普通色,是视觉上最安全、最不踩雷的方案。
最后分享一个我个人比较喜欢的小技巧:给歌词TextView加一个微弱的上下边缘渐隐——用Paint的maskfilter叠加一个垂直方向的LinearGradient,让歌词上下边缘自然淡出。和进度方向的渐变叠加在一起,歌词页的质感会提升一个档次,而且实现成本很低,就是再多加两层LinearGradient的绘制,性能影响几乎可以忽略。
这一套Gradient歌词TextView,我从最初的双层遮罩方案优化到现在,线上项目跑了大半年,没有出现过绘制异常或明显掉帧的情况。有需要的朋友可以直接拿去改,按你自己的音乐风格调整颜色组合。如果后面有遇到更复杂的歌词语义分段需求,这个View也能再往上叠加一个歌词解析器,把每行文本拆成字段时间轴,配合逐字渐变播放,那就是正儿八经的演唱级歌词方案了。
本文还有配套的精品资源,点击获取