Android中TextView文本高亮和點(diǎn)擊行為的封裝方法
前言
相信大家應(yīng)該都有所體會,對于一個社交性質(zhì)的App,業(yè)務(wù)上少不了給一段文本加上@功能、話題功能,或者是評論上要高亮人名的需求。當(dāng)然,Android為我們提供了ClickableSpan,用于解決TextView部分內(nèi)容可點(diǎn)擊的問題,但卻附加了一堆的坑點(diǎn):
- ClickableSpan 默認(rèn)沒有高亮行為,也不能添加背景顏色;
- ClickableSpan 必須配合 MovementMethod 使用
- 一旦使用 MovementMethod,TextView 必定消耗事件
- 當(dāng)點(diǎn)擊ClickableSpan時(shí),TextView的點(diǎn)擊也會隨后觸發(fā)
- 當(dāng)press ClickableSpan 時(shí), TextView的press態(tài)也會被觸發(fā)
這些默認(rèn)的表現(xiàn)會使得添加 ClickableSpan 后會出現(xiàn)各種不符合預(yù)期的問題,因此我們需要對其進(jìn)行封裝。
據(jù)個人使用經(jīng)驗(yàn),封裝后應(yīng)該能夠方便開發(fā)實(shí)現(xiàn)以下行為:
- 讓Span支持字體顏色和背景顏色變化,并且有press態(tài)行為
- Span的click或者press不影響TextView的click和press
- 可選擇的決定TextView是否應(yīng)該消耗事件
對于第三點(diǎn),需要解釋下TextView是否消耗事件的影響
用一張圖來闡述下我們的目的。我們開發(fā)過程中,可能將點(diǎn)擊事件加在TextView上,也可能將點(diǎn)擊行為添加在TextView的父元素上,例如評論一般是點(diǎn)擊整個評論item就可以觸發(fā)回復(fù)。 如果我們把點(diǎn)擊事件加在TextView的父元素上,那么我們期待的是點(diǎn)擊TextView的綠色區(qū)域應(yīng)該也要響應(yīng)點(diǎn)擊事件,但現(xiàn)實(shí)總是殘酷的,如果TextView調(diào)用了setMovementMethod, 點(diǎn)擊綠色區(qū)域?qū)⒉粫腥魏畏磻?yīng),因?yàn)闀r(shí)間被TextView消耗了,并不會傳遞到TextView的父元素上。
那我們來一步一步看如何實(shí)現(xiàn)這幾個問題。
首先我們定義一個接口 ITouchableSpan, 用于抽象press和點(diǎn)擊:
public interface ITouchableSpan { void setPressed(boolean pressed); void onClick(View widget); }
然后建立一個 ClickableSpan的子類 QMUITouchableSpan 來擴(kuò)充它的表現(xiàn):
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狀態(tài)和點(diǎn)擊行為傳遞給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 中會調(diào)用到 LinkMovementMethod的onTouchEvent,并且會傳入Spannable,這是一個去處理Spannable數(shù)據(jù)的好hook點(diǎn)。 我們抽取一個 QMUILinkTouchDecorHelper 用于處理公共邏輯,因?yàn)長inkMovementMethod存在多個行為各異的子類。
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,然后做了相應(yīng)的修改。完成這些,我們才僅僅能做到我們想要的第一步而已。
接下來我們看如何處理TextView的click與press與 QMUITouchableSpan 沖突的問題。 這一步我們需要建立一個TextView的子類QMUISpanTouchFixTextView去處理相關(guān)細(xì)節(jié)。
第一步我們需要判斷是否是點(diǎn)擊到了QMUITouchableSpan, 這個判斷可以放在 QMUILinkTouchDecorHelper#onTouchEvent中完成, 在onTouchEvent中補(bǔ)充以下代碼:
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; } }
這個時(shí)候我們在 QMUISpanTouchFixTextView就可以通過是否點(diǎn)擊到QMUITouchableSpan來決定不同行為了,對于點(diǎn)擊是非常好處理的,代碼如下:
@Override public boolean performClick() { if (!mTouchSpanHint) { return super.performClick(); } return false; }
對于press行為,就會有點(diǎn)棘手,因?yàn)閟etPress在 onTouchEvent多次調(diào)用,而且在QMUILinkTouchDecorHelper#onTouchEvent前就會被調(diào)用到,所以不能簡單的用mTouchSpanHint這個變量來管理。來看看我給出的方案:
// 記錄每次真正傳入的press,每次更改mTouchSpanHint,需要再調(diào)用一次setPressed,確保press狀態(tài)正確 // 第一步: 用一個變量記錄setPress傳入的值,這個是TextView真正的press值 private boolean mIsPressedRecord = false; // 第二步,onTouchEvent在調(diào)用super前將mTouchSpanHint設(shè)為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才調(diào)用super.setPressed,開一個onSetPressed給子類覆寫 @Override public final void setPressed(boolean pressed) { mIsPressedRecord = pressed; if (!mTouchSpanHint) { onSetPressed(pressed); } } protected void onSetPressed(boolean pressed) { super.setPressed(pressed); } // 第四步: 每次調(diào)用setTouchSpanHint是調(diào)用一次setPressed,并傳入mIsPressedRecord,確保press狀態(tài)的統(tǒng)一 public void setTouchSpanHint(boolean touchSpanHint) { if (mTouchSpanHint != touchSpanHint) { mTouchSpanHint = touchSpanHint; setPressed(mIsPressedRecord); } }
這幾個步驟相互耦合,靜下心好好理解下。這樣就順利的解決了第二個問題。那么我們來看看如何消除 MovementMethod造成TextView對事件的消耗行為。
調(diào)用 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); } }
原來設(shè)置MovementMethod后會把clickable,longClickable和focusable都設(shè)置為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; // 調(diào)用super.onTouchEvent,會走到QMUILinkTouchMovementMethod // 會走到QMUILinkTouchMovementMethod#onTouchEvent會修改mTouchSpanHint boolean ret = super.onTouchEvent(event); if(!mShouldConsumeEvent){ return mTouchSpanHint; } return ret; }
經(jīng)過層層fix,我們終于可以給出一份不錯的封裝代碼提供給業(yè)務(wù)方使用了:
public class QMUISpanTouchFixTextView extends TextView { private boolean mTouchSpanHint; // 記錄每次真正傳入的press,每次更改mTouchSpanHint,需要再調(diào)用一次setPressed,確保press狀態(tài)正確 private boolean mIsPressedRecord = false; private boolean mShouldConsumeEvent = true; // TextView是否應(yīng)該消耗事件 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; // 調(diào)用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); } }
總結(jié)
以上就是這篇文章的全部內(nèi)容了,希望本文的內(nèi)容對給位Android開發(fā)者們能帶來一定的幫助,如果有疑問大家可以留言交流,謝謝大家對腳本之家的支持。
相關(guān)文章
Android實(shí)現(xiàn)圖片在屏幕內(nèi)縮放和移動效果
這篇文章主要為大家詳細(xì)介紹了Android控制圖片在屏幕內(nèi)縮放和移動效果,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-02-02Android 實(shí)現(xiàn)密碼輸入框動態(tài)明文/密文切換顯示效果
在項(xiàng)目中遇到需要提供給用戶一個密碼輸入框明文/密文切換顯示的需求,今天小編借腳本之家平臺給大家分享下Android 實(shí)現(xiàn)密碼輸入框動態(tài)明文/密文切換顯示效果,需要的朋友參考下2017-01-01Android基礎(chǔ)控件RadioGroup使用方法詳解
這篇文章主要為大家詳細(xì)介紹了Android基礎(chǔ)控件RadioGroup的使用方法,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-11-11Android實(shí)現(xiàn)微信首頁左右滑動切換效果
這篇文章主要介紹了Android實(shí)現(xiàn)微信首頁左右滑動切換效果,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-09-09Android動畫之TranslateAnimation用法案例詳解
這篇文章主要介紹了Android動畫之TranslateAnimation用法案例詳解,本篇文章通過簡要的案例,講解了該項(xiàng)技術(shù)的了解與使用,以下就是詳細(xì)內(nèi)容,需要的朋友可以參考下2021-08-08Android實(shí)現(xiàn)滑動刪除操作(PopupWindow)
這篇文章主要介紹了Android ListView結(jié)合PopupWindow實(shí)現(xiàn)滑動刪除的相關(guān)資料,需要的朋友可以參考下2016-07-07學(xué)習(xí)Android自定義Spinner適配器
這篇文章主要為大家詳細(xì)介紹了學(xué)習(xí)Android自定義Spinner適配器的相關(guān)資料,感興趣的小伙伴們可以參考一下2016-05-05