如何使用自定义跨度在带占位符的 TextView 格式化文本上设置多个跨度?

How to set multiple spans on a TextView's formatted text with placeholder, using customized span?

背景

我知道如何在静态文本中的部分文本上设置多个跨度,正如我所问的 here :

final SpannableString text = new SpannableString("Hello Whosebug");
text.setSpan(new RelativeSizeSpan(1.5f), text.length() - "Whosebug".length(), text.length(),
            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
text.setSpan(new ForegroundColorSpan(Color.RED), 3, text.length() - 3, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
tv.setText(text);

这会将 "Whosebug" 设置为自身有 2 个 span。

我也知道如何在文本的一部分上设置可绘制范围,正如我所问的

问题

现在我需要在使用占位符格式化文本生成的文本上设置 2 个跨度,同时仍像往常一样设置其他样式。

例如,假设我在 strings.xml 中有下一个文本:

<string name="potential_free_upgrade_1_d_months">
    <![CDATA[
    Potential free upgrade: <uu><b><font color=\'#3792e5\'>%1$d months</font></b></uu>]]>
</string>

计划是“%1$d 个月”的文本颜色为“#3792e5”,并且有一个特殊的下划线,比默认下划线稍低一些。我为特殊的下划线使用了一个特殊的自定义标签"uu",在代码中处理。

问题是,无论我做什么,我都找不到如何同时显示文本颜色和下划线。

我试过的

由于这个问题有一个占位符(并且要格式化的文本周围的文本可能不同),我不得不使用 "Html.FromHtml" :

    String formattedStr = getString(R.string.potential_free_upgrade_1_d_months, 9);
    Spanned textToShow = Html.fromHtml(formattedStr, null, new TagHandler() {
        int start;

        @Override
        public void handleTag(final boolean opening, final String tag, Editable output, final XMLReader xmlReader) {
            switch (tag) {
                case "uu":
                    if (opening)
                        start = output.length();
                    else {
                        int end = output.length();
                        //output.setSpan(new ForegroundColorSpan(0xff3792e5), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
                        output.setSpan(
                                new DrawableSpan(ResourcesCompat.getDrawable(getResources(), R.drawable.bit_below_underline, null)),
                                start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
                    }
            }
        }
    });
    titleTextView.setText(textToShow);

DrawableSpan.java

public class DrawableSpan extends ReplacementSpan {
    private Drawable mDrawable;
    private final Rect mPadding;

    public DrawableSpan(Drawable drawable) {
        super();
        mDrawable = drawable;
        mPadding = new Rect();
        mDrawable.getPadding(mPadding);
    }

    @Override
    public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
        RectF rect = new RectF(x, top, x + measureText(paint, text, start, end), bottom);
        mDrawable.setBounds((int) rect.left - mPadding.left, (int) rect.top - mPadding.top, (int) rect.right + mPadding.right, (int) rect.bottom + mPadding.bottom);
        canvas.drawText(text, start, end, x, y, paint);
        mDrawable.draw(canvas);
    }

    @Override
    public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
        return Math.round(paint.measureText(text, start, end));
    }

    private float measureText(Paint paint, CharSequence text, int start, int end) {
        return paint.measureText(text, start, end);
    }
}

res/drawable/bit_below_underline.xml

<shape
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="line">
    <padding android:bottom="30dp"/>
    <stroke
        android:width="1dp"
        android:color="#3792e5"/>
</shape>

我尝试将 2 "setSpan" 调用在一起(首先在上面的代码中进行了注释),但没有帮助。

问题

如何在文本部分设置 2 个跨度,正如我在上面指定的那样(带占位符的部分文本),以便一个是文本颜色,另一个是自定义下划线?

测试您的代码时,似乎 ReplacementSpans 是在任何 CharacterStyleSpan 之前绘制的。所以尝试使用 MetricAffectingSpan 。它会在 ReplacementSpans 之前绘制。

所以使用自定义 MetricAffectingSpan 而不是 ForegroundColorSpan

import android.text.TextPaint;
import android.text.style.MetricAffectingSpan;

public class BGColorSpan extends MetricAffectingSpan {

    private int color;

    public BGColorSpan(int color) {
        this.color = color;
    }

    @Override
    public void updateMeasureState(TextPaint textPaint) {
        textPaint.setColor(color);
    }

    @Override
    public void updateDrawState(TextPaint textPaint) {
        textPaint.setColor(color);
    }
} 

好的。我试图从 android SDK 源代码中解释这个问题,在这个 for 循环中有 continue 关键字它将跳过 CharacterStyleSpan

的渲染
 final float originalX = x;
    for (int i = start, inext; i < measureLimit; i = inext) {
        TextPaint wp = mWorkPaint;
        wp.set(mPaint);

        inext = mMetricAffectingSpanSpanSet.getNextTransition(mStart + i, mStart + limit) -
                mStart;
        int mlimit = Math.min(inext, measureLimit);

        ReplacementSpan replacement = null;

        for (int j = 0; j < mMetricAffectingSpanSpanSet.numberOfSpans; j++) {
            // Both intervals [spanStarts..spanEnds] and [mStart + i..mStart + mlimit] are NOT
            // empty by construction. This special case in getSpans() explains the >= & <= tests
            if ((mMetricAffectingSpanSpanSet.spanStarts[j] >= mStart + mlimit) ||
                    (mMetricAffectingSpanSpanSet.spanEnds[j] <= mStart + i)) continue;
            MetricAffectingSpan span = mMetricAffectingSpanSpanSet.spans[j];
            if (span instanceof ReplacementSpan) {
                replacement = (ReplacementSpan)span;
            } else {
                // We might have a replacement that uses the draw
                // state, otherwise measure state would suffice.
                span.updateDrawState(wp);
            }
        }

        if (replacement != null) {
            x += handleReplacement(replacement, wp, i, mlimit, runIsRtl, c, x, top, y,
                    bottom, fmi, needWidth || mlimit < measureLimit);

            // I think this line making your issue
            continue;
        }

        for (int j = i, jnext; j < mlimit; j = jnext) {
            jnext = mCharacterStyleSpanSet.getNextTransition(mStart + j, mStart + inext) -
                    mStart;
            int offset = Math.min(jnext, mlimit);

            wp.set(mPaint);
            for (int k = 0; k < mCharacterStyleSpanSet.numberOfSpans; k++) {
                // Intentionally using >= and <= as explained above
                if ((mCharacterStyleSpanSet.spanStarts[k] >= mStart + offset) ||
                        (mCharacterStyleSpanSet.spanEnds[k] <= mStart + j)) continue;

                CharacterStyle span = mCharacterStyleSpanSet.spans[k];
                span.updateDrawState(wp);
            }

            // Only draw hyphen on last run in line
            if (jnext < mLen) {
                wp.setHyphenEdit(0);
            }
            x += handleText(wp, j, jnext, i, inext, runIsRtl, c, x,
                    top, y, bottom, fmi, needWidth || jnext < measureLimit, offset);
        }
    }