Android - TextView 添加ClickableSpan的故事

2,493 阅读3分钟

在Android 里面,想要实现一段文字中部分文字可以点击就可以使用ClickableSpan,大概的方式

tv = (TextView) findViewById(R.id.tv_tsm_test);
SpannableStringBuilder builder = new SpannableStringBuilder("这个是连接");
builder.setSpan(new ClickableSpan() {
    @Override
    public void onClick(@NonNull View widget) {
        tv.setBackgroundColor(Color.GREEN);
    }
}, 3, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
tv.setText(builder);
tv.setMovementMethod(LinkMovementMethod.getInstance());
tv.setAutoLinkMask(Linkify.WEB_URLS);

实现的效果如下

图片1.png

点击连接这两个字就可以回调ClickableSpan 的 onClick方法将背景变为绿色,一个非常简单的应用, 现在产品又提出需求,要求我们给这个TextView 添加一个长按的事件, 心想这么简单的,几行代码就能实现,回去修改代码

tv = (TextView) findViewById(R.id.tv_tsm_test);
SpannableStringBuilder builder = new SpannableStringBuilder("这个是连接");
builder.setSpan(new ClickableSpan() {
    @Override
    public void onClick(@NonNull View widget) {
        tv.setBackgroundColor(Color.GREEN);
    }
}, 3, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
tv.setText(builder);
tv.setMovementMethod(LinkMovementMethod.getInstance());
tv.setAutoLinkMask(Linkify.WEB_URLS);
tv.setOnLongClickListener(new View.OnLongClickListener() {
    @Override
    public boolean onLongClick(View v) {
        tv.setBackgroundColor(Color.RED);
        return true;
    }
});

发现如果这个长按事件如果是在ClickableSpan上面响应的时候,同时也会回调ClickableSpan 的onClick事件, 这种情况是由LinkMovementMethod 导致的问题,查看他的源码,在onTouch中发现问题 gif

发现如果这个长按事件如果是在ClickableSpan上面响应的时候,同时也会回调ClickableSpan 的onClick事件, 这种情况是由LinkMovementMethod 导致的问题,查看他的源码,在onTouch中发现问题

@Override
public boolean onTouchEvent(TextView widget, Spannable buffer,
                            MotionEvent event) {
    int action = event.getAction();
    if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
    ......
        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) {
               ........
            }
            return true;
        } else {
            Selection.removeSelection(buffer);
        }
    }
    return super.onTouchEvent(widget, buffer, event);
}

发现在响应事件的地方判断只要是抬起事件,不管这个事件点击还是长按,他都会响应ClickableSpan 的onClick事件,我们需要修改一下这个方法,给定一个时长,当超过这个时长,就不响应点击事件

private static final long CLICK_DELAY = 1*1000;
 
     if (link.length != 0) {
                switch (action){
                    case MotionEvent.ACTION_UP:
                        long flag=(System.currentTimeMillis() - lastClickTime);
                        if (flag< CLICK_DELAY) {
                            link[0].onClick(widget);
                        }
                        return true;
                    case MotionEvent.ACTION_DOWN:
                        Selection.setSelection(buffer,
                                buffer.getSpanStart(link[0]),
                                buffer.getSpanEnd(link[0]));
                        lastClickTime = System.currentTimeMillis();
                        return true;
                }
            } else {
                Selection.removeSelection(buffer);
            }

修改后的代码变成了这个样子,这次我们再来重新试一下,发现不会在长按的时候响ClickableSpan 的onClick事件了,你以为这样就结束了吗,并没有 产品又来了一个牛X的操作,他觉得长按复制要将所有文本都复制下来,她想要自由复制,这个长按复制体验并不好,她不想要了 在android 中想要实现TextView的复制功能其实也并不复杂,只需要将

android:textIsSelectable="true"

这个属性设置为true 就可以了,但是测试的时候发现 ,在点击ClickableSpan 的时候,会调用两次, what ? 为什么会导致这个问题,我明明在LinkMovementMethod 的onTouch 里面才刚看到过,只有一个点击事件,为什么会响应两次, 经过了一番折腾后发现在TextView onTouchEvent 里面也有相应的判断, 代码案例如下

