一、前言
在Android中,可以使用强大的标记(Span)对象来实现富文本展示,相比 HTML 而言更高效实用。关于 Android Span 的入门篇可以阅读 Android中强大的标记对象-Span。本文将对 ClickableSpan
(可点击的Span)展开深入的学习。
二、基本使用
查看Android Doc 文档可以知道,ClickableSpan
是一个抽象类,它有两个子类,分别是 URLSpan
和 TextLinks.TextLinkSpan
(从 API Level 28 开始支持),对于这两个类的使用,这里不做详细讲解,我们主要讲解下如何通过继承 ClickableSpan
实现可点击的标记。
2.1 ClicableSpan
源码剖析
首先,我们先来看看 ClickableSpan
抽象类的源码:
public abstract class ClickableSpan extends CharacterStyle implements UpdateAppearance {
private static int sIdCounter = 0;
private int mId = sIdCounter++;
/**
* Performs the click action associated with this span.
*/
public abstract void onClick(@NonNull View widget);
/**
* Makes the text underlined and in the link color.
*/
@Override
public void updateDrawState(@NonNull TextPaint ds) {
ds.setColor(ds.linkColor);
ds.setUnderlineText(true);
}
/**
* Get the unique ID for this span.
*
* @return The unique ID.
* @hide
*/
public int getId() {
return mId;
}
}
从上面的源码来看,ClickableSpan
抽象类非常简单,继承该类需要重写的方法也是比较少,其中抽象方法 onClick()
是必须实现,下面讲解重写方法所能实现的效果:
public abstract void onClick(@NonNull View widget)
:抽象方法,必须实现。用以相应可点击标记被点击时的事件相应处理。public void updateDrawState(@NonNull TextPaint ds)
:配置绘制参数,可以用来更改绘制样式,比如文字颜色、背景颜色、链接颜色、是否包含下划线等等。如果不重载此方法,将会使用默认的绘制样式。
2.2 自定义 ClickableSpan
从前面的源码我们了解到 ClickableSpan
的成员方法,实现自己的自定义 ClickableSpan
就非常容易了:
/**
* 自定义 ClickableSpan
* @param textColor 可点击标记文字颜色
* @param clickListener 点击时间监听
*/
class CSClickableSpan (@param:ColorInt private val textColor: Int,
private val clickListener: View.OnClickListener?) : ClickableSpan() {
override fun onClick(widget: View) {
clickListener?.onClick(widget)
}
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.color = textColor // 字体颜色(前景色)
ds.bgColor = Color.TRANSPARENT // 背景颜色
ds.linkColor = textColor // 链接颜色
ds.isUnderlineText = false // 是否显示下划线
// 这里还可以配置其他绘制样式,比如下划线的粗细(如果启用下划线)、字体等等
}
}
2.3 使用自定义的 ClickableSpan
接下来就可以在 SpannableString
或者 SpannableStringBuilder
中使用自定义的 CSClickableSpan
类。
val tvNormal = findViewById<TextView>(R.id.tv_normal_clickable_span)
// 必须设置 TextView 的 movementMethod 为 LinkMovementMethod,否则标记无法响应点击事件
tvNormal.movementMethod = LinkMovementMethod.getInstance()
tvNormal.setText(SpannableString("我是普通的ClickableSpan").apply {
setSpan(CSClickableSpan(Color.BLUE, View.OnClickListener {
Toast.makeText(this@ClickableSpanActivity, tvNormal.text, Toast.LENGTH_SHORT).show()
}), 5, 18, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
})
注意事项:在
TextView
中使用ClickableSpan
时,必须要设置TextView
对象的movementMethod
属性为LinkMovementMethod
,否则ClickableSpan
标记不会响应点击事件。
运行之后,可以看看效果。
- 点击前
- 点击后
根据上面的例子,我们会发现标记点击后,会有一个背景色,其实这个背景色是TextView
的高亮颜色,因为LinkMovementMethod
在标记点击后,会选中标记部分文本。解决这个问题也很简单,只要将TextView
的highlightColor
设置为透明即可,如下示例:
val tvNormalNoSelection = findViewById<TextView>(R.id.tv_normal_clickable_span_no_selection)
// 将 TextView 的高两色设置为透明,可去除点击后的选择高亮色
tvNormalNoSelection.highlightColor = Color.TRANSPARENT
// 必须设置 TextView 的 movementMethod 为 LinkMovementMethod,否则标记无法响应点击事件
tvNormalNoSelection.movementMethod = LinkMovementMethod.getInstance()
tvNormalNoSelection.setText(SpannableString("我是普通的ClickableSpan(无选中背景)").apply {
setSpan(CSClickableSpan(Color.BLUE, View.OnClickListener {
Toast.makeText(this@ClickableSpanActivity, tvNormalNoSelection.text, Toast.LENGTH_SHORT).show()
}), 5, 18, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
})
运行之后看效果,点击标记之后选中高亮色为透明,看起来就是没有高两色的效果,如下图:
三、高手进阶
3.1 在 ClickableSpan
中实现点击效果
在前面篇幅中,虽然可以去掉标记选中高亮色,但是这样也并不完美,没有点击效果,用户体验还是有所欠缺。我们首先会想到用TextView
的高亮色,然而高亮色只能设置整型的颜色值,并不能设置ColorList
。于是就猜想通过 TextView
的高亮色结合自定义的 CSClickableSpan
实现,笔者刚开始也是从这个角度着手,预想将高亮色设置成按下状态颜色,然后再将高亮色设置为透明色,后来发现这样无法实现,因为 ClickableSpan
这个过程中,会在 onClick()
方法调用之前,前后均会调用 updateDrawState()
更新绘制文本,在如此的调用逻辑下,这种方案是不可行的。=既然无法从 TextView
下手,在示例代码中,我们唯一能寄予希望的就是 TextView
的 movementMethod
属性了(也就是 LinkMovementMethod
)。
LinkMovementMethod
类源码剖析
package android.text.method;
import android.annotation.UnsupportedAppUsage;
import android.os.Build;
import android.text.Layout;
import android.text.NoCopySpan;
import android.text.Selection;
import android.text.Spannable;
import android.text.style.ClickableSpan;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.textclassifier.TextLinks.TextLinkSpan;
import android.widget.TextView;
/**
* A movement method that traverses links in the text buffer and scrolls if necessary.
* Supports clicking on links with DPad Center or Enter.
*/
public class LinkMovementMethod extends ScrollingMovementMethod {
private static final int CLICK = 1;
private static final int UP = 2;
private static final int DOWN = 3;
private static final int HIDE_FLOATING_TOOLBAR_DELAY_MS = 200;
@Override
public boolean canSelectArbitrarily() {
return true;
}
@Override
protected boolean handleMovementKey(TextView widget, Spannable buffer, int keyCode,
int movementMetaState, KeyEvent event) {
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_CENTER:
case KeyEvent.KEYCODE_ENTER:
if (KeyEvent.metaStateHasNoModifiers(movementMetaState)) {
if (event.getAction() == KeyEvent.ACTION_DOWN &&
event.getRepeatCount() == 0 && action(CLICK, widget, buffer)) {
return true;
}
}
break;
}
return super.handleMovementKey(widget, buffer, keyCode, movementMetaState, event);
}
@Override
protected boolean up(TextView widget, Spannable buffer) {
if (action(UP, widget, buffer)) {
return true;
}
return super.up(widget, buffer);
}
@Override
protected boolean down(TextView widget, Spannable buffer) {
if (action(DOWN, widget, buffer)) {
return true;
}
return super.down(widget, buffer);
}
@Override
protected boolean left(TextView widget, Spannable buffer) {
if (action(UP, widget, buffer)) {
return true;
}
return super.left(widget, buffer);
}
@Override
protected boolean right(TextView widget, Spannable buffer) {
if (action(DOWN, widget, buffer)) {
return true;
}
return super.right(widget, buffer);
}
private boolean action(int what, TextView widget, Spannable buffer) {
Layout layout = widget.getLayout();
int padding = widget.getTotalPaddingTop() +
widget.getTotalPaddingBottom();
int areaTop = widget.getScrollY();
int areaBot = areaTop + widget.getHeight() - padding;
int lineTop = layout.getLineForVertical(areaTop);
int lineBot = layout.getLineForVertical(areaBot);
int first = layout.getLineStart(lineTop);
int last = layout.getLineEnd(lineBot);
ClickableSpan[] candidates = buffer.getSpans(first, last, ClickableSpan.class);
int a = Selection.getSelectionStart(buffer);
int b = Selection.getSelectionEnd(buffer);
int selStart = Math.min(a, b);
int selEnd = Math.max(a, b);
if (selStart < 0) {
if (buffer.getSpanStart(FROM_BELOW) >= 0) {
selStart = selEnd = buffer.length();
}
}
if (selStart > last)
selStart = selEnd = Integer.MAX_VALUE;
if (selEnd < first)
selStart = selEnd = -1;
switch (what) {
case CLICK:
if (selStart == selEnd) {
return false;
}
ClickableSpan[] links = buffer.getSpans(selStart, selEnd, ClickableSpan.class);
if (links.length != 1) {
return false;
}
ClickableSpan link = links[0];
if (link instanceof TextLinkSpan) {
((TextLinkSpan) link).onClick(widget, TextLinkSpan.INVOCATION_METHOD_KEYBOARD);
} else {
link.onClick(widget);
}
break;
case UP:
int bestStart, bestEnd;
bestStart = -1;
bestEnd = -1;
for (int i = 0; i < candidates.length; i++) {
int end = buffer.getSpanEnd(candidates[i]);
if (end < selEnd || selStart == selEnd) {
if (end > bestEnd) {
bestStart = buffer.getSpanStart(candidates[i]);
bestEnd = end;
}
}
}
if (bestStart >= 0) {
Selection.setSelection(buffer, bestEnd, bestStart);
return true;
}
break;
case DOWN:
bestStart = Integer.MAX_VALUE;
bestEnd = Integer.MAX_VALUE;
for (int i = 0; i < candidates.length; i++) {
int start = buffer.getSpanStart(candidates[i]);
if (start > selStart || selStart == selEnd) {
if (start < bestStart) {
bestStart = start;
bestEnd = buffer.getSpanEnd(candidates[i]);
}
}
}
if (bestEnd < Integer.MAX_VALUE) {
Selection.setSelection(buffer, bestStart, bestEnd);
return true;
}
break;
}
return false;
}
@Override
public boolean onTouchEvent(TextView widget, Spannable buffer,
MotionEvent event) {
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);
ClickableSpan[] links = buffer.getSpans(off, off, ClickableSpan.class);
if (links.length != 0) {
ClickableSpan link = links[0];
if (action == MotionEvent.ACTION_UP) {
if (link instanceof TextLinkSpan) {
((TextLinkSpan) link).onClick(
widget, TextLinkSpan.INVOCATION_METHOD_TOUCH);
} else {
link.onClick(widget);
}
} else if (action == MotionEvent.ACTION_DOWN) {
if (widget.getContext().getApplicationInfo().targetSdkVersion
>= Build.VERSION_CODES.P) {
// Selection change will reposition the toolbar. Hide it for a few ms for a
// smoother transition.
widget.hideFloatingToolbar(HIDE_FLOATING_TOOLBAR_DELAY_MS);
}
Selection.setSelection(buffer,
buffer.getSpanStart(link),
buffer.getSpanEnd(link));
}
return true;
} else {
Selection.removeSelection(buffer);
}
}
return super.onTouchEvent(widget, buffer, event);
}
@Override
public void initialize(TextView widget, Spannable text) {
Selection.removeSelection(text);
text.removeSpan(FROM_BELOW);
}
@Override
public void onTakeFocus(TextView view, Spannable text, int dir) {
Selection.removeSelection(text);
if ((dir & View.FOCUS_BACKWARD) != 0) {
text.setSpan(FROM_BELOW, 0, 0, Spannable.SPAN_POINT_POINT);
} else {
text.removeSpan(FROM_BELOW);
}
}
public static MovementMethod getInstance() {
if (sInstance == null)
sInstance = new LinkMovementMethod();
return sInstance;
}
@UnsupportedAppUsage
private static LinkMovementMethod sInstance;
private static Object FROM_BELOW = new NoCopySpan.Concrete();
}
源码有点多,但是我们的目标是实现点击效果,那么肯定跟触摸事件相关,所以,我们需要处理的也就是 onTouchEvent()
方法。接下来,我们通过继承 LinkMovementMethod
来自定义一个MovementMethod
类,在onTouchEvent()
方法中的 MotionEvent.ACTION_DOWN
和 MotionEvent.ACTION_UP
事件中添加处理逻辑。
/**
* 可点击标记 MovementMethod
* @param clickedBgColor 按下背景颜色
*/
class ClickableSpanMovementMethod(@ColorInt val clickedBgColor: Int) : LinkMovementMethod() {
override fun onTouchEvent(widget: TextView?, buffer: Spannable?, event: MotionEvent?): Boolean {
if(null == event || null == widget || null == buffer) {
return false
}
val action = event.action
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
var x = event.x.toInt()
var y = event.y.toInt()
x -= widget.totalPaddingLeft
y -= widget.totalPaddingTop
x += widget.scrollX
y += widget.scrollY
val layout = widget.layout
val line = layout.getLineForVertical(y)
val off = layout.getOffsetForHorizontal(line, x.toFloat())
val links = buffer!!.getSpans(off, off, ClickableSpan::class.java)
if (links.isNotEmpty()) {
val link = links[0]
if (action == MotionEvent.ACTION_UP) {
// ACTION_UP 移除选中
Selection.removeSelection(buffer)
link.onClick(widget)
} else if (action == MotionEvent.ACTION_DOWN) {
// ACTION_DOWN 设置高亮色为点击色,并选中标记
widget.highlightColor = clickedBgColor
Selection.setSelection(
buffer,
buffer.getSpanStart(link),
buffer.getSpanEnd(link)
)
}
return true
} else {
Selection.removeSelection(buffer)
}
}
return super.onTouchEvent(widget, buffer, event)
}
}
然后将 TextView
的 movementMethod
属性值设置为自定义的 MovementMethod
实例对象即可:
val tvStyle = findViewById<TextView>(R.id.tv_clickstyle_clickable_span)
tvStyle.movementMethod = ClickableSpanMovementMethod(Color.argb(0x20, 0x33, 0x33, 0x33))
tvStyle.setText(SpannableString("我是带点击效果的ClickableSpan").apply {
setSpan(CSClickableSpan(Color.BLUE, View.OnClickListener {
Toast.makeText(this@ClickableSpanActivity, tvStyle.text, Toast.LENGTH_SHORT).show()
}), 8, 21, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
})
- 实现效果
至此,已经完美实现ClickableSpan
点击效果。上面的示例是通过改变选中高亮色来实现的,下面是通过给ClickableSpan
重叠一个BackgroundColorSpan
的实现方案,效果完全一致,代码如下所示:
/**
* 可点击标记 MovementMethod
* @param clickedBgColor 按下背景颜色
*/
class ClickableSpanMovementMethod(@ColorInt val clickedBgColor: Int) : LinkMovementMethod() {
override fun onTouchEvent(widget: TextView?, buffer: Spannable?, event: MotionEvent?): Boolean {
if(null == event || null == widget || null == buffer) {
return false
}
val action = event.action
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
var x = event.x.toInt()
var y = event.y.toInt()
x -= widget.totalPaddingLeft
y -= widget.totalPaddingTop
x += widget.scrollX
y += widget.scrollY
val layout = widget.layout
val line = layout.getLineForVertical(y)
val off = layout.getOffsetForHorizontal(line, x.toFloat())
val links = buffer!!.getSpans(off, off, ClickableSpan::class.java)
if (links.isNotEmpty()) {
val link = links[0]
if (action == MotionEvent.ACTION_UP) {
// ACTION_UP 给当前标记添加一个透明色的背景Span
buffer.setSpan(
BackgroundColorSpan(Color.TRANSPARENT), buffer.getSpanStart(link),
buffer.getSpanEnd(link), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
// 移除选中(如果将TextView高亮色设置为透明,可忽略此行代码)
Selection.removeSelection(buffer)
link.onClick(widget)
} else if (action == MotionEvent.ACTION_DOWN) {
// ACTION_DOWN 给当前标记添加一个点击色的背景Span
buffer.setSpan(BackgroundColorSpan(clickedBgColor), buffer.getSpanStart(link),
buffer.getSpanEnd(link), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
// 移除选中(如果将TextView高亮色设置为透明,可忽略此行代码)
Selection.removeSelection(buffer)
}
return true
} else {
Selection.removeSelection(buffer)
}
}
// return false
return super.onTouchEvent(widget, buffer, event)
}
}