Loading...
墨滴

掀乱书页的风

2021/10/16  阅读:27  主题:兰青

TextView文本点击冲突完美解决

Textview文本点击冲突完美解决

对于有社交性质的部落,帖子上需要给一段文本加上#话题功能。需求看起来很简单 1.点击话题跳转到话题页 2.点击除话题外文字区跳转到帖子详情页

image.png 当然,Android为我们提供了ClickableSpan,用于解决TextView部分内容可点击的问题,但却附加了一堆的坑:

1.ClickableSpan 必须配合 MovementMethod 使用 2.一旦使用 MovementMethod,TextView 必定消耗事件 3.当点击ClickableSpan时,TextView的点击也会随后触发

这些默认的表现会使得添加 ClickableSpan 后会出现各种不符合预期的问题,比如当我点击非话题的时候Textview会消耗点击事件,导致无法跳转帖子详情页

Textview消耗事件的原因

从TextView的源码看起,找到TextView的onTouchEvent的方法

  @Override
    public boolean onTouchEvent(MotionEvent event) {
        final int action = event.getActionMasked();
        if (mEditor != null) {
            mEditor.onTouchEvent(event);
            if (mEditor.mSelectionModifierCursorController != null &&
                    mEditor.mSelectionModifierCursorController.isDragAcceleratorActive()) {
                return true;
            }
        }
        final boolean superResult = super.onTouchEvent(event); //触发Textview的监听事件
    
...
        
        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) {
                //linkmovement的onTouch事件
                handled |= mMovement.onTouchEvent(this, (Spannable) mText, event);
            }
            final boolean textIsSelectable = isTextSelectable();
            if (touchIsFinished && mLinksClickable && mAutoLinkMask != 0 && textIsSelectable) {
                
                ClickableSpan[] links = ((Spannable) mText).getSpans(getSelectionStart(),
                        getSelectionEnd(), ClickableSpan.class);
                if (links.length > 0) {
                    links[0].onClick(this);
                    handled = true;
                }
            }
            
...         
            //如果点击的是clickspan,消耗事件,否则返回super.onTouchEvent(event)
             if (handled) {
                return true;
            }
            
        return superResult; //消费事件
    }

可以看到调用了super.onTouchEvent(),接着执行clickablespan的点击事件

ClickableSpan[] links = ((Spannable) mText).getSpans(getSelectionStart(), getSelectionEnd(), ClickableSpan.class);
 if (links.length > 0) { 
  links[0].onClick(this); 
  handled = true; }

简单梳理一下 如果点击的是clickspan,消耗事件,否则返回super.onTouchEvent(event)

现象是当点击未设置span的textview部分时,发现textview消费了事件,并没有传递给viewgroup

设置clickablespan时设置了一个LinkMovementMethod,看一下LinkMovementMehod的源码

@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[] link = buffer.getSpans(off, off, ClickableSpan.class);
            if (link.length != 0) {
                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]));
                }
                return true;
            } else {
                Selection.removeSelection(buffer);
            }
        }
        return super.onTouchEvent(widget, buffer, event);
    }

可以看到当点击设置了span的textview时return true,没有问题,如果点击的是没有设置span的text或者空白部分时返回的是一个super.onTouchEvent();

LinkMovementMethod 的父布局 ScrollingMovementMethod是控制内容滑动的MovementMethod,在它的onTouch方法中默认会给所有的点击事件返回true

case MotionEvent.ACTION_DOWN:
            ds = buffer.getSpans(0, buffer.length(), DragState.class);

            for (int i = 0; i < ds.length; i++) {
                buffer.removeSpan(ds[i]);
            }
            //down默认给加一个DragState
            buffer.setSpan(new DragState(event.getX(), event.getY(),
                            widget.getScrollX(), widget.getScrollY()),
                    0, 0, Spannable.SPAN_MARK_MARK);
            return true;
case MotionEvent.ACTION_UP:
            ds = buffer.getSpans(0, buffer.length(), DragState.class);

            for (int i = 0; i < ds.length; i++) {
                buffer.removeSpan(ds[i]);
            }
            //up就判断是否有DragState
            if (ds.length > 0 && ds[0].mUsed) {
                return true;
            } else {
                return false;
            }

最终这个 true 被 TextView.onTouchEvent() 返回给父View,即告诉父 View:这个事件我消费了,你别管了。所以我们要重写LinkMovementMethod中的onTouchEvent, 让最后默认返回显式为return false。即不消耗事件,事件继续向上传递

但是这样修改完之后还是不能解决这个问题,问题出在textview的setMovementMethod方法里面

public final void setMovementMethod(MovementMethod movement) {
    if (mMovement != movement) {
        mMovement = movement;

        if (movement != null && !(mText instanceof Spannable)) {
            setText(mText);
        }

        fixFocusableAndClickableSettings();

        // SelectionModifierCursorController depends on textCanBeSelected, which depends on
        // mMovement
        if (mEditor != null) mEditor.prepareCursorControllers();
    }
}

