0%

文字效果(Spannable)

基本原理

首先来看BabushkaText库所达到的基本效果,使用方法如下

1
2
3
4
5
BabushkaText babushka = (BabushkaText)findViewById(R.id.babushka_text);
babushka.addPiece(new BabushkaText.Piece.Builder("Central Park, NY\n")
.textColor(Color.parseColor("#414141"))
.textSizeRelative(0.9f)
.build());

效果图示如下

其核心在于使用 android.text 包内的 Spannable 接口,这里以它的父接口 Editable 来阐述。

Editable 接口继承 CharSequence(表示字符序列借口),GetChars(获取字符),Appendable(连接字符),Spanned (装饰字符序列)以及 Spannable(添加和移除字符序列装饰),可谓字符处理接口的集大成者。

当 TextView 接收 Spannable 字符时,将利用它提供的装饰效果, 改变文本格式,该方法如下

1
2
3
4
5
//注意这里的参数是 Object,具体实现上使用 Object 提供改变文本绘制效果的信息。 
public void setSpan(Object what, int start, int end, int flags);
// SPAN_EXCLUSIVE_INCLUSIVE 表示所选中的字符序列是开闭的, StrikethroughSpan 表示给字符序列添加中划线,
Editable editable = Editable.Factory.getInstance().newEditable("liuxiangtian");
editable.setSpan(new StrikethroughSpan(), 0, 6, Spannable.SPAN_EXCLUSIVE_INCLUSIVE);

我们所熟悉的 Html 类实际上就是通过解析 html 文本中的标签,将其转换为带 Span 效果的 Spannable 对象。

android.text 包下存在各种 Span 类,可以为字符序列添加各种效果,如BackgroundColorSpan 添加背景色,ForegroundColorSpan 改变字体颜色等等。

回到 BabushkaText 库的实现上,它通过收集各个字符段落的绘制信息,并为各个段落设置 Span,最终合成为整体 Spannable 对象。

BabushkaText 库实际上并不推荐使用,功能上单一,且不易扩展,继承 TextView 的做法也显得多余。

各种 Span 效果的实现原理

那么这些 Span 效果是如何实现的呢?

实现各种 Span 效果需要继承和实现如下接口,其本质是改变画笔 TextPaint 的属性

1
2
3
public abstract class CharacterStyle {
public abstract void updateDrawState(TextPaint tp);
}

以绘制背景 BackgroundColorSpan 的实现为例

1
2
3
4
//BackgroundColorSpan 
public void updateDrawState(TextPaint ds) {
ds.bgColor = mColor;
}

这样绘制该段文本时的画笔类将切换背景颜色,其它大多数 Span 效果无不如此,如 ScaleXSpan类的实现如下

1
ScaleXSpan ds.setTextScaleX(ds.getTextScaleX() * mProportion);

值得注意的是 URLSpan 继承 ClickableSpan,表示可以点击的URL,其实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void updateDrawState(TextPaint ds) {
ds.setColor(ds.linkColor);
ds.setUnderlineText(true);
}

@Override
public void onClick(View widget) {
Uri uri = Uri.parse(getURL());
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName());
context.startActivity(intent);
}

可见要实现点击跳转,需要继承 ClickableSpan,重写 onClick 方法。

ReplacementSpan 和 ImageSpan的利用

子类 ReplacementSpan 及其下的扩展类 DynamicDrawableSpan 是一种特殊的 Span 效果,它不满足于仅仅修改画笔属性,使用圈定绘制区域,自定代理绘制效果。

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

计算 Span 的宽度,

1
2
3
4
5
6
7
8
9
10
    /**
* @param x Edge of the replacement closest to the leading margin.
* @param top Top of the line.
* @param y Baseline.
* @param bottom Bottom of the line.
* @param paint Paint instance.
*/
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);

绘制 Span 。

子类 DynamicDrawableSpan 将绘制的内容定义为 Drawable,由 Drawable 对象提供尺寸和绘制效果,只需要你实现以下方法来提供Drawable对象即可。

1
public abstract Drawable getDrawable();

使用时利用子类 ImageSpan 即可。

ImageSpan 的实践

BabushkaText库的弱点实际上很多,在功能上它比较单一,不能实现如 AwesomeText库中圆角背景效果,

AwesomeText库正是利用 ImageSpan 来实现圆角背景效果的。