///抬起操作,并且有焦点
  final boolean touchIsFinished = (action == MotionEvent.ACTION_UP)
                && (mEditor == null || !mEditor.mIgnoreActionUpEvent) && isFocused();
        //enable 并且text 是Spannable 
        if ((mMovement != null || onCheckIsTextEditor()) && isEnabled()
                && mText instanceof Spannable && mLayout != null) {
            boolean handled = false;
            if (mMovement != null) {
                handled |= mMovement.onTouchEvent(this, mSpannable, event);
            }
            final boolean textIsSelectable = isTextSelectable();
            ///意思是抬起操作并且有焦点 并且  链接可以被点击并且设置过setAutoLinkMask 这个属性  同时 textIsSelectable=true
            ///在所有的条件都满足的情况下,就会调用ClickableSpan 的onClick事件,
            if (touchIsFinished && mLinksClickable && mAutoLinkMask != 0 && textIsSelectable) {
                // The LinkMovementMethod which should handle taps on links has not been installed
                // on non editable text that support text selection.
                // We reproduce its behavior here to open links for these.
                ClickableSpan[] links = mSpannable.getSpans(getSelectionStart(),
                    getSelectionEnd(), ClickableSpan.class);
                if (links.length > 0) {
                    links[0].onClick(this);
                    handled = true;
                }
            }

实际项目中,由于代码是祖传的,修改的时候不能修改比较重要的属性,所以不能将原来的setAutoLinkMask(Linkify.WEB_URLS);这个属性去掉,所以只能修改这个mLinksClickable ,让他不满足情况,就不会影响我们的事件了,在实际开发过程中也可以将setMovementMethod(LinkMovementMethod.getInstance()); 这段代码移除,或者尝试移除setAutoLinkMask(Linkify.WEB_URLS); 这段代码,我选择的是

android:linksClickable="false"

在布局中添加这个属性,就可以达到我们想要的效果了,点击事件只会响应一次了, 但是在实际开发过程中,我修改的代码是在组件里面,使用这个组件的的应用有好几个,那么就要根据功能开关动态的在代码中去修改这些属性 实际项目中代码为

SpannableStringBuilder builder = SpannableUtil.addInnerLink(vh.tv, msg, color, needLinkUnderLine, new          
        SpannableUtil.LinkCallback() {
            @Override
            public void onLinkClick(String originText, String link, int startIndex, int endIndex) {
 
            }
        });
  vh.tv.setText(SpannableUtil.addPhoneLink(msg.getMsgContent(), builder, mExtAdapter.isAddPhoneLink(), color, new
      SpannableUtil.LinkCallback() {
            @Override
            public void onLinkClick(String originText, String link, int startIndex, int endIndex) {
                 
            }
        }), TextView.BufferType.SPANNABLE);
 
   if(打开自由复制){
      vh.tv.setLinksClickable(false);
      vh.tv.setTextIsSelectable(true);
  }

重点是 setMovementMethod(selectable ? ArrowKeyMovementMethod.getInstance() : null); 这个地方,如果设置的是可以自由复制,那么就使用ArrowKeyMovementMethod ,否则将MovementMethod 设置为null ,将我们的LinkMovementMethod给替换掉了,所以将
vh.tv.setTextIsSelectable(true);这个方法提前到祖传代码setMovementMethod 之前,问题得到解决。

本文作者:自如大前端研发中心-田守明

招聘信息

自如大前端研发中心招募新同学!

FE/IOS/Android工程师看过来

公司福利有:

  • 全额五险一金,并额外购买商业保险
  • 免费健身房+年度体检
  • 公司附近租房9折优惠
  • 每年2次晋升机会 办公地点:北京酒仙桥普天实业科技园 欢迎对技术有执着热爱的你加入我们!简历请投递 zhangxl122@ziroom.com, 或加微信 v-nice-v 详聊!