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

gdedk242 3年前
   <p>对于一个社交性质的App,业务上少不了给一段文本加上@功能、话题功能,或者是评论上要高亮人名的需求。当然,Android为我们提供了 ClickableSpan ,用于解决TextView部分内容可点击的问题,但却附加了一堆的坑点:</p>    <ol>     <li>ClickableSpan 默认没有高亮行为,也不能添加背景颜色;</li>     <li>ClickableSpan 必须配合 MovementMethod 使用</li>     <li>一旦使用 MovementMethod , TextView 必定消耗事件</li>     <li>当点击 ClickableSpan 时,TextView的点击也会随后触发</li>     <li>当press ClickableSpan 时, TextView的press态也会被触发</li>    </ol>    <p>这些默认的表现会使得添加 ClickableSpan 后会出现各种不符合预期的问题,因此我们需要对其进行封装。据个人使用经验,封装后应该能够方便开发实现以下行为:</p>    <ol>     <li>让Span支持字体颜色和背景颜色变化,并且有press态行为</li>     <li>Span的click或者press不影响TextView的click和press</li>     <li>可选择的决定TextView是否应该消耗事件</li>    </ol>    <p>对于第三点,需要解释下TextView是否消耗事件的影响</p>    <p><img src="https://simg.open-open.com/show/f9161c693d0bbe2102a42b65c3d9b252.png"></p>    <p>用一张图来阐述下我们的目的。我们开发过程中,可能将点击事件加在TextView上,也可能将点击行为添加在TextView的父元素上,例如评论一般是点击整个评论item就可以触发回复。 如果我们把点击事件加在TextView的父元素上,那么我们期待的是点击TextView的绿色区域应该也要响应点击事件,但现实总是残酷的,如果TextView调用了 setMovementMethod , 点击绿色区域将不会有任何反应,因为时间被TextView消耗了,并不会传递到TextView的父元素上。</p>    <p>那我们来一步一步看如何实现这几个问题。</p>    <p>首先我们定义一个接口 ITouchableSpan , 用于抽象press和点击:</p>    <pre>  <code class="language-java">public interface ITouchableSpan {      void setPressed(boolean pressed);      void onClick(View widget);  }</code></pre>    <p>然后建立一个 ClickableSpan 的子类 QMUITouchableSpan 来扩充它的表现:</p>    <pre>  <code class="language-java">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);      }  }</code></pre>    <p>然后我们要把press状态和点击行为传递给 QMUITouchableSpan ,这一层我们可以通过重载 LinkMovementMethod 去解决:</p>    <pre>  <code class="language-java">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();    }</code></pre>    <p>对TextView使用 setMovementMethod 后,TextView的 onTouchEvent 中会调用到 LinkMovementMethod 的 onTouchEvent ,并且会传入Spannable,这是一个去处理Spannable数据的好hook点。 我们抽取一个 QMUILinkTouchDecorHelper 用于处理公共逻辑,因为LinkMovementMethod存在多个行为各异的子类。</p>    <pre>  <code class="language-java">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;      }  }</code></pre>    <p>上述的很多行为直接取自官方的 LinkTouchMovementMethod ,然后做了相应的修改。完成这些,我们才仅仅能做到我们想要的第一步而已。</p>    <p>接下来我们看如何处理TextView的click与press与 QMUITouchableSpan 冲突的问题。 这一步我们需要建立一个TextView的子类 QMUISpanTouchFixTextView 去处理相关细节。</p>    <p>第一步我们需要判断是否是点击到了 QMUITouchableSpan , 这个判断可以放在 QMUILinkTouchDecorHelper#onTouchEvent 中完成, 在 onTouchEvent中 补充以下代码:</p>    <pre>  <code class="language-java">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;      }  }</code></pre>    <p>这个时候我们在 QMUISpanTouchFixTextView 就可以通过是否点击到 QMUITouchableSpan 来决定不同行为了,对于点击是非常好处理的,代码如下:</p>    <pre>  <code class="language-java">@Override  public boolean performClick() {      if (!mTouchSpanHint) {          return super.performClick();      }      return false;  }</code></pre>    <p>对于press行为,就会有点棘手,因为 setPress 在 onTouchEvent 多次调用,而且在 QMUILinkTouchDecorHelper#onTouchEvent 前就会被调用到,所以不能简单的用 mTouchSpanHint 这个变量来管理。来看看我给出的方案:</p>    <pre>  <code class="language-java">// 记录每次真正传入的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);      }  }</code></pre>    <p>这几个步骤相互耦合,静下心好好理解下。这样就顺利的解决了第二个问题。那么我们来看看如何消除 MovementMethod 造成TextView对事件的消耗行为。</p>    <p>调用 setMovementMethod 为何会使得TextView必然消耗事件呢?我们可以看看源码:</p>    <pre>  <code class="language-java">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);      }  }</code></pre>    <p>原来设置MovementMethod后会把 clickable , longClickable 和 focusable 都设置为true,这样必然TextView会消耗事件了。因此我们想到的解决方案就是:如果我们想不让TextView消耗事件,那么我们就在 setMovementMethod 之后再改一次 clickable , longClickable 和 focusable 。</p>    <pre>  <code class="language-java">public void setShouldConsumeEvent(boolean shouldConsumeEvent) {      mShouldConsumeEvent = shouldConsumeEvent;      setFocusable(shouldConsumeEvent);      setClickable(shouldConsumeEvent);      setLongClickable(shouldConsumeEvent);  }    public void setMovementMethodCompat(MovementMethod movement){      setMovementMethod(movement);      if(!mShouldConsumeEvent){          setShouldConsumeEvent(false);      }  }</code></pre>    <p>仅仅这样还不够,我们还必须在 onTouchEvent 里面返回false:</p>    <pre>  <code class="language-java">@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;  }</code></pre>    <p>经过层层fix,我们终于可以给出一份不错的封装代码提供给业务方使用了:</p>    <pre>  <code class="language-java">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);      }  }</code></pre>    <p>参考链接:</p>    <p><a href="/misc/goto?guid=4959742861089200542" rel="nofollow,noindex">TextView ClickableSpan 事件分发的两个坑</a></p>    <p> </p>    <p>来自:http://blog.cgsdream.org/2017/03/22/textview-highlight-clickablespan/</p>    <p> </p>