首页 > 系统 > Android > 正文

Android中TextView文本高亮和点击行为的封装方法

2019-12-12 03:16:26
字体:
来源:转载
供稿:网友

前言

相信大家应该都有所体会,对于一个社交性质的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来决定不同行为了,对于点击是非常好处理的,代码如下:

@Overridepublic 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行为失效,参考第三步@Overridepublic boolean onTouchEvent(MotionEvent event) { if (!(getText() instanceof Spannable)) {  return super.onTouchEvent(event); } mTouchSpanHint = true; return super.onTouchEvent(event);}// 第三步: final掉setPressed,如果!mTouchSpanHint才调用super.setPressed,开一个onSetPressed给子类覆写@Overridepublic 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:

@Overridepublic 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); }}

总结

以上就是这篇文章的全部内容了,希望本文的内容对给位Android开发者们能带来一定的帮助,如果有疑问大家可以留言交流,谢谢大家对武林网的支持。

发表评论 共有条评论
用户名: 密码:
验证码: 匿名发表