Android中TextView文本高亮和點擊行為的封裝方法
前言
相信大家應該都有所體會,對于一個社交性質(zhì)的App,業(yè)務上少不了給一段文本加上@功能、話題功能,或者是評論上要高亮人名的需求。當然,Android為我們提供了ClickableSpan,用于解決TextView部分內(nèi)容可點擊的問題,但卻附加了一堆的坑點:
- ClickableSpan 默認沒有高亮行為,也不能添加背景顏色;
- ClickableSpan 必須配合 MovementMethod 使用
- 一旦使用 MovementMethod,TextView 必定消耗事件
- 當點擊ClickableSpan時,TextView的點擊也會隨后觸發(fā)
- 當press ClickableSpan 時, TextView的press態(tài)也會被觸發(fā)
這些默認的表現(xiàn)會使得添加 ClickableSpan 后會出現(xiàn)各種不符合預期的問題,因此我們需要對其進行封裝。
據(jù)個人使用經(jīng)驗,封裝后應該能夠方便開發(fā)實現(xiàn)以下行為:
- 讓Span支持字體顏色和背景顏色變化,并且有press態(tài)行為
- Span的click或者press不影響TextView的click和press
- 可選擇的決定TextView是否應該消耗事件
對于第三點,需要解釋下TextView是否消耗事件的影響

用一張圖來闡述下我們的目的。我們開發(fā)過程中,可能將點擊事件加在TextView上,也可能將點擊行為添加在TextView的父元素上,例如評論一般是點擊整個評論item就可以觸發(fā)回復。 如果我們把點擊事件加在TextView的父元素上,那么我們期待的是點擊TextView的綠色區(qū)域應該也要響應點擊事件,但現(xiàn)實總是殘酷的,如果TextView調(diào)用了setMovementMethod, 點擊綠色區(qū)域?qū)⒉粫腥魏畏磻?,因為時間被TextView消耗了,并不會傳遞到TextView的父元素上。
那我們來一步一步看如何實現(xiàn)這幾個問題。
首先我們定義一個接口 ITouchableSpan, 用于抽象press和點擊:
public interface ITouchableSpan {
void setPressed(boolean pressed);
void onClick(View widget);
}
然后建立一個 ClickableSpan的子類 QMUITouchableSpan 來擴充它的表現(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)和點擊行為傳遞給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點。 我們抽取一個 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去處理相關細節(jié)。
第一步我們需要判斷是否是點擊到了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多次調(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設為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);
}
}
原來設置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;
// 調(diào)用super.onTouchEvent,會走到QMUILinkTouchMovementMethod
// 會走到QMUILinkTouchMovementMethod#onTouchEvent會修改mTouchSpanHint
boolean ret = super.onTouchEvent(event);
if(!mShouldConsumeEvent){
return mTouchSpanHint;
}
return ret;
}
經(jīng)過層層fix,我們終于可以給出一份不錯的封裝代碼提供給業(yè)務方使用了:
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是否應該消耗事件
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ā)者們能帶來一定的幫助,如果有疑問大家可以留言交流,謝謝大家對腳本之家的支持。
相關文章
Android入門之BroadCast模擬實現(xiàn)異地登錄事件發(fā)生后的主動退出
隨著對BroadCast的越來越深入,我們今天要實現(xiàn)一個稍微復雜一點的BroadCast。即只允許一個設備登錄一個帳號時,APP會彈一個對話框如:您的賬號在別處登錄,請重新登陸!感興趣的可以了解一下2022-12-12
如何使用Matrix對bitmap的旋轉(zhuǎn)與鏡像水平垂直翻轉(zhuǎn)
本篇文章是對使用Matrix對bitmap的旋轉(zhuǎn)與鏡像水平垂直翻轉(zhuǎn)進行了詳細的分析介紹,需要的朋友參考下2013-06-06
淺談Android Studio導出javadoc文檔操作及問題的解決
這篇文章主要介紹了淺談Android Studio導出javadoc文檔操作及問題的解決,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-03-03
Android中EditText的drawableRight屬性設置點擊事件
這篇文章主要介紹了Android中EditText的drawableRight屬性的圖片設置點擊事件,具有一定的參考價值,感興趣的小伙伴們可以參考一下2016-10-10

