ClickableSpan(URLSpan)的长按处理

给TextView设置了OnLongClickListener后,如果TextView中同时有ClickableSpan(一般为URLSpan),此时长按TextView,如果长按的位置在ClickableSpan上,会发现同时触发了OnLongClickListener和ClickableSpan的onClick。比如下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
final TextView tv = (TextView) findViewById(R.id.txt);
String url = "http://www.baidu.com";
URLSpan span = new URLSpan(url);
String content = url + "Test";
SpannableString ss = new SpannableString(content);
ss.setSpan(span, 0, url.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
tv.setText(ss);
tv.setMovementMethod(LinkMovementMethod.getInstance());
tv.setOnLongClickListener(new OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
Toast.makeText(getApplicationContext(), "OnLongClick", Toast.LENGTH_SHORT).show();
return true;
}
});

当在Url上长按时,会同时弹出Toast并打开指定的网页。为什么这样?当然还要从源码中查找,看下TextView的onTouchEvent函数。

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
@Override
public boolean onTouchEvent(MotionEvent event) {
final int action = event.getActionMasked();
if (mEditor != null) mEditor.onTouchEvent(event);
final boolean superResult = super.onTouchEvent(event); // 1
if (mEditor != null && mEditor.mDiscardNextActionUp && action == MotionEvent.ACTION_UP) {
mEditor.mDiscardNextActionUp = false;
return superResult;
}
final boolean touchIsFinished = (action == MotionEvent.ACTION_UP) &&
(mEditor == null || !mEditor.mIgnoreActionUpEvent) && isFocused();
if ((mMovement != null || onCheckIsTextEditor()) && isEnabled()
&& mText instanceof Spannable && mLayout != null) {
boolean handled = false;
if (mMovement != null) { // 2
handled |= mMovement.onTouchEvent(this, (Spannable) mText, event);
}
...
if (handled) {
return true;
}
}
return superResult;
}

在上面标注的第一步中,调用super.onTouchEvent,会处理长按事件。在接下来的第二步中,判断mMovement是否等于null,如果不为null,无论第一步中返回的结果是true还是false,都会调用mMovementonTouchEvent,Shit,问题就在这里了。而LinkMovementMethodonTouchEvent是这样的

1
2
3
4
5
6
if (action == MotionEvent.ACTION_UP) {
link[0].onClick(widget);
} else if (action == MotionEvent.ACTION_DOWN) {
Selection.setSelection(buffer, buffer.getSpanStart(link[0]), buffer.getSpanEnd(link[0]));
}

省略了次要部分,在ACTION_UP时就调用linkClickableSpan的onClick了,Holy Shit。

问题找到了,那么怎么解决?从上面代码中看到,在进入第二步前有一个很长的if条件判断,破坏这个条件就行了,所以可以在OnLongClickListnersetEnabled(false),在ACTION_UP中再setEnabled(true),但什么时候调用setEnabled(true)?可以设置OnTouchListener,在ACTION_UP时post一个Runnable来设置,不过个人不喜欢这个方法。

另一个办法比较取巧,你要调用onClick就调用吧,我判断一下如果触发了长按就在onClick中什么都不做不就行了,这就要求继承ClickableSpan。那么判断的方法呢?最简单的方法就是通过View.setTag,如下所示

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
tv.setOnLongClickListener(new OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
Toast.makeText(getApplicationContext(), "onLongClick", Toast.LENGTH_SHORT).show();
v.setTag("Test");
return true;
}
});
private static class NonLongClickableUrlSpan extends URLSpan {
public NonLongClickableUrlSpan(String url) {
super(url);
}
@Override
public void onClick(View widget) {
if (widget.getTag() != null) {
widget.setTag(null);
return;
}
super.onClick(widget);
}
}

直接通过tag判断,这个TextView的Tag已经用做其他用途了?没事,setTag还有一个重载版本:setTag(int, Object)

主要问题已经解决了,但可能还会遇到一点小问题,对于URLSpan,点击时后有个背景,有时松手后这个背景并不消失,解决方法也很简单,手动让它消失就行了,在onLongClick中添加以下代码

1
2
3
4
5
6
7
8
9
10
11
12
tv.setOnLongClickListener(new OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
...
TextView txt = (TextView) v;
Spannable span = (Spannable) txt.getText();
Selection.removeSelection(span);
return true;
}
});

前面说了简单针对TextView的情况,再来看一下其他情况,比如说ListView,假设它是一个聊天信息列表,每一项包含一个头像和一个消息内容,我们可能希望给这个消息内容加个长按事件,而这个长按事件的范围不包括头像,所以不能使用setOnItemLongClickListener,只能给这个消息内容的布局设置OnLongClickListener。这时可能会遇到一个问题:

如果消息内容中有一个TextView包含URLSpan,当长按时点击的位置在这个URLSpan上时,可能不会触发外层Layout的长按事件,这个TextView拦截了焦点。

这时可以给这个TextView也设置一个OnLongClickListener,URLSpan的长按处理和前面讲的一样,而长按事件的处理,可以简单地调用外层Layout的performLongClick()方法。

如果这个外层布局设置了Selector Background,在长按包含URLSpan的TextView时外层Layout的背景是不会改变的,可以在这个Layout中添加android:addStatesFromChildren="true"属性解决。