AwesomeText 的扩展性很好,你可以自定义效果

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MentionSpanRenderer implements AwesomeTextHandler.ViewSpanRenderer, AwesomeTextHandler.ViewSpanClickListener {
@Override
public View getView(String text, Context context) {
TextView view = new TextView(context);
view.setText(text.substring(1));
view.setBackgroundResource(R.drawable.round_cornor);
return view;
}
@Override
public void onClick(String text, Context context) {
Toast.makeText(context, "Hello " + text, Toast.LENGTH_SHORT).show();
}
}

在构建对应的 Spannable 对象时,从 getView 方法提供的View获得 Bitmap 对象,构建 ImageSpan ,

1
2
3
4
View view = renderer.getView(text, context);
BitmapDrawable d = (BitmapDrawable) ViewUtils.convertViewToDrawable(view);
bitmpaDrawable.setBounds(0, 0, d.getIntrinsicWidth(), d.getIntrinsicHeight());
spannableString.setSpan(new ImageSpan(bitmpaDrawable), start, end, DEFAULT_RENDER_APPLY_MODE);

这样原本的效果以 Bitmap 的方式被绘制出来。

AwesomeText 库的实现值得称道,它可以构建出任何效果,功能强大;且容易扩展,对 TextView 亦无侵入性。

ReplacementSpan 的实践

就达到圆角背景的效果而言,实际上继承 ReplacementSpan 并重写其 draw 方法 即可,亦能达到同样效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class CustomBackgroundSpan extends ReplacementSpan {
private int color;
private int corner;
public CustomBackgroundSpan(int color, int corner) {
this.color = color;
this.corner = corner;
}
@Override public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
return Math.round(paint.getTextSize());
}
@Override public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
paint.setColor(color);
RectF rect = new RectF(x , top, x + paint.measureText(text, start, end) + corner, bottom);
canvas.drawRoundRect(rect, corner, corner, paint);
paint.setColor(Color.WHITE);
canvas.drawText(text, start, end, x + corner/2, y, paint);
}
}

Spannable的其它实践 :PhraseColorPhrase

Spannable 的具体实现类是 SpannableString 和 SpannableStringBuilder,它们定义了文字的绘制方法,利用 它们做文章的库有很多。

Square 的 Sequence 库的主要目的是为了进行文本替换,使用方法如下

1
2
3
4
CharSequence formatted = Phrase.from("Hi {first_name}, you are {age} years old.")
.put("first_name", firstName)
.put("age", age)
.format();

首先根据 {}收集替换文本,将它们变为 SpannableString ,而后使用替换的方式达成效果

1
2
3
4
5
6
7
@Override void expand(SpannableStringBuilder target, Map<String, CharSequence> data) {
value = data.get(key);
int replaceFrom = getFormattedStart();
int replaceTo = replaceFrom + key.length() + 2;
//将{***}内容替换成所定义的字符
target.replace(replaceFrom, replaceTo, value);
}

ColorPhrase 的目的是更改文字颜色,其实现和Phrase类似。

这两个库都是针对特定功能的实现,它们将搜索出的文本抽象成Token对象,并组成链表的方式。

其它效果

elegant-underline

这个库的功能不是使用 Spannable 能够解决的,它要求下划线被文字截断。

它的实现其实也不复杂,求助于 Path 即可。

1.获得文字区域和路径

1
mPaint.getTextBounds(mText, 0, mText.length(), mBounds);mPaint.getTextPath(mText, 0, mText.length(), 0.0f, 0.0f, mOutline);

2.确定下划线路径

下划线路径位于文字的 baseline 位置,故而下划线路径可以确定为

1
mUnderline.addRect(mBounds.left, 3.0f * density, mBounds.right, 3.8f * density, Direction.CW);

3,.计算覆盖路径

此时唯一的问题在于计算两种路径的重叠:即文字覆盖下滑线的部分。

1
mOutline.op(mUnderline, Path.Op.INTERSECT); //此时取文字路径和下划线路径的交集

因为此时是文字覆盖下划线,故而要在下划线路径上减去重叠路径。

1
mUnderline.op(mOutline, Path.Op.DIFFERENCE); //在下划线区域减去交集

此时截取的区域是致密的,如果要将边界扩大若干 dp,可以使用以下办法获得实际 Path

1
//使用具有 stroke 宽度的 Paint ,这样路径 src 会被扩展成路径 dstpublic boolean getFillPath(Path src, Path dst) ;

区域的剪切和拼接要仔细,此处容易出错。

4最后分别绘制路径和下划线即可。

Fancybuttons

这个效果是靠View组合来完成的,实际是一个 LinearLayout,这是传统的思路,不再多说。