這一篇文章将介紹 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 會顯示成圖檔原來的大小。
ImageSpan 的繼承關系圖如下,出現了 ReplacementSpan 和 DynamicDrawableSpan 兩個新的類,先來看一下它們。MetricAffectingSpan 和 CharacterStyle 接口在 介紹了,這裡就不贅述了。
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 之間的距離。
圖檔來源
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
這裡需要特殊注意 Top 和 Bottom,跟上面說的有點不同這裡先記住,後面會一起介紹。
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() 擷取的不是一個對象。也就是說,無論我們在 getSize 的 Paint.FontMetricsInt 中設定什麼值,都不會影響到 paint.getFontMetricsInt() 擷取對象中的值。它影響的是 top 和 bottom 的值,這也是剛才介紹參數時給 Top 和 Bottom 打引号的原因。
第二個坑是 ALIGN_CENTER 在圖檔大小超過文字大小時“不起作用”。如下圖所示,為了友善顯示我加了輔助線,白線是代表參數 top,bottom,但是 bottom 被其它顔色覆寫了。可以看到,圖檔是居中的,是文字沒有居中讓我們看上去 ALIGN_CENTER 沒有效果一樣。
去掉輔助線後,看上去更明顯一些。
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;
}
}
上面看上去很完美,但是事情沒有那麼簡單。因為我們隻是寫死了圖檔的大小,并沒有改變圖檔位置繪制的算法。如果其他地方使用了 EmojiSpan ,但是文字的大小小于圖檔大小時還是會出問題。如下圖,當文字的 textsize 為 10sp 時的情況。
實際上,文字大于圖檔大小時也有問題。如下圖所示,多行的情況下,隻有表情的行間距明顯小于其他行的間距。
參考
●