简介:这是一份面向 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 用来控制每个颜色在渐变中的位置,如果传 null,LinearGradient 会自动均匀分布。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 name="GradientTextView"> <attr name="gradientColors" format="reference|color" /> <attr name="gradientDirection"> <enum name="leftToRight" value="0" /> <enum name="topToBottom" value="1" /> <enum name="diagonal" value="2" /> </attr> <attr name="gradientPositions" format="reference" /> </declare-styleable> </resources>gradientColors 用 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_width="match_parent" android:layout_height="wrap_content" android:text="这是一段渐变文字" android:textSize="24sp" app:gradientColors="@array/rainbow_colors" app:gradientDirection="leftToRight" app:gradientPositions="@array/rainbow_positions" />rainbow_colors 是一个 integer-array,里面放了多个颜色值。rainbow_positions 是一个 float 数组,控制每个颜色的位置。如果不写 gradientPositions,渐变会均匀分布。gradientDirection 选 leftToRight 就是从左边到右边渐变。整个使用方式和系统 TextView 几乎一样,学习成本很低。
4. 避坑与常见问题:那些让我加班到凌晨的细节
4.1 文字不显示渐变,只有纯色
现象:设置了渐变颜色,但文字看起来还是单一颜色,没有过渡效果。
原因:最常见的是在构造函数里就调用了 getPaint().setShader(),但那时候控件的宽高还是 0,LinearGradient 的起点和终点坐标都是 0,Shader 实际上没有覆盖到文字区域。另一个可能是颜色数组里所有颜色值太接近,肉眼看不出来。
解决:把 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 的调用链改一改,很快就能上手。
本文还有配套的精品资源,点击获取