给你的 Android App 添加自定义表情

2,645 阅读5分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第2篇文章,点击查看活动详情

上一篇文章 Android Span 原理解析 介绍了 Span 的原理。这一篇文章将介绍 Span 的应用,使用 Span 来给 App 添加自定义表情。

原理

添加自定义表情的原理其实很简单,就是使用 ImageSpan 对文字进行替换。代码如下:

ImageSpan imageSpan = new ImageSpan(this, R.drawable.emoji_kelian);
SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder("哈哈哈哈[可怜]");
spannableStringBuilder.setSpan(imageSpan, 4, spannableStringBuilder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
textView.setText(spannableStringBuilder);

上面的代码把 [可怜] 文字替换成了对应的表情图片。效果如下图,可以看到图片的大小不符合预期,这是因为 ImageSpan 会显示成图片原来的大小。

image.png

ImageSpan 的继承关系图如下,出现了 ReplacementSpanDynamicDrawableSpan 两个新的类,先来看一下它们。MetricAffectingSpanCharacterStyle 接口在 Android Span 原理解析 介绍了,这里就不赘述了。

image.png

ReplacementSpan 接口

ReplacementSpan 是一个接口,看名字是用来替换文字的。它里面定义了两个方法,如下所示。


public abstract int getSize(@NonNull Paint paint,
                                    CharSequence text,
                                    @IntRange(from = 0) int start,
                                    @IntRange(from = 0) int end,
                                    @Nullable Paint.FontMetricsInt fm);

返回替换后 Span 的宽,上面的例子中就是返回图片的宽度,参数作用如下:

  • paint: Paint 的实例
  • text: 当前文本,上面的例子中它的值是是 哈哈哈哈[可怜]
  • start: Span 的开始位置,这里是 4
  • end: Span 的结束位置,这里是 8
  • fm: FontMetricsInt 的实例

FontMetricsInt 是描述给定文本大小的字体的各种度量的类。内部属性代表的含义如下图:

  • Top:图中紫线的位置
  • Ascent: 图中绿线的位置
  • Descent: 图中蓝线的位置
  • Bottom: 图中黄线的位置
  • Leading: 未在图中标出,是指上一行的 Bottom 与下一行的 Top 之间的距离。

图片来源 Meaning of top, ascent, baseline, descent, bottom, and leading in Android's FontMetrics

image.png

Baseline 是文字绘制的基准线。它不定义在 FontMetricsInt 中,但可以通过 FontMetricsInt 的属性获取。

上面讲到 getSize 方法只返回宽度,那高度是怎么确定的呢?其实它是通过 FontMetricsInt 来控制,不过这里有个坑,后面会说到。


public abstract void draw(@NonNull Canvas canvas,
                                    CharSequence text,
                                    @IntRange(from = 0) int start,
                                    @IntRange(from = 0) int end,
                                    float x,
                                    int top,
                                    int y,
                                    int bottom,
                                    @NonNull Paint paint);

在 Canvas 中绘制 Span。参数如下:

  • canvas:Canvas 实例
  • text:当前文本
  • start:Span 的开始位置
  • end:Span 的结束位置
  • x:[可怜] 的 x 坐标位置
  • top:当前行的 “Top“ 属性值
  • y:当前行的 Baseline
  • bottom: 当前行的 ”Bottom“ 属性值
  • paint:Paint 实例,可能为 null

这里需要特殊注意 TopBottom,跟上面说的有点不同这里先记住,后面会一起介绍。

DynamicDrawableSpan

DynamicDrawableSpan 实现了 ReplacementSpan 接口的方法。同时它是一个抽象类,定义了 getDrawable 抽象方法,由 ImageSpan 实现来获取 Drawable 实例。源码如下:

@Override

public int getSize(@NonNull Paint paint, CharSequence text,
    @IntRange(from = 0) int start, @IntRange(from = 0) int end,
    @Nullable Paint.FontMetricsInt fm) {
    
    Drawable d = getCachedDrawable();
    Rect rect = d.getBounds();

    //设置图片的高
    if (fm != null) {
    fm.ascent = -rect.bottom;
    fm.descent = 0;
    fm.top = fm.ascent;
    fm.bottom = 0;
    }
    return rect.right;
}

@Override

public void draw(@NonNull Canvas canvas, CharSequence text,
    @IntRange(from = 0) int start, @IntRange(from = 0) int end, float x,
    int top, int y, int bottom, @NonNull Paint paint) {

    Drawable b = getCachedDrawable();
    canvas.save();

    int transY = bottom - b.getBounds().bottom;
    //设置对齐方式,有三种分别是
    //ALIGN_BOTTOM 底部对齐,默认
    //ALIGN_BASELINE 基线对齐
    //ALIGN_CENTER 居中对齐
    if (mVerticalAlignment == ALIGN_BASELINE) {
        transY -= paint.getFontMetricsInt().descent;
    } else if (mVerticalAlignment == ALIGN_CENTER) {
        transY = top + (bottom - top) / 2 - b.getBounds().height() / 2;
    }

    canvas.translate(x, transY);
    b.draw(canvas);
    canvas.restore();
}

public abstract Drawable getDrawable();

DynamicDrawableSpan 有两个坑需要特别注意。

第一个坑就是在 getSize 中的 Paint.FontMetricsInt 对象和 draw 方法中通过 paint.getFontMetricsInt() 获取的不是一个对象。也就是说,无论我们在 getSizePaint.FontMetricsInt 中设置什么值,都不会影响到 paint.getFontMetricsInt() 获取对象中的值。它影响的是 topbottom 的值,这也是刚才介绍参数时给 Top 和 Bottom 打引号的原因。

第二个坑是 ALIGN_CENTER图片大小超过文字大小时“不起作用”。如下图所示,为了方便显示我加了辅助线,白线是代表参数 top,bottom,但是 bottom 被其它颜色覆盖了。可以看到,图片是居中的,是文字没有居中让我们看上去 ALIGN_CENTER 没有效果一样。

image.png

去掉辅助线后,看上去更明显一些。

image.png

ImageSpan

ImageSpan 就简单多了,它只实现了 getDrawable() 方法来获取 Drawable 实例,代码如下:

@Override
public Drawable getDrawable() {

    Drawable drawable = null;
    if (mDrawable != null) {

        drawable = mDrawable;

    } else if (mContentUri != null) {

        Bitmap bitmap = null;
        try {
            InputStream is = mContext.getContentResolver().openInputStream(
            mContentUri);
            bitmap = BitmapFactory.decodeStream(is);
            drawable = new BitmapDrawable(mContext.getResources(), bitmap);
            drawable.setBounds(0, 0, drawable.getIntrinsicWidth(),
            drawable.getIntrinsicHeight());
            is.close();
        } catch (Exception e) {
            Log.e("ImageSpan", "Failed to loaded content " + mContentUri, e);
        }

    } else {
        try {
            drawable = mContext.getDrawable(mResourceId);
            drawable.setBounds(0, 0, drawable.getIntrinsicWidth(),
            drawable.getIntrinsicHeight());
        } catch (Exception e) {
            Log.e("ImageSpan", "Unable to find resource: " + mResourceId);
        }
    }
    return drawable;
}

这里代码很简单,我们唯一需要关注的就是获取 Drawable 时,需要设置它的宽高,让它别超过文字的大小。

实现

说完前面的原理后,实现起来就非常简单了。我们只需要继承 DynamicDrawableSpan,实现 getDrawable() 方法,让图片的宽高别超过文字的大小就行了。效果如下图所示:


public class EmojiSpan extends DynamicDrawableSpan {

    @DrawableRes
    private int mResourceId;

    private Context mContext;

    private Drawable mDrawable;

    public EmojiSpan(@NonNull Context context, int resourceId) {
        this.mResourceId = resourceId;
        this.mContext = context;
    }

    @Override

    public Drawable getDrawable() {

        Drawable drawable = null;

        if (mDrawable != null) {

            drawable = mDrawable;

        } else {
            try {
                drawable = mContext.getDrawable(mResourceId);
                drawable.setBounds(0, 0, 48, 48);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return drawable;
    }
}

image.png

上面看上去很完美,但是事情没有那么简单。因为我们只是写死了图片的大小,并没有改变图片位置绘制的算法。如果其他地方使用了 EmojiSpan ,但是文字的大小小于图片大小时还是会出问题。如下图,当文字的 textsize 为 10sp 时的情况。

image.png

实际上,文字大于图片大小时也有问题。如下图所示,多行的情况下,只有表情的行间距明显小于其他行的间距。

image.png

如果大家对这个的解决办法感兴趣的话,点赞+收藏数 >= 40,我就复刻一下B站的自定义表情,加上会动的自定义表情(实际上是 Gif 图)。

参考