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

前言

相信大家应该都有所体会,对于一个社交性质的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);
 }
}

总结

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

(0)

相关推荐

  • Android TextView中部分文字高亮显示

    Android  TextView中部分文字高亮显示 /** * 关键字高亮显示 * * @param context 上下文 * @param text 需要显示的文字 * @param target 需要高亮的关键字 * @param color 高亮颜色 * @param start 头部增加高亮文字个数 * @param end 尾部增加高亮文字个数 * @return 处理完后的结果 */ public static SpannableString highlight(Context

  • Android TextView实现词组高亮的示例代码

    本文介绍了Android TextView实现词组高亮的示例代码,分享给大家,具体如下: HighlightTextView Android文本高亮控件,基于View实现. 特点 文本高亮 单词自动换行 高亮词组保持在同一行显示 效果如下: 主要逻辑: 两个 Paint 负责绘制不同的文字 在每次绘制之前计算将要绘制的文本是否会超出屏幕宽度,如果超出则换行 protected void onDraw(Canvas canvas) { super.onDraw(canvas); float x_d

  • Android中实现词组高亮TextView方法示例

    前言 本文主要给大家介绍了关于Android实现词组高亮TextView的相关内容,分享出来供大家参考学习,下面话不多说了,来一起看看详细的介绍吧. HighlightTextView Android文本高亮控件,基于View实现. 特点 文本高亮 单词自动换行 高亮词组保持在同一行显示 主要逻辑: 两个 Paint 负责绘制不同的文字 在每次绘制之前计算将要绘制的文本是否会超出屏幕宽度,如果超出则换行 protected void onDraw(Canvas canvas) { super.o

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

    前言 相信大家应该都有所体会,对于一个社交性质的App,业务上少不了给一段文本加上@功能.话题功能,或者是评论上要高亮人名的需求.当然,Android为我们提供了ClickableSpan,用于解决TextView部分内容可点击的问题,但却附加了一堆的坑点: ClickableSpan 默认没有高亮行为,也不能添加背景颜色: ClickableSpan 必须配合 MovementMethod 使用 一旦使用 MovementMethod,TextView 必定消耗事件 当点击ClickableS

  • Android开发中TextView文本过长滚动显示实现方法分析

    本文实例讲述了Android开发中TextView文本过长滚动显示实现方法.分享给大家供大家参考,具体如下: 项目中在使用TextView时,总会有因要显示的内容过多而需要我们进行处理的问题.我们第一时间想到的是TextView的android:ellipsize属性,比如 android:ellipsize="end",效果是在文字的尾部打三个小点. 但是这个属性要配合android:singLine="true"使用.通常来说,要实现尾端三个点的省略号形式是比较

  • Android中TextView自动适配文本大小的几种解决方案

    目录 TextView文本大小自动适配与TextView边距的去除 一.Autosizing的方式(固定宽度) 二.自定义View的方式(固定宽度) 三.使用工具类自行计算(非控件固定宽度) 四.去除TextView的边距 总结 TextView文本大小自动适配与TextView边距的去除 标题太难取了,其实本文主要就是讲如何控制文本大小,让其自动适配宽度,其次我们还需要精准控制Text的高度和宽度间距等属性. 一般我们的布局都是分 match parent 和 wrap content 而他们

  • Android中TextView实现垂直滚动和上下滚动效果

    布局里面就是两个自定义的TextView,上面的左右滑动的是AutoHorizontalScrollTextView; 下面上下滚动的是AutoVerticalScrollTextView; 上面左右滑动的非常好实现,直接把AutoHorizontalScrollTextView复制到项目中,复制全类名到布局文件中,和系统TextView一样,只需设置文本其他什么都不用设置: 下面垂直滚动的AutoVerticalScrollTextView相比AutoHorizontalScrollTextV

  • Android中TextView显示插入的图片实现方法

    本文实例讲述了Android中TextView显示插入的图片实现方法.分享给大家供大家参考,具体如下: Android系统默认给TextView插入图片提供了三种方式: 1.ImageSpan 2.Html.ImageGetter 3.TextView.setCompoundDrawables(left, top, right, bottom) 1.TextView使用ImageSpan显示图片 ImageSpan span = new ImageSpan(this, R.drawable.ic

  • Android 中TextView的使用imageview被压缩问题解决办法

    Android 中TextView的使用imageview被压缩问题解决办法 看下运行效果图: 今天解bug的时候遇到一个奇怪的问题:listview的item由一个textview和一个imageview组成,父布局是线性水平排列.我的本意是imageview显示相同的图片,textview显示文本,但是运行程序后发现,当某个textview的文本较多时,imageview会被压缩,刚开始没注意,检查代码了好久. 代码示例如下: <!--文本少的item--> <LinearLayou

  • Android中TextView局部变色功能实现

    在做项目的时候,遇到过一行文字有两种颜色.在菜鸟的时候直接会想到用多个TextView来实现.后来自己学的多了就找到了更为简单的方法了. 直接上代码: 方法一: xml代码片段: <TextView android:id="@+id/tv" android:layout_width="wrap_content" android:layout_height="wrap_content" /> Java代码片段: TextView tv=

  • Android编程中TextView宽度过大导致Drawable无法居中问题解决方法

    本文实例讲述了Android编程中TextView宽度过大导致Drawable无法居中问题解决方法.分享给大家供大家参考,具体如下: 在做项目的时候,很多时候我们都要用到文字和图片一起显示,一般设置TextView的DrawableLeft.DrawableRight.DrawableTop.DrawableBottom就行了.但是有一种情况是当TextView的熟悉是fill_parent或者使用权重的时候并且设置了起Gravity的ceter的时候,Drawable图片是无法一起居中的,为了

  • Android 中RecycleView实现item的点击事件

    Android 中RecycleView实现item的点击事件 RecycleView现在已经越来越受到大家的重视,因为他既可以代替listView还可以代替GridView,但是RecycleView本身不不像ListView那样具有setOnItemClickListener,这个关于子item的点击,但是我们往往会用到RecycleView并且希望他的自孩子可以被点击,那么如何实现他的item的点击事件呢? 首先我们在RecyclerView.ViewHolder中的实现: public

  • Android中TextView动态设置缩进距离的方法

    需求是需要在TextView前端加入一个标签展示. 最终效果图如下: 根据效果图,很容易就能想到使用SpannableStringBuilder,在这里使用到的就是LeadingMarginSpan这个类了. 官方说明: A paragraph style affecting the leading margin. There can be multiple leading margin spans on a single paragraph; they will be rendered in

随机推荐