TextView 显示富文本&部分点击生效

本文主要 TextView 显示富文本&部分点击生效。

最近需求中有个功能,需要实现以下效果:

  • 一行白色文本后,跟随一个蓝色的文本。这个蓝色文本可以响应点击操作,白色文本不响应点击操作。
  • 一行白色文本后,跟随一个蓝色的图标。这个蓝色图标可以响应点击操作,白色文本不响应点击操作。

上面功能有三个关键点:

  • 文本可部分点击
  • 可显示富文本
  • 文本后可设置图标

考察了一番,最终决定使用 SpannableString 实现图文混排、文本变色,以及变色文本、图标可点击。

Android 中如果想要 TextView 显示富文本,可以使用 SpannableString,SpannableString可以设置 xxxSpan,实现不同效果的文本,以及图文混合显示。但是有几个点比较坑:

  • Android SDK 提供的 URLSpan,虽然可以点击,但是文本带有下划线。本问题中,富文本不需要下划线。故 URLSpan 不满足要求
  • Android SDK 提供的 ImageSpan,可以实现文本中显示图片,但是不能响应点击操作。也不满足要求。

最终决定自定义 Span。自定义也不太复杂。

定义点击接口

点击接口定义如下,代表着点击能力。实现了这个接口,xxxSpan 就有了响应点击操作的能力。

public interface IClickableSpan {
    /**
     * 自定义点击事件,配合 LinkMovementMethod 使用
     * */
    void onClick(View view);
}

自定义 ImageSpan

新增可点击的 ImageSpan,继承自 ImageSpan,用于显示图标。

/**
 * 抽象类,点击操作在具体的实例处实现
 */
public abstract class ClickableImageSpan extends ImageSpan 
    implements IClickableSpan {
    public ClickableImageSpan(@NonNull Drawable drawable) {
        super(drawable);
    }

    @Override
    public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, 
                       @Nullable Paint.FontMetricsInt fm) {
        Drawable drawable = getDrawable();

        if (fm != null) {
            // FontMetricsInt 不能使用方法自带的 fm,其现在没有值
            Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt();
            if(fmPaint == null) {
                return drawable.getBounds().right;
            }
            // 文字行的高度(具体字符顶部到底部的真实高度)
            int fontH = fmPaint.descent - fmPaint.ascent;
            // 图片的高度
            int imageH = drawable.getIntrinsicHeight();

            // 如果图片的高度 <= 文本的高度,放大图片
            if (imageH < fontH) {
                // 如果是小图片,需要先放大图片,防止图片过小时,影响查看
                int srcWidth = drawable.getIntrinsicWidth();
                int srcHeight = drawable.getIntrinsicHeight();
                int destHeight = fmPaint.descent - fmPaint.ascent;
                int destWidth = (int)((float)destHeight / srcHeight * srcWidth);
                drawable.setBounds(0, 0, destWidth, destHeight);
            }

            // 如果图片的高度 > 文本的高度,则调整文本位置,相对于图片居中
            fm.ascent = fmPaint.ascent - (imageH - fontH) / 2;
            fm.top = fmPaint.ascent - (imageH - fontH) / 2;
            fm.bottom = fmPaint.descent + (imageH - fontH) / 2;
            fm.descent = fmPaint.descent + (imageH - fontH) / 2;
        }
	return drawable.getBounds().right;
    }
}

自定义 TextSpan

新增可点击的 TextSpan,继承自 ForegroundColorSpan,用于改变文本的颜色。

/**
 * 抽象类,点击操作在具体的实例处实现
 */
public abstract class ClickableTextSpan extends ForegroundColorSpan 
    implements IClickableSpan {
    public ClickableTextSpan(int color) {
        super(color);
    }
}

自定义LinkMovementMethod

LinkMovementMethod 是我们在点击 Span 的时候响应,但系统原生的实现无法响应除 ClickableSpan 以外的其他 Span,故需要自定义,以放开限制。

public class ClickableMovementMethod extends LinkMovementMethod {
    /** 
     * 单例类
     */
    private static ClickableMovementMethod sInstance;

    public static ClickableMovementMethod getInstance() {
        if (sInstance == null) {
            synchronized (ClickableMovementMethod.class) {
                if(sInstance == null) {
                    sInstance = new ClickableMovementMethod();
                }
            }
        }
        return sInstance;
    }

    @Override
    public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) {
        // 以下代码参考自 LinkMovementMethod.onTouchEvent
        int action = event.getAction();
        if(action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
            int x = (int) event.getX();
            int y = (int) event.getY();

            x -= widget.getTotalPaddingLeft();
            y -= widget.getTotalPaddingTop();

            x += widget.getScrollX();
            y += widget.getScrollY();

            Layout layout = widget.getLayout();
            int line = layout.getLineForVertical(y);
            int off = layout.getOffsetForHorizontal(line, x);

            // 关键代码,新增得到 IClickableSpan,处理自定义的点击操作
            // 代码写法参考自系统实现
            IClickableSpan[] clickSpans = buffer.getSpans(off, off, IClickableSpan.class);
            if(clickSpans != null && clickSpans.length > 0) {
                IClickableSpan link = clickSpans[0];
                if (action == MotionEvent.ACTION_UP) {
                    link.onClick(widget);
                } else {
                    Selection.setSelection(buffer, buffer.getSpanStart(link), 
                                           buffer.getSpanEnd(link));
                }
                return true;
            }
            return super.onTouchEvent(widget, buffer, event);
        }
        return super.onTouchEvent(widget, buffer, event);
    }
}

使用

自定义完了上述的几个类之后,就可以使用了。

public void setView(final Info info) {
    if(info == null) {
        return;
    }
    SpannableString spannableString;
    StringBuilder srcText = new StringBuilder();
    // 添加文本
    builder.append(mContext.getString(stringResId));
    // 增加空格,防止可点击内容和提示文本靠的过于紧凑
    // 使用 Html 是因为 TextView 会对空格进行优化,多个空格时,只会显示一个空格
    builder.append(Html.fromHtml("&#160;&#160;"));
    // 文本末尾显示可点击的图标
    if(info.isIcon()) {
        // 添加一个字符 i,意为图标,最后会被替换成图片。
        // 此处添加这个字符的目的是防止数组下标越界
        srcText.append("i");
        spannableString = new SpannableString(srcText);
        Drawable drawable = mContext.getResources().getDrawable(R.drawable.icon);
        // IClickableSpan 具体实现的地方
        ClickableImageSpan imageSpan = new ClickableImageSpan(drawable) {
            @Override
            public void onClick(View view) {
                dealClickEvent(view, info);
            }
        };
        // 最后一个字符替换为图标
        spannableString.setSpan(imageSpan, srcText.length() - 1, 
                                srcText.length(), 
                                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    } else {
        // 文本末尾显示可点击的文本
        String clickText = mContext.getResources().getString(R.string.click_text);
        spannableString = new SpannableString(srcText + clickText);
        // IClickableSpan 具体实现的地方
        ClickableTextSpan textSpan = new ClickableTextSpan(Color.BLUE) {
            @Override
            public void onClick(View view) {
                dealClickEvent(view, info);
            }
        };
        spannableString.setSpan(textSpan, srcText.length(), 
                                srcText.length() + clickText.length(),
				Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }
    // 设置 TextView
    tvHint.setMovementMethod(ClickableMovementMethod.getInstance());
    tvHint.setText(spannableString);
}

至此,功能就算完成了。自测一波,效果 OK。