TextView文本高亮與點擊行為完美封裝

gdedk242 7年前發布 | 14K 次閱讀 TextView Android開發 移動開發

對于一個社交性質的App,業務上少不了給一段文本加上@功能、話題功能,或者是評論上要高亮人名的需求。當然,Android為我們提供了 ClickableSpan ,用于解決TextView部分內容可點擊的問題,但卻附加了一堆的坑點:

  1. ClickableSpan 默認沒有高亮行為,也不能添加背景顏色;
  2. ClickableSpan 必須配合 MovementMethod 使用
  3. 一旦使用 MovementMethod , TextView 必定消耗事件
  4. 當點擊 ClickableSpan 時,TextView的點擊也會隨后觸發
  5. 當press ClickableSpan 時, TextView的press態也會被觸發

這些默認的表現會使得添加 ClickableSpan 后會出現各種不符合預期的問題,因此我們需要對其進行封裝。據個人使用經驗,封裝后應該能夠方便開發實現以下行為:

  1. 讓Span支持字體顏色和背景顏色變化,并且有press態行為
  2. Span的click或者press不影響TextView的click和press
  3. 可選擇的決定TextView是否應該消耗事件

對于第三點,需要解釋下TextView是否消耗事件的影響

用一張圖來闡述下我們的目的。我們開發過程中,可能將點擊事件加在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 中會調用到 LinkMovementMethod 的 onTouchEvent ,并且會傳入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行為,就會有點棘手,因為 setPress 在 onTouchEvent 多次調用,而且在 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 , longClickable 和 focusable 都設置為true,這樣必然TextView會消耗事件了。因此我們想到的解決方案就是:如果我們想不讓TextView消耗事件,那么我們就在 setMovementMethod 之后再改一次 clickable , longClickable 和 focusable 。

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 事件分發的兩個坑

 

來自:http://blog.cgsdream.org/2017/03/22/textview-highlight-clickablespan/

 

 本文由用戶 gdedk242 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!