Android自定义TextView实现歌词渐变高亮效果 简介这是一份面向 Android 开发者的自定义控件源码聚焦歌词风格的文字渐变效果适合想学习自定义 View 与 Shader 绘制的中级开发者参考。核心 GradientTextView 通过 Gradient 渐变让文字颜色逐步过渡目前支持双色渐变可用于音乐播放器歌词、标题高亮、进度提示等场景。资源包共 39 个文件约 101KB包含 6 个 Java 源码文件、13 个 XML 布局与配置、5 张 PNG 效果图以及 4 个 gradle 构建脚本和 properties、jar 等辅助文件目录分为 app 与 mylibrary 两个模块结构清晰便于直接导入 Android Studio 运行调试。已有 495 人学习下载。读者可从中获取完整的自定义 View 实现思路、渐变着色器用法、XML 属性定义方式与 Gradle 依赖配置示例并借助效果图快速验证渐变文字在真实界面中的表现适合作为控件二次开发与绘制原理学习的参考素材。1. 歌词渐变 TextView一个把文字玩出光影层次的自定义控件做音乐播放器或者 KTV 类 App 的时候产品经理总会提一个需求歌词要跟着节奏一行行高亮最好还有那种从暗到亮、像光扫过去的渐变效果。用系统自带的 TextView 加 ForegroundColorSpan 能做单色高亮但要做真正的渐变过渡就得自己动手了。这个项目就是一个纯 Android 自定义控件核心思路是用 LinearGradient 配合 Shader 去渲染文字让 TextView 的每一行文字都能呈现出从透明到实色、或者从一种颜色平滑过渡到另一种颜色的效果。它适合正在做播放器、歌词展示、或者任何需要文字渐变视觉的 Android 开发者代码量不大但把 Canvas 绘制文字和 Shader 配合的细节讲得很透。拿到源码后你可以直接把它当成一个普通 TextView 用也可以顺着它的思路去改造成更复杂的逐字渐变。2. 核心原理拆解LinearGradient 怎么把文字染上渐变2.1 从 TextView 到自定义 View 的继承关系这个控件本质上是一个继承自 android.widget.TextView 的自定义 View。为什么选 TextView 而不是 View因为 TextView 已经帮我们处理了文字测量、换行、字体样式、行距这些繁琐的排版逻辑我们只需要在它绘制文字的时候把画笔的 Shader 换成一个 LinearGradient 就行。如果从 View 开始写光是处理多行文本的换行和基线对齐就够喝一壶的。继承之后需要重写的关键方法只有一个onDraw。但要注意TextView 的 onDraw 里已经调用了 super.onDraw(canvas) 来绘制文字如果我们直接在里面改 Paint 的 Shader会影响整个控件的绘制。所以常见的做法是不在 onDraw 里动手脚而是通过 getPaint() 拿到 TextView 内部使用的 TextPaint在合适的时机给它设置 Shader。public class GradientTextView extends androidx.appcompat.widget.AppCompatTextView { private LinearGradient mLinearGradient; private int[] mColors; // 渐变颜色数组 private float[] mPositions; // 颜色位置数组可为 null private int mGradientDirection; // 渐变方向 public GradientTextView(Context context) { this(context, null); } public GradientTextView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public GradientTextView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs); } private void init(Context context, AttributeSet attrs) { // 默认从左到右渐变 mGradientDirection DIRECTION_LEFT_TO_RIGHT; mColors new int[]{0xFF000000, 0xFF888888}; mPositions null; } }这段代码定义了控件的基本骨架。mColors 是渐变用的颜色数组至少需要两个颜色mPositions 用来控制每个颜色在渐变中的位置如果传 nullLinearGradient 会自动均匀分布。mGradientDirection 是一个自定义的方向常量后面会根据它来计算渐变起点和终点的坐标。构造函数里调用了 init 做默认值初始化保证即使不在 XML 里配置属性控件也能正常显示。2.2 LinearGradient 的构造参数与坐标系LinearGradient 的构造函数有六个参数起点 x、起点 y、终点 x、终点 y、颜色数组、位置数组最后一个参数是平铺模式通常用 CLAMP 就行。关键在于起点和终点的坐标怎么定。TextView 的绘制区域是从 (0, 0) 到 (getWidth(), getHeight())所以渐变方向本质上就是在这个矩形里选两个点。Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // 根据方向计算渐变起点和终点 float startX 0f, startY 0f, endX 0f, endY 0f; switch (mGradientDirection) { case DIRECTION_LEFT_TO_RIGHT: startX 0f; startY 0f; endX w; endY 0f; break; case DIRECTION_TOP_TO_BOTTOM: startX 0f; startY 0f; endX 0f; endY h; break; case DIRECTION_DIAGONAL: startX 0f; startY 0f; endX w; endY h; break; default: startX 0f; startY 0f; endX w; endY 0f; } mLinearGradient new LinearGradient( startX, startY, endX, endY, mColors, mPositions, Shader.TileMode.CLAMP ); getPaint().setShader(mLinearGradient); }onSizeChanged 是设置 Shader 的最佳时机因为这时候控件的宽高已经确定了。如果放在构造函数里getWidth() 和 getHeight() 都还是 0算出来的渐变坐标没有意义。这里根据方向常量分别计算了起点和终点从左到右就是 (0,0) 到 (w,0)从上到下就是 (0,0) 到 (0,h)对角线就是 (0,0) 到 (w,h)。最后把创建好的 LinearGradient 设置给 TextView 的 Paint文字就会按照这个渐变来着色。注意getPaint().setShader() 会影响 TextView 绘制的所有文字包括可能存在的 drawable 和提示文字。如果只想让部分文字渐变需要更精细的控制。2.3 颜色数组与位置数组的配合颜色数组和位置数组是 LinearGradient 最灵活的地方。颜色数组决定了渐变经过哪些颜色位置数组决定了每个颜色出现在渐变线的哪个位置。位置数组的取值范围是 0 到 1长度必须和颜色数组一致如果传 null 就均匀分布。// 三色渐变红 - 绿 - 蓝中间绿色在 30% 的位置 int[] colors new int[]{0xFFFF0000, 0xFF00FF00, 0xFF0000FF}; float[] positions new float[]{0f, 0.3f, 1f}; mLinearGradient new LinearGradient(0, 0, w, 0, colors, positions, Shader.TileMode.CLAMP); getPaint().setShader(mLinearGradient);这个例子中红色从 0% 开始绿色在 30% 的位置就出现了然后一直渐变到 100% 的蓝色。位置数组的好处是可以做出非均匀的渐变效果比如歌词高亮时可以让亮色集中在某个区域而不是均匀铺开。如果位置数组传 null三个颜色就会分别在 0%、50%、100% 的位置效果更平均。2.4 在 onDraw 中动态更新渐变位置静态渐变只能满足基本需求歌词高亮往往需要渐变跟着进度走。这时候就需要在 onDraw 里根据当前进度重新计算渐变起点和终点然后重新创建 LinearGradient。Override protected void onDraw(Canvas canvas) { // 假设 mProgress 是外部设置的进度范围 0~1 if (mProgress 0 mProgress 1) { float gradientWidth getWidth() * 0.3f; // 渐变区域宽度 float centerX getWidth() * mProgress; float startX centerX - gradientWidth / 2; float endX centerX gradientWidth / 2; LinearGradient dynamicGradient new LinearGradient( startX, 0, endX, 0, new int[]{0x00FFFFFF, 0xFFFFFFFF, 0x00FFFFFF}, new float[]{0f, 0.5f, 1f}, Shader.TileMode.CLAMP ); getPaint().setShader(dynamicGradient); } super.onDraw(canvas); }这段代码实现了一个跟随进度移动的高亮光带。mProgress 从 0 到 1 变化时渐变区域的中心点从左边移到右边光带宽度固定为控件宽度的 30%。颜色数组用了透明-白-透明这样光带边缘是柔和的不会出现生硬的截断。每次 onDraw 都重新创建 LinearGradient 会有一定的性能开销如果进度变化频繁可以考虑复用对象或者用 setLocalMatrix 来平移渐变。3. 工程结构一览从自定义属性到完整控件3.1 目录结构与文件职责拿到源码后先看目录结构。这是一个典型的 Android 自定义控件项目核心文件不多但每个都有明确职责。GradientTextView/ ├── app/ │ ├── src/main/java/com/example/gradienttextview/ │ │ ├── GradientTextView.java // 自定义控件核心类 │ │ └── MainActivity.java // 演示用法 │ ├── src/main/res/ │ │ ├── layout/activity_main.xml // 布局文件 │ │ ├── values/attrs.xml // 自定义属性定义 │ │ └── values/colors.xml // 颜色资源 │ └── build.gradle // 模块构建脚本 └── build.gradle // 项目构建脚本GradientTextView.java 是核心所有绘制逻辑都在这里。attrs.xml 定义了自定义属性让控件可以在 XML 里配置渐变颜色和方向。MainActivity 和 activity_main.xml 是演示入口展示了几种不同的渐变效果。build.gradle 里没有特殊的第三方依赖纯 Android SDK 实现兼容性很好。3.2 自定义属性的声明与解析为了让控件好用需要把渐变颜色和方向暴露成 XML 属性。在 attrs.xml 里声明 declare-styleable然后在构造函数里解析。!-- res/values/attrs.xml -- resources declare-styleable nameGradientTextView attr namegradientColors formatreference|color / attr namegradientDirection enum nameleftToRight value0 / enum nametopToBottom value1 / enum namediagonal value2 / /attr attr namegradientPositions formatreference / /declare-styleable /resourcesgradientColors 用 reference|color 格式既可以传颜色数组资源也可以直接写颜色值。gradientDirection 用枚举限定三个方向。gradientPositions 用 reference指向一个 float 数组资源。声明好之后在构造函数里通过 obtainStyledAttributes 读取。private void init(Context context, AttributeSet attrs) { if (attrs ! null) { TypedArray ta context.obtainStyledAttributes(attrs, R.styleable.GradientTextView); // 读取颜色数组 int colorsId ta.getResourceId(R.styleable.GradientTextView_gradientColors, 0); if (colorsId ! 0) { mColors context.getResources().getIntArray(colorsId); } // 读取方向 mGradientDirection ta.getInt(R.styleable.GradientTextView_gradientDirection, 0); // 读取位置数组 int positionsId ta.getResourceId(R.styleable.GradientTextView_gradientPositions, 0); if (positionsId ! 0) { float[] positions getFloatArray(context.getResources(), positionsId); mPositions positions; } ta.recycle(); } }这里有个细节getIntArray 只能读取 integer-array 资源颜色数组需要定义成 integer-array 而不是 array。位置数组是 float 类型Android 没有直接读取 float 数组的 API需要自己写一个辅助方法从 TypedArray 里逐个读取。ta.recycle() 一定要调用否则会内存泄漏。3.3 在布局中使用控件配置好属性后在布局文件里就可以像普通 TextView 一样使用只是多了几个自定义属性。com.example.gradienttextview.GradientTextView android:layout_widthmatch_parent android:layout_heightwrap_content android:text这是一段渐变文字 android:textSize24sp app:gradientColorsarray/rainbow_colors app:gradientDirectionleftToRight app:gradientPositionsarray/rainbow_positions /rainbow_colors 是一个 integer-array里面放了多个颜色值。rainbow_positions 是一个 float 数组控制每个颜色的位置。如果不写 gradientPositions渐变会均匀分布。gradientDirection 选 leftToRight 就是从左边到右边渐变。整个使用方式和系统 TextView 几乎一样学习成本很低。4. 避坑与常见问题那些让我加班到凌晨的细节4.1 文字不显示渐变只有纯色现象设置了渐变颜色但文字看起来还是单一颜色没有过渡效果。原因最常见的是在构造函数里就调用了 getPaint().setShader()但那时候控件的宽高还是 0LinearGradient 的起点和终点坐标都是 0Shader 实际上没有覆盖到文字区域。另一个可能是颜色数组里所有颜色值太接近肉眼看不出来。解决把 Shader 的设置放到 onSizeChanged 里确保宽高已经确定。如果还是不行检查颜色数组是否至少有两个差异明显的颜色并且位置数组没有把颜色都挤在同一个位置。4.2 渐变方向反了或者不对现象想要从左到右渐变结果变成了从右到左或者渐变区域只覆盖了文字的一半。原因LinearGradient 的起点和终点坐标决定了渐变方向。如果起点 x 大于终点 x渐变就会反向。另外TextView 可能有 padding文字实际绘制区域不是从 (0,0) 开始的如果直接用控件宽高算坐标渐变可能会偏移。解决在 onSizeChanged 里计算坐标时把 padding 考虑进去。用 getPaddingLeft()、getPaddingRight() 来修正起点和终点。如果方向反了交换起点和终点的坐标即可。4.3 动态更新渐变时文字闪烁现象在 onDraw 里根据进度重新创建 LinearGradient文字出现闪烁或者卡顿。原因每次 onDraw 都 new 一个 LinearGradient 对象频繁创建对象会增加 GC 压力。而且如果进度变化很快Shader 不断切换渲染管线可能来不及处理。解决尽量复用 LinearGradient 对象用 setLocalMatrix 来平移渐变而不是重新创建。如果必须重新创建考虑用 postInvalidateOnAnimation 来控制刷新频率避免每帧都重绘。4.4 多行文字渐变不连续现象TextView 有多行文字时每一行的渐变都是从左边重新开始看起来像多条独立的渐变带。原因LinearGradient 的坐标系是整个控件的矩形区域但 TextView 绘制多行文字时每一行都是独立绘制的Shader 会应用到整个绘制区域。如果渐变方向是水平的每一行确实会独立渐变。解决如果希望多行文字整体渐变需要把渐变方向改成垂直方向或者用更复杂的 Shader。另一种思路是重写 onDraw自己控制每一行的绘制但这样会失去 TextView 的排版便利。常见做法是接受每行独立渐变或者只对单行文字使用这个控件。4.5 在 RecyclerView 中复用时渐变错乱现象把 GradientTextView 放在 RecyclerView 的 item 里滑动后渐变效果错乱有的 item 渐变方向不对。原因RecyclerView 复用 item 时onSizeChanged 可能不会再次触发导致 Shader 还是旧的坐标。另外如果 item 的宽高在复用时发生变化渐变坐标没有更新。解决在 onSizeChanged 里一定要调用 super.onSizeChanged并且每次尺寸变化都重新创建 Shader。如果 item 尺寸固定可以在 onBindViewHolder 里手动调用 requestLayout 或者 invalidate 来触发重绘。更稳妥的做法是监听布局变化用 addOnLayoutChangeListener 来更新 Shader。5. 二次开发改造从歌词高亮到逐字渐变5.1 把静态渐变改成跟随进度的动态高亮原项目的渐变是静态的但歌词场景需要渐变跟着播放进度走。改造思路是暴露一个 setProgress(float progress) 方法在 onDraw 里根据进度计算渐变中心点。public void setProgress(float progress) { mProgress Math.max(0f, Math.min(1f, progress)); invalidate(); // 触发重绘 } Override protected void onDraw(Canvas canvas) { if (mProgress 0) { float centerX getWidth() * mProgress; float halfWidth getWidth() * 0.15f; LinearGradient gradient new LinearGradient( centerX - halfWidth, 0, centerX halfWidth, 0, new int[]{0x00FFFFFF, 0xFFFFFFFF, 0x00FFFFFF}, new float[]{0f, 0.5f, 1f}, Shader.TileMode.CLAMP ); getPaint().setShader(gradient); } super.onDraw(canvas); }setProgress 接收 0 到 1 的进度值invalidate 触发重绘。onDraw 里根据进度算出渐变中心点光带宽度是控件宽度的 30%。这样播放器只需要在播放进度回调里调用 setProgress 就能实现光带扫过的效果。注意每次 onDraw 都创建了新的 LinearGradient如果进度更新频繁建议改成复用对象加 setLocalMatrix。5.2 逐字渐变的实现思路更高级的需求是逐字渐变每个字根据进度独立着色。这需要放弃 TextView 的整体绘制改成自己测量每个字的位置然后逐个绘制。Override protected void onDraw(Canvas canvas) { String text getText().toString(); Paint paint getPaint(); float x getPaddingLeft(); float y getBaseline(); for (int i 0; i text.length(); i) { float charProgress (float) i / text.length(); int color; if (charProgress mProgress) { color 0xFFFFFFFF; // 已播放亮色 } else { color 0x66FFFFFF; // 未播放暗色 } paint.setColor(color); canvas.drawText(String.valueOf(text.charAt(i)), x, y, paint); x paint.measureText(String.valueOf(text.charAt(i))); } }这段代码逐个字符绘制根据字符位置和当前进度的比较来决定颜色。已播放的字符用亮色未播放的用暗色。这种方式可以实现精确的逐字高亮但失去了 TextView 的换行和排版能力只适合单行歌词。如果要支持多行需要自己计算换行位置工作量会大很多。5.3 性能优化与验证方法动态渐变最大的问题是性能。每次 onDraw 都创建 LinearGradient 会产生大量临时对象。优化方法是把 LinearGradient 作为成员变量用 setLocalMatrix 来平移。private LinearGradient mReusableGradient; private Matrix mMatrix new Matrix(); Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mReusableGradient new LinearGradient( 0, 0, w * 0.3f, 0, new int[]{0x00FFFFFF, 0xFFFFFFFF, 0x00FFFFFF}, new float[]{0f, 0.5f, 1f}, Shader.TileMode.CLAMP ); getPaint().setShader(mReusableGradient); } public void setProgress(float progress) { mProgress progress; float translateX getWidth() * progress - getWidth() * 0.15f; mMatrix.setTranslate(translateX, 0); mReusableGradient.setLocalMatrix(mMatrix); invalidate(); }这样只创建一次 LinearGradient后续通过 Matrix 平移来改变渐变位置避免了频繁创建对象。验证方法很简单打开开发者选项里的 GPU 渲染模式分析看柱状图是否稳定在 16ms 以下。如果超过说明有性能问题需要继续优化。5.4 一个容易忽略的细节文字抗锯齿渐变文字如果边缘有锯齿视觉效果会大打折扣。确保 Paint 开启了抗锯齿。getPaint().setAntiAlias(true);这行代码通常放在 init 方法里。另外如果文字有阴影或者描边Shader 会和这些效果叠加可能出现意料之外的颜色。建议在使用渐变时关闭阴影和描边保持文字干净。从那以后我每次给自定义控件加 Shader都会先在 onSizeChanged 里把坐标算清楚再在 onDraw 里只做必要的更新绝不把对象创建放在高频调用的方法里。希望这个控件的思路能帮到你拿到源码后先跑起来看看效果再顺着 onDraw 的调用链改一改很快就能上手。本文还有配套的精品资源点击获取