Android 自定义 TextView:渐变文字与逐字动画
需求是做一屏提示语:文字要带渐变,还要一个字一个字地弹起来。
系统的 TextView 只能画单色,逐字动画更是做不到,
只能自己接管绘制。
为什么是「继承 TextView」而不是「继承 View」
第一反应可能是直接写个 extends View,自己画文字。
但这样做会丢掉 TextView 的一整套排版能力:
换行、maxLines、ellipsize、
breakStrategy、letterSpacing、
lineSpacingMultiplier……在中文里这些都很重要。
更实际的理由:如果代码里已经有 findViewById(...) 赋值给
TextView 的地方,换成 View 会直接
ClassCastException。
所以正确姿势是继承 AppCompatTextView,只覆盖 onDraw:
public class GradientHeadlineView extends AppCompatTextView {
// 保留 super 的 measure/layout,排版能力全部继续生效
@Override
protected void onDraw(Canvas canvas) {
// 自己画
}
}
接管 onDraw 要补的那一步
这是第一个坑。TextView.onDraw 在真正画字之前,会先做一次画布平移:
canvas.translate(getCompoundPaddingLeft(),
getExtendedPaddingTop() + voffset);
如果你实现了 onDraw 却不调 super.onDraw,
这一步也省掉了 —— 结果是文字贴在控件左上角,padding 完全失效。
@Override
protected void onDraw(Canvas canvas) {
final Layout layout = getLayout();
if (layout == null) { super.onDraw(canvas); return; }
final int voffset = Math.max(0,
(getHeight() - getCompoundPaddingTop() - getCompoundPaddingBottom()
- layout.getHeight()) / 2);
canvas.save();
canvas.translate(getCompoundPaddingLeft(),
getExtendedPaddingTop() + voffset);
drawPasses(canvas, layout);
canvas.restore();
}
分四层画
要让文字有「厚度」,不能只画一遍。我的顺序是:
| 层 | 画什么 | 参数 |
|---|---|---|
| ① | 下投影 | 偏移一份深色字影,向下约 0.06em |
| ② | 描边 | Style.STROKE,宽度 0.05em,
StrokeJoin.ROUND |
| ③ | 渐变填充 | Style.FILL + LinearGradient |
| ④ | 顶部高光 | 竖向渐变,只覆盖上方约一半 |
顺序不能换:描边在填充之下,填充会盖住描边的内侧一半,
这样字形只会向外长出 strokeWidth/2,不会显得虚胖。
Style.STROKE 是居中描边,会向内吃掉字怀的一半。
「回」「国」「目」这类字的字怀本来就只有几个像素,
描边放到 0.10em 基本就糊死了。我最后定在 0.05em,
并且一定要设 StrokeJoin.ROUND,否则中文的尖角会出「刺」。
第二个坑:自定义 Paint 没有字号
这个坑让我浪费了不少时间。我在构造函数里建了几个 Paint:
private final Paint mFillPaint = new Paint(ANTI_ALIAS_FLAG);
private final Paint mStrokePaint = new Paint(ANTI_ALIAS_FLAG);
// ...
然后直接拿去画。结果是屏幕上只有几个看不清的小点。
原因很简单:Paint 的 textSize 默认是 0。
用 canvas.drawText(..., paint) 时,字号来自 paint
而不是 TextView,所以什么都没画出来(只剩一点抗锯齿的痕迹)。
更麻烦的是,就算手动设了 setTextSize(getTextSize()),
字体、letterSpacing、fontFeatureSettings
也都得跟着同步,否则绘制宽度和 TextView 的布局宽度对不上,
文字会错位。
最省心的写法是每帧从 TextView 自己的 TextPaint 整体复制:
@Override
protected void onDraw(Canvas canvas) {
final TextPaint src = getPaint(); // TextView 自己的 paint
mStrokePaint.set(src); // 整体复制
mStrokePaint.setStyle(Paint.Style.STROKE);
mStrokePaint.setShader(null);
mStrokePaint.setColor(STROKE_COLOR);
mStrokePaint.setStrokeJoin(Paint.Join.ROUND);
mFillPaint.set(src);
mFillPaint.setStyle(Paint.Style.FILL);
mFillPaint.setShader(mGradient);
// ...
}
set() 会把 shader 一起复制过来。
所以 shader 必须在 set() 之后再指定,否则会被覆盖成 null。
逐字绘制:坐标从哪来
要单独控制每个字,就得算出每个字的位置。可以用 Layout
提供的 API:
for (int line = 0; line < layout.getLineCount(); line++) {
int start = layout.getLineStart(line);
int end = layout.getLineEnd(line);
float baseline = layout.getLineBottom(line) - layout.getLineDescent(line);
for (int i = start; i < end; i++) {
float x = layout.getPrimaryHorizontal(i);
canvas.drawText(chars, i, 1, x, baseline, mFillPaint);
}
}
这里有个细节值得注意:getPrimaryHorizontal() 返回的坐标
是否已经包含了居中对齐带来的偏移,在不同版本上表现不完全一致。
稳妥的做法是拿首字的返回值跟 getLineLeft() 比一下,
差得远就手动补上:
float lineLeft = layout.getLineLeft(line);
float firstPrimary = layout.getPrimaryHorizontal(lineStart);
float alignOffset = (Math.abs(firstPrimary - lineLeft) < 1f) ? 0f : lineLeft;
// 之后每个字都用 primaryHorizontal(i) + alignOffset
另外逐字绘制时不要用 String.substring(),
每帧都会产生大量临时对象。用 canvas.drawText(char[], index, count, ...)
可以完全避免分配。
渐变怎么「流动」
如果每帧都 new LinearGradient(...),60fps 下必然掉帧。
正确做法是建一次,之后只改它的局部矩阵:
// 只建一次
mGradient = new LinearGradient(0, 0, period, 0,
COLORS, POSITIONS, Shader.TileMode.REPEAT);
// 每帧只改矩阵
mShaderMatrix.setTranslate(-phase * period, 0);
mGradient.setLocalMatrix(mShaderMatrix);
TileMode.REPEAT 加上首尾相同的颜色,就能无缝循环。
周期取文字块宽度的话,一行之内正好走完一整圈色轮。
渐变色要考虑对比度
一开始想用很高饱和的彩虹色,实际放进界面发现有几种颜色几乎看不见。 在浅色背景上测下来:
| 颜色 | 对比度 | 结果 |
|---|---|---|
| 柠檬黄 | 1.23:1 | 基本消失 |
| 草绿 | 1.81:1 | 基本消失 |
| 天蓝 | 2.54:1 | 很勉强 |
| 深红 | 7.15:1 | 清晰 |
| 靛蓝 | 8.92:1 | 清晰 |
WCAG 对正文的建议是 4.5:1。所以最终把色板压到了深色区间 (明度 L* 控制在 45 以下),牺牲了「炫彩」,换回了可读性。
最后:动效要尊重系统的无障碍设置
系统设置里有「减弱动效」这个开关(对应
Settings.Global.ANIMATOR_DURATION_SCALE = 0)。
API 26 起有官方接口可以直接判断:
if (!ValueAnimator.areAnimatorsEnabled()) {
// 直接落到动画结束时的状态,而不是播一个「短版」
setEntranceMillis(ENTRANCE_MS);
return;
}
注意是「直接到终态」,不是「把动画时长改短」。对这类有前庭敏感的用户来说, 任何位移都可能造成不适。