TextView 文本高亮与点击行为完美封装

简介: ClickableSpan的优雅处理

对于一个社交性质的App,业务上少不了给一段文本加上@功能、话题功能,或者是评论上要高亮人名的需求。当然,Android为我们提供了ClickableSpan,用于解决TextView部分内容可点击的问题,但却附加了一堆的坑点:


1.ClickableSpan 默认没有高亮行为,也不能添加背景颜色;

2.ClickableSpan 必须配合 MovementMethod 使用

3.一旦使用 MovementMethodTextView 必定消耗事件

4.当点击ClickableSpan时,TextView的点击也会随后触发

5.当press ClickableSpan 时, TextView的press态也会被触发


这些默认的表现会使得添加 ClickableSpan 后会出现各种不符合预期的问题,因此我们需要对其进行封装。据个人使用经验,封装后应该能够方便开发实现以下行为:


1.让Span支持字体颜色和背景颜色变化,并且有press态行为

2.Span的click或者press不影响TextView的click和press

3.可选择的决定TextView是否应该消耗事件


对于第三点,需要解释下TextView是否消耗事件的影响

b385208a488ccdcecaa6b58a11f8dd4.png

用一张图来阐述下我们的目的。我们开发过程中,可能将点击事件加在TextView上,也可能将点击行为添加在TextView的父元素上,例如评论一般是点击整个评论item就可以触发回复。 如果我们把点击事件加在TextView的父元素上,那么我们期待的是点击TextView的绿色区域应该也要响应点击事件,但现实总是残酷的,如果TextView调用了setMovementMethod, 点击绿色区域将不会有任何反应,因为时间被TextView消耗了,并不会传递到TextView的父元素上。


那我们来一步一步看如何实现这几个问题。


首先我们定义一个接口 ITouchableSpan, 用于抽象press和点击:

public interface ITouchableSpan {
    void setPressed(boolean pressed);
    void onClick(View widget);
}

然后建立一个 ClickableSpan的子类 QMUITouchableSpan 来扩充它的表现:

public abstract class QMUITouchableSpan extends ClickableSpan implements ITouchableSpan {
    private boolean mIsPressed;
    @ColorInt private int mNormalBackgroundColor;
    @ColorInt private int mPressedBackgroundColor;
    @ColorInt private int mNormalTextColor;
    @ColorInt private int mPressedTextColor;
    private boolean mIsNeedUnderline = false;
    public abstract void onSpanClick(View widget);
    @Override
    public final void onClick(View widget) {
        if (ViewCompat.isAttachedToWindow(widget)) {
            onSpanClick(widget);
        }
    }
    public QMUITouchableSpan(@ColorInt int normalTextColor,
                         @ColorInt int pressedTextColor,
                         @ColorInt int normalBackgroundColor,
                         @ColorInt int pressedBackgroundColor) {
        mNormalTextColor = normalTextColor;
        mPressedTextColor = pressedTextColor;
        mNormalBackgroundColor = normalBackgroundColor;
        mPressedBackgroundColor = pressedBackgroundColor;
    }
    // .... get/set ...
    public void setPressed(boolean isSelected) {
        mIsPressed = isSelected;
    }
    public boolean isPressed() {
        return mIsPressed;
    }
    @Override
    public void updateDrawState(TextPaint ds) {
        // 通过updateDrawState来更新字体颜色和背景色
        ds.setColor(mIsPressed ? mPressedTextColor : mNormalTextColor);
        ds.bgColor = mIsPressed ? mPressedBackgroundColor
                : mNormalBackgroundColor;
        ds.setUnderlineText(mIsNeedUnderline);
    }
}

然后我们要把press状态和点击行为传递给QMUITouchableSpan,这一层我们可以通过重载 LinkMovementMethod去解决:

public class QMUILinkTouchMovementMethod extends LinkMovementMethod {
    @Override
    public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) {
        return sHelper.onTouchEvent(widget, buffer, event)
                || Touch.onTouchEvent(widget, buffer, event);
    }
    public static MovementMethod getInstance() {
        if (sInstance == null)
            sInstance = new QMUILinkTouchMovementMethod();
        return sInstance;
    }
    private static QMUILinkTouchMovementMethod sInstance;
    private static QMUILinkTouchDecorHelper sHelper = new QMUILinkTouchDecorHelper();
}