private void fixFocusableAndClickableSettings() {
    if (mMovement != null || (mEditor != null && mEditor.mKeyListener != null)) {
        setFocusable(true);
        setClickable(true);
        setLongClickable(true);
    } else {
        setFocusable(false);
        setClickable(false);
        setLongClickable(false);
    }
}

原来设置MovementMethod后会把clickable,longClickable和focusable都设置为true

如果movementMothed的onTouch方法返回false之后,实际上TextView返回的是super的onTouch,即View的onTouch, 而View.onTouch代码

final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
                || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
                || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;
if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
            switch (action) {//全是break,没有return ,所以省略.
                case MotionEvent.ACTION_UP:
                 .
                 .
                 .
                 }
            return true;
        }

可以看到,只要clickable=true,onTouch都会拦截所有事件,关联上边的setLinkMovementMethod方法的源码,对clickAble,longClickAble的设置会导致onTouchEvent方法返回true 这样必然TextView会消耗事件了 如果我们想不让TextView消耗事件,那么我们就在 setMovementMethod之后再改一次clickable,longClickable和focusable。

解决方案

1.重写LinkMovementMethod方法,根据需要控制super.ontouch()的返回值,这里直接修改为false。 2.在setMovementMethod之前保存一下textView之前的xxxAble属性,设置完之后对这些属性进行还原。

点击事件问题已经解决了,本来以为大功告成了,但是这样又带来了另一个问题,Textview的maxline属性失效,什么意思呢,我设置最多三行,超出省略号显示,但是即使超出三行,也不显示省略号

省略号失效问题原因

我们大致看下LinkMovementMethod的实现。LinkMovementMethod继承自ScrollingMovementMethod,

public class LinkMovementMethod extends ScrollingMovementMethod {

从名字可以看出来它是可以滚动的。他有一个onTouchEvent方法,看来是处理点击事件的,前面已经分析过了,它会在action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN的时候去处理事件,获得点击位置的ClickableSpan, 在ACTION_UP的时候响应点击事件。而在action == MotionEvent.ACTION_MOVE的时候交给父类ScrollingMovementMethod处理,这也就使TextView可以滚动, 整个TextView可以滚动显示所有的文本,也就不会有ellipsize的省略号了。 Android 这样处理LinkMovementMethod可能是为了在大量文字时更方便地阅读,可以上下滚动,点击的时候点击的位置可以不遮挡要点击文字。但是在有些情况下就不太适用了, 比如只是想缩略的显示两行文本,而点击时要点那儿是那儿,这就需要我们来自己处理TextView的点击事件。

尝试填坑

1.重写TextView的onDraw方法,当达到最大行数之后手工加... :效果不好 2.反射调用修改StaticLayout:有时生效,有时失效

tips:TextView的末尾省略号是把文本的一个字符替换为省略号(不是三个点,而是真正的省略号…,编号U+2026),并且将剩下需要删除的字符 替换为一个零宽无间断间隔(U+FEFF)

只要使用LinkMovementMethod就是绕不过去的一个坑,最后采用一种侵入性比较强的方法

取代MovementMethod

给Textview添加OnTouchListener,去除MovementMethod,自己处理点击

   @Override
    public boolean onTouch(View v, MotionEvent event) {
        CharSequence text = getText();
        if (!(text instanceof Spanned)) {
            return false;
        }
        Spanned buffer = (Spanned) text;
        int action = event.getAction();
        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
            int x = (int) event.getX();
            int y = (int) event.getY();

            x -= getTotalPaddingLeft();
            y -= getTotalPaddingTop();

            x += getScrollX();
            y += getScrollY();

            Layout layout = 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) {
                    //自己处理点击
                    link.onClick(this);
                    isSpanClick = true;
                }
                return true;
            } else {
                isSpanClick = false;
            }
        }
        return false;
    }

然后重写Textview的performClick和performLongClick方法

 @Override
    public boolean performClick() {
        if (isSpanClick) {
            return false;
        }
        return super.performClick();
    }

    @Override
    public boolean performLongClick() {
        if (isSpanClick) {
            return false;
        }
        return super.performLongClick();
    }

总结

  1. Textview虽然是很基础的控件,但是也有很多坑啊,有人总结了七宗罪 安卓 TextView 七宗罪 - 陈蒙的博客 - CSDN博客 有时候我们要根据自己的需求来填不同的坑
  2. 有时候我们经常使用的一些api,可能并不是我们理解的那样,比如setSpan(Object what, int start, int end, int flags) 最后一个参数,跟start和end是没有任何关系的

掀乱书页的风

2021/10/16  阅读:27  主题:兰青

作者介绍

掀乱书页的风