ClickableSpan

ClickableSpan 用来实现 TextView里的文字局部的高亮和点击事件。

介绍:

If an object of this type is attached to the text of a TextView with a movement method of LinkMovementMethod, the affected spans of text can be selected. If selected and clicked, the {@link #onClick} method will* be called.

意思是这东西加到TextView上,并设置LinkMovementMethod,就可以选择或点击并回调onClick方法。
源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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;
}
}

源码比较简单,就是能改变文字样式的同时有个onClick抽象方法。

遇到问题

问题:
使用中,我们经常在vm层(vm里或者 vm的辅助逻辑类里)设置数据(比如SpannableString),如果设置的是ClickableSpan。设置样式外,还需要实现onClick方法,即点击事件。然而点击事件往往是UI层的逻辑。一般不允许在vm层写点击事件逻辑。向 vm里传点击事件(往往是内部类会持有fragment),不是很可取。
目标:
我希望vm层只对数据的设置,UI层设置点击事件。

方案:
定义一个可以设置事件,并携带数据的 ClickableSpan。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class DataClickSpan(@ColorInt val color: Int) : ClickableSpan() {
val map = HashMap<String, Any?>()
var listener: OnClickListener? = null
interface OnClickListener {
fun onSpanClick(widget: View, map: HashMap<String, Any?>)
}
override fun onClick(widget: View) {
listener?.onSpanClick(widget, map)
}
override fun updateDrawState(ds: TextPaint) {
//设置颜色
ds.color = color
//去掉下划线
ds.isUnderlineText = false
}
}
/**
* 设置点击事件。
*/
fun Spanned.setDataClickListener(listener: DataClickSpan.OnClickListener?) {
getSpans(0, this.length - 1, DataClickSpan::class.java)
.forEach { it.listener = listener }
}
再整一个BindingAdapter方法:
@BindingAdapter(value = ["binding_spanned_data", "binding_spanned_clickListener"], requireAll = true)
fun TextView.setSpannedClickListenerOfString(data: Spanned?, listener: DataClickSpan.OnClickListener?) {
data?.setDataClickListener(listener)
movementMethod = ClickLinkMovementMethod// 这个是自定义LinkMovementMethod
}

使用:
vm 层使用,设置携带数据:

1
2
3
4
5
6
// 携带 imAccount
SpannableString("这是可以点击的文字").apply {
setSpan(DataClickSpan(getColor(R.color.color_576B95))
.apply { map[IM_ACCOUNT] = joinGroupMsg.inviteImAccount },
1, length - 1, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}

UI 层使用,设置事件,比如这个SpannableString是设置再某个item的TextView 上。

  1. 让这个Item的 VHModel 的OnItemEventListener继承DataClickSpan.OnClickListener
  2. 再布局里设置:

    1
    2
    3
    4
    <TextView 
    binding_spanned_clickListener="@{listener}"
    binding_spanned_data="@{item.removeDesc}"
    .../>
  3. 在Fragment里实现接口:

    1
    2
    3
    4
    5
    6
    override fun onSpanClick(widget: View, map: HashMap<String, Any?>) {
    val imAccount = map[ConvertUtil.IM_ACCOUNT]
    if (imAccount is String) {
    RouterManager.goImUser(UserParams(imAccount), "ChatFragment")
    }
    }

结论:
没啥好的,就是曲折去实现分离而已。

vm 还有间接依赖View。
vm持有SpannableString,
SpannableString持有ClickableSpan,
ClickableSpan持有listener,
listener持有fragment。
emmmm….

ClickableSpan设计就是这样。那就来了解了解它的实现原理吧。

LinkMovementMethod:

ClickableSpan源码也看了,显然它不是主要关键。那是谁去调用ClickableSpan的onClick方法,怎么决定调用时机呢?

ClickableSpan文件头介绍中,已供出主谋是LinkMovementMethod(是一个单例)。

点击事件,显然离不开onTach的方法。LinkMovementMethod里正好有,那就决定是它了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@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。
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 {
// 手指抬起时回调onClick方法。
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);
}

所以LinkMovementMethod也是根据触摸的位置找出ClickableSpan(同一个位置设置多个的话,也只会执行第一个),然后回调onClick。
LinkMovementMethod是被TextView回调。
看到这里ClickableSpan的实现原理基本就清楚了。

其他方案

有个大胆的想法💡:
我先自定义只带数据和样式的span。再定义一个 MySpanListener 里面有个方法onClick(v:View,data:Data)
然后自定义LinkMovementMethod(比如叫MyMovementMethod)。同上在onTouchEvent里找出自己定义span。然后根据textView拿到listener。回调onClick(v:View,data:Data)方法。
那么问题是红字的怎么去实现(主要问题是listener,以什么维度储存,怎么储存)。比如在MyMovementMethod里设置一个弱引用的map:WeakHashMap<TextView,MySpanListener>

也是一种方法,但是看起来挺别扭。哈。。。

配一张图

另一个问题

LinkMovementMethod有个很大的问题,就是长按时。依旧会回调onClick方法。这就会出现交互伤的bug。
解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
object ClickLinkMovementMethod : LinkMovementMethod() {
private const val CLICK_DELAY = 500L
private var lastClickTime: Long = 0
override fun onTouchEvent(widget: TextView?, buffer: Spannable?, event: MotionEvent?): Boolean {
event ?: return false
widget ?: 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: Layout = widget.layout
val line: Int = layout.getLineForVertical(y)
val off: Int = layout.getOffsetForHorizontal(line, x.toFloat())
val link: Array<ClickableSpan> = buffer?.getSpans(off, off, ClickableSpan::class.java)
?: return true
if (link.isNotEmpty()) {
if (action == MotionEvent.ACTION_UP) {
if (System.currentTimeMillis() - lastClickTime < CLICK_DELAY) {
link[0].onClick(widget)
}
} else if (action == MotionEvent.ACTION_DOWN) {
lastClickTime = System.currentTimeMillis()
Selection.setSelection(buffer,
buffer.getSpanStart(link[0]),
buffer.getSpanEnd(link[0]))
}
return true
} else {
Selection.removeSelection(buffer)
}
}
return false
}
}

总结:

  1. ClickableSpan实现点击监听的原理是LinkMovementMethod。
  2. LinkMovementMethod存在长按时交互的bug。
  3. ClickableSpan的数据&事件分离依旧期望更优质的方案。