对TextView使用 setMovementMethod 后,TextView的 onTouchEvent 中会调用到 LinkMovementMethodonTouchEvent,并且会传入Spannable,这是一个去处理Spannable数据的好hook点。 我们抽取一个 QMUILinkTouchDecorHelper 用于处理公共逻辑,因为LinkMovementMethod存在多个行为各异的子类。

public class QMUILinkTouchDecorHelper {
    private ITouchableSpan mPressedSpan;
    public boolean onTouchEvent(TextView textView, Spannable spannable, MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_DOWN) {
            mPressedSpan = getPressedSpan(textView, spannable, event);
            if (mPressedSpan != null) {
                mPressedSpan.setPressed(true);
                Selection.setSelection(spannable, spannable.getSpanStart(mPressedSpan),
                        spannable.getSpanEnd(mPressedSpan));
            }
            if (textView instanceof QMUISpanTouchFixTextView) {
                QMUISpanTouchFixTextView tv = (QMUISpanTouchFixTextView) textView;
                tv.setTouchSpanHint(mPressedSpan != null);
            }
            return mPressedSpan != null;
        } else if (event.getAction() == MotionEvent.ACTION_MOVE) {
            ITouchableSpan touchedSpan = getPressedSpan(textView, spannable, event);
            if (mPressedSpan != null && touchedSpan != mPressedSpan) {
                mPressedSpan.setPressed(false);
                mPressedSpan = null;
                Selection.removeSelection(spannable);
            }
            return mPressedSpan != null;
        } else if (event.getAction() == MotionEvent.ACTION_UP) {
            boolean touchSpanHint = false;
            if (mPressedSpan != null) {
                touchSpanHint = true;
                mPressedSpan.setPressed(false);
                mPressedSpan.onClick(textView);
            }
            mPressedSpan = null;
            Selection.removeSelection(spannable);
            return touchSpanHint;
        } else {
            if (mPressedSpan != null) {
                mPressedSpan.setPressed(false);
            }
            Selection.removeSelection(spannable);
            return false;
        }
    }
    public ITouchableSpan getPressedSpan(TextView textView, Spannable spannable, MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        x -= textView.getTotalPaddingLeft();
        y -= textView.getTotalPaddingTop();
        x += textView.getScrollX();
        y += textView.getScrollY();
        Layout layout = textView.getLayout();
        int line = layout.getLineForVertical(y);
        int off = layout.getOffsetForHorizontal(line, x);
        ITouchableSpan[] link = spannable.getSpans(off, off, ITouchableSpan.class);
        ITouchableSpan touchedSpan = null;
        if (link.length > 0) {
            touchedSpan = link[0];
        }
        return touchedSpan;
    }
}

上述的很多行为直接取自官方的LinkTouchMovementMethod,然后做了相应的修改。完成这些,我们才仅仅能做到我们想要的第一步而已。


接下来我们看如何处理TextView的click与press与 QMUITouchableSpan 冲突的问题。 这一步我们需要建立一个TextView的子类QMUISpanTouchFixTextView去处理相关细节。


第一步我们需要判断是否是点击到了QMUITouchableSpan, 这个判断可以放在 QMUILinkTouchDecorHelper#onTouchEvent中完成, 在onTouchEvent中补充以下代码:

public boolean onTouchEvent(TextView textView, Spannable spannable, MotionEvent event) {
    if (event.getAction() == MotionEvent.ACTION_DOWN) {
        // ...
        if (textView instanceof QMUISpanTouchFixTextView) {
            QMUISpanTouchFixTextView tv = (QMUISpanTouchFixTextView) textView;
            tv.setTouchSpanHint(mPressedSpan != null);
        }
        return mPressedSpan != null;
    } else if (event.getAction() == MotionEvent.ACTION_MOVE) {
        // ...
        if (textView instanceof QMUISpanTouchFixTextView) {
            QMUISpanTouchFixTextView tv = (QMUISpanTouchFixTextView) textView;
            tv.setTouchSpanHint(mPressedSpan != null);
        }
        return mPressedSpan != null;
    } else if (event.getAction() == MotionEvent.ACTION_UP) {
        // ...
        Selection.removeSelection(spannable);
        if (textView instanceof QMUISpanTouchFixTextView) {
            QMUISpanTouchFixTextView tv = (QMUISpanTouchFixTextView) textView;
            tv.setTouchSpanHint(touchSpanHint);
        }
        return touchSpanHint;
    } else {
        // ...
        if (textView instanceof QMUISpanTouchFixTextView) {
            QMUISpanTouchFixTextView tv = (QMUISpanTouchFixTextView) textView;
            tv.setTouchSpanHint(false);
        }
       // ...
        return false;
    }
}

