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 以下),牺牲了「炫彩」,换回了可读性。

如果确实需要高饱和的艳色文字,唯一的办法是把背景压暗 —— 深色底上的亮色文字对比度能到 10:1 左右。但那会改变整个界面风格, 要看场景是否允许。

最后:动效要尊重系统的无障碍设置

系统设置里有「减弱动效」这个开关(对应 Settings.Global.ANIMATOR_DURATION_SCALE = 0)。 API 26 起有官方接口可以直接判断:

if (!ValueAnimator.areAnimatorsEnabled()) {
    // 直接落到动画结束时的状态,而不是播一个「短版」
    setEntranceMillis(ENTRANCE_MS);
    return;
}

注意是「直接到终态」,不是「把动画时长改短」。对这类有前庭敏感的用户来说, 任何位移都可能造成不适。

← 返回首页