这个时候我们在 QMUISpanTouchFixTextView就可以通过是否点击到QMUITouchableSpan来决定不同行为了,对于点击是非常好处理的,代码如下:

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

对于press行为,就会有点棘手,因为setPressonTouchEvent多次调用,而且在QMUILinkTouchDecorHelper#onTouchEvent前就会被调用到,所以不能简单的用mTouchSpanHint这个变量来管理。来看看我给出的方案:

// 记录每次真正传入的press,每次更改mTouchSpanHint,需要再调用一次setPressed,确保press状态正确
// 第一步: 用一个变量记录setPress传入的值,这个是TextView真正的press值
private boolean mIsPressedRecord = false;
// 第二步,onTouchEvent在调用super前将mTouchSpanHint设为true,这会使得QMUILinkTouchDecorHelper#onTouchEvent的press行为失效,参考第三步
@Override
public boolean onTouchEvent(MotionEvent event) {
    if (!(getText() instanceof Spannable)) {
        return super.onTouchEvent(event);
    }
    mTouchSpanHint = true;
    return super.onTouchEvent(event);
}
// 第三步: final掉setPressed,如果!mTouchSpanHint才调用super.setPressed,开一个onSetPressed给子类覆写
@Override
public final void setPressed(boolean pressed) {
    mIsPressedRecord = pressed;
    if (!mTouchSpanHint) {
        onSetPressed(pressed);
    }
}
protected void onSetPressed(boolean pressed) {
    super.setPressed(pressed);
}
// 第四步: 每次调用setTouchSpanHint是调用一次setPressed,并传入mIsPressedRecord,确保press状态的统一
public void setTouchSpanHint(boolean touchSpanHint) {
    if (mTouchSpanHint != touchSpanHint) {
        mTouchSpanHint = touchSpanHint;
        setPressed(mIsPressedRecord);
    }
}

这几个步骤相互耦合,静下心好好理解下。这样就顺利的解决了第二个问题。那么我们来看看如何消除 MovementMethod造成TextView对事件的消耗行为。


调用 setMovementMethod为何会使得TextView必然消耗事件呢?我们可以看看源码:

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,longClickablefocusable都设置为true,这样必然TextView会消耗事件了。因此我们想到的解决方案就是:如果我们想不让TextView消耗事件,那么我们就在 setMovementMethod之后再改一次clickable,longClickablefocusable

public void setShouldConsumeEvent(boolean shouldConsumeEvent) {
    mShouldConsumeEvent = shouldConsumeEvent;
    setFocusable(shouldConsumeEvent);
    setClickable(shouldConsumeEvent);
    setLongClickable(shouldConsumeEvent);
}
public void setMovementMethodCompat(MovementMethod movement){
    setMovementMethod(movement);
    if(!mShouldConsumeEvent){
        setShouldConsumeEvent(false);
    }
}

仅仅这样还不够,我们还必须在 onTouchEvent里面返回false:

@Override
public boolean onTouchEvent(MotionEvent event) {
    if (!(getText() instanceof Spannable)) {
        return super.onTouchEvent(event);
    }
    mTouchSpanHint = true;
    // 调用super.onTouchEvent,会走到QMUILinkTouchMovementMethod
    // 会走到QMUILinkTouchMovementMethod#onTouchEvent会修改mTouchSpanHint
    boolean ret = super.onTouchEvent(event);
    if(!mShouldConsumeEvent){
        return mTouchSpanHint;
    }
    return ret;
}

经过层层fix,我们终于可以给出一份不错的封装代码提供给业务方使用了:

public class QMUISpanTouchFixTextView extends TextView {
    private boolean mTouchSpanHint;
    // 记录每次真正传入的press,每次更改mTouchSpanHint,需要再调用一次setPressed,确保press状态正确
    private boolean mIsPressedRecord = false;
    private boolean mShouldConsumeEvent = true; // TextView是否应该消耗事件
    public QMUISpanTouchFixTextView(Context context) {
        this(context, null);
    }
    public QMUISpanTouchFixTextView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public QMUISpanTouchFixTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setHighlightColor(Color.TRANSPARENT);
        setMovementMethod(QMUILinkTouchMovementMethod.getInstance());
    }
    public void setShouldConsumeEvent(boolean shouldConsumeEvent) {
        mShouldConsumeEvent = shouldConsumeEvent;
        setFocusable(shouldConsumeEvent);
        setClickable(shouldConsumeEvent);
        setLongClickable(shouldConsumeEvent);
    }
    public void setMovementMethodCompat(MovementMethod movement){
        setMovementMethod(movement);
        if(!mShouldConsumeEvent){
            setShouldConsumeEvent(false);
        }
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!(getText() instanceof Spannable)) {
            return super.onTouchEvent(event);
        }
        mTouchSpanHint = true;
        // 调用super.onTouchEvent,会走到QMUILinkTouchMovementMethod
        // 会走到QMUILinkTouchMovementMethod#onTouchEvent会修改mTouchSpanHint
        boolean ret = super.onTouchEvent(event);
        if(!mShouldConsumeEvent){
            return mTouchSpanHint;
        }
        return ret;
    }
    public void setTouchSpanHint(boolean touchSpanHint) {
        if (mTouchSpanHint != touchSpanHint) {
            mTouchSpanHint = touchSpanHint;
            setPressed(mIsPressedRecord);
        }
    }
    @Override
    public boolean performClick() {
        if (!mTouchSpanHint && mShouldConsumeEvent) {
            return super.performClick();
        }
        return false;
    }
    @Override
    public boolean performLongClick() {
        if (!mTouchSpanHint && mShouldConsumeEvent) {
            return super.performLongClick();
        }
        return false;
    }
    @Override
    public final void setPressed(boolean pressed) {
        mIsPressedRecord = pressed;
        if (!mTouchSpanHint) {
            onSetPressed(pressed);
        }
    }
    protected void onSetPressed(boolean pressed) {
        super.setPressed(pressed);
    }
}

参考链接:

TextView ClickableSpan 事件分发的两个坑

目录
相关文章
|
3月前
点击富文本部分文字跳转功能
点击富文本部分文字跳转功能
37 0
|
前端开发 Python
tkinter模块高级操作(一)—— 透明按钮、透明文本框、自定义按钮及自定义文本框
tkinter模块高级操作(一)—— 透明按钮、透明文本框、自定义按钮及自定义文本框
633 0
|
前端开发
Bootstrap-文本,背景,按钮样式
Bootstrap-文本,背景,按钮样式
87 0
|
开发者
文字控件| 学习笔记
快速学习文字控件。
84 0
文字控件| 学习笔记
|
开发者
文字控件|学习笔记
快速学习文字控件
79 0
文字控件|学习笔记
|
Android开发 数据格式 XML
Android图表库MPAndroidChart(五)——自定义MarkerView实现选中高亮
Android图表库MPAndroidChart(五)——自定义MarkerView实现选中高亮 在学习本课程之前我建议先把我之前的博客看完,这样对整体的流程有一个大致的了解 Android图表库MPAndroidChart(一)——了解他的本质,方能得心应手...
3299 0
PyQt5 技术篇-plainTextEdit控件获得文本内容方法、设置文本内容方法。
PyQt5 技术篇-plainTextEdit控件获得文本内容方法、设置文本内容方法。
710 0
|
Android开发 数据格式 XML
关于 AutoCompleteTextView 不输入文字就显示下拉
由于项目要做一个带有下拉提示的输入框,第一时间就想到了AutoCompleteTextView。但是需求和控件还是有一点出入,公司的需求为:点击输入框即可显示提示数据的数据。
1597 0