前言
按照惯例先上效果图
在项目开发中,需求是实现漂亮复杂的textview span效果,如图上图。看到效果图的时候,觉得简单一个BackgroundColorSpan就可以解决,然而并不然,仔细观察发现,背景带有圆角,当整块不能在一行显示的时候,需要换行,背景和字体之间是带有一定边距的。
紧张之下打开了,BackgroundColorSpan源码,发现里面并没像ReplacementSpan一样,提供onDraw的方法。看了其他的span发现,解决方案也只能继承ReplacementSpan。重写绘制了。对应的文本内容了。
一、因为不是特别复杂,所以直接贴上RoundBackgroundSpan源码,具体的参数说明,注释已经写的很清楚了
/**
* 圆角背景span
*/
public class RoundBackgroundSpan extends ReplacementSpan {
private int mSize;
private int mBgColor;
private int mDrawTextColor;
private int mRadius;
private int mSpace;
private int mDrawTextSize;
/**
*
* @param bgcolor 绘制背景颜色
* @param drawTextColor 绘制字体颜色
* @param drawTextSize 绘制字体大小
* @param radius 绘制圆角背景半径
* @param space 间距
*/
public RoundBackgroundSpan(int bgcolor, int drawTextColor, int drawTextSize, int radius, int space) {
mBgColor = bgcolor;
mDrawTextColor = drawTextColor;
mDrawTextSize = drawTextSize;
mRadius = radius;
mSpace = space;
}
/**
* mSize就是span的宽度,span有多宽,可以在这里随便定义规则
* 我的规则:这里text传入的是SpannableString,start,end对应setSpan方法相关参数
* 可以根据传入起始截至位置获得截取文字的宽度,最后加上左右间距
* @param paint
* @param text
* @param start 设置的对应的span起始位置
* @param end 设置的对应span终止位置
* @param fm
* @return
*/
@Override
public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
float textSize = paint.getTextSize();
paint.setTextSize(SizeUtils.dp2px(12));
mSize = (int) (paint.measureText(text, start, end) + 2 * mSpace);
paint.setTextSize(textSize);
return mSize;
}
@Override
public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
//缓存原有textView的一些属性
int color = paint.getColor();
float textSize = paint.getTextSize();
paint.setAntiAlias(true);
paint.setDither(true);
//背景颜色,绘制圆角背景
paint.setColor(mBgColor);
//设置文字背景矩形,x为span其实左上角相对整个TextView的x值,y为span左上角相对整个View的y值。paint.ascent()获得文字上边缘,paint.descent()获得文字下边缘
RectF oval = new RectF(x, y + paint.ascent(), x + mSize, y + paint.descent());
canvas.drawRoundRect(oval, mRadius, mRadius, paint);
float bgHeight = paint.descent()-paint.ascent();
//设置文字大小,文字颜色
paint.setTextSize(mDrawTextSize);
paint.setColor(mDrawTextColor);
float textHeight = paint.descent()-paint.ascent();
float diff = (bgHeight -textHeight)/4;
canvas.drawText(text, start, end, x + mSpace, y-diff, paint);
//恢复textView原有的默属性
paint.setColor(color);
paint.setTextSize(textSize);
}
实现代码有了,那要怎么使用呢?
/**
* 设置textView 带圆角背景span 主要类{@link RoundBackgroundSpan}
* 这边为了演示方便,把需要span数据直接拼接在了文本内容前面
* @param spanText 需要匹配背景span文字
* @param context 内容文本
* @param textColor 字体颜色
* @param bgColor 背景颜色
* @return
*/
public static Spannable getTextSpanBgColor(List<String> spanText,String context, int textColor, int bgColor) {
SpannableStringBuilder spannable = new SpannableStringBuilder();
//先拼接文本内容
if (spanText != null && spanText.size() > 0) {
for (int i = 0; i < spanText.size(); i++) {
String text = spanText.get(i);
spannable.append(text);
//这边是为了让两个带颜色的span分割开,也可以用replaseSpan去加,这边偷个懒
spannable.append(" ");
}
}
spannable.append(context);
//在设置对应span
//需要设置span文本起始位置
int mathStart =0;
for (int i = 0; i < spanText.size(); i++) {
String item = spanText.get(i);
int mathEnd = item.length() +mathStart;
RoundBackgroundSpan backgroundColorSpan = new RoundBackgroundSpan(bgColor,textColor,dp2px(12),dp2px(2),dp2px(7));
spannable.setSpan(backgroundColorSpan,mathStart,mathEnd, Spanned.SPAN_MARK_POINT);
//需要设置span终止位置
mathStart=mathEnd+" ".length();
}
return spannable;
}
效果图的的两个span间隔有空白间距,这个我在拼接文本内容的时候多加了一个空格(偷了个懒,也减少了设置了对应ReplaceSpan)。因为正常情况下,ReplaceSpan是直接替换对应位置上的内容。需要加的地方也是要有填补空格占位。要不然文本内容会直接被替换
***这边有个坑***
就是在使用ReplacementSpan的时候,如果文本内容是使用先设置了ReplacementSpan在去拼接对应文本,在有上面效果图那种存在多个且有换行的时候会导致Clickspan的点击事件位置出现偏差。
先设置ReplacmentSpan在设置文本内容是什么意思呢?
首先像上面的那种效果图的形式,业务场景是:需要设置span的内容后台是单独下发的,后面的文本内容也是单独下发的。需要设置span的内容是拼接在正常文本内容的前面。所以我在初次尝试的时候,我是先把前面需要展示的内容先设置对应ReplacementSpan,然后在去拼上后面的文本,就会出现点击事件错位。这边也给大家提个醒。避免重复踩坑。
二、由上面效果,引发出了TextView点击事件和ClickableSpan冲突血案问题。
1.当TextView设置了ClickableSpan点击事件,会拦截TextView对应父容器的点击事件
2.如果TextView也设置了点击事件,点击ClickableSpan会响应对应TextView的 点击事件。
分析原因:
我们知道TextViwe是属于view,根据view的事件分发机制了触发点击事件的时机回顾一下View的事件分发机制
View的事件分发
1.dispatchTouchEvent();分发
2.onTouchListener-->onTouch方法 如果有设置的话
3.onTouchEvent()
4.onClickListener-->onClick方法
结论:
1.如果onTouchListener的onTouch方法返回了true,那么view里面的onTouchEvent就不会被调用了。
顺序dispatchTouchEvent-->onTouchListener---return false-->onTouchEvent
2.如果view为disenable,则:onTouchListener里面不会执行,但是会执行onTouchEvent(event)方法
3.onTouchEvent方法中的ACTION_UP分支中触发onclick事件监听
onTouchListener-->onTouch方法返回true,消耗次事件。down,但是up事件是无法到达onClickListener.
onTouchListener-->onTouch方法返回false,不会消耗此事件
由上可以知道TextView的onClick事件肯定是在其的onTouchEvent中调用,跟进源码:
发现在TextView中的onTouchEvent中有这段代码
@Override
public boolean onTouchEvent(MotionEvent event) {
....省略部分代码.....
if ((mMovement != null || onCheckIsTextEditor()) && isEnabled()
&& mText instanceof Spannable && mLayout != null) {
boolean handled = false;
if (mMovement != null) {
handled |= mMovement.onTouchEvent(this, mSpannable, event);
}
final boolean textIsSelectable = isTextSelectable();
if (touchIsFinished && mLinksClickable && mAutoLinkMask != 0 && textIsSelectable) {
// The LinkMovementMethod which should handle taps on links has not been installed
// on non editable text that support text selection.
// We reproduce its behavior here to open links for these.
ClickableSpan[] links = mSpannable.getSpans(getSelectionStart(),
getSelectionEnd(), ClickableSpan.class);
if (links.length > 0) {
links[0].onClick(this);
handled = true;
}
}
....省略部分代码.....
return superResult;
}
上面代码中可以看出,当mMovement!=null的时候会调用其的mMovement.onTouchEvnet
MovementMethod 是一个接口,子类有 ArrowKeyMovementMethod, LinkMovementMethod, ScrollingMovementMethod 。
我们已LinkMovementMethod为例子。
@Override
public boolean onTouchEvent(TextView widget, Spannable buffer,
MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
int x = (int) event.getX();
int y = (int) event.getY();
x -= widget.getTotalPaddingLeft();
y -= widget.getTotalPaddingTop();
x += widget.getScrollX();
y += widget.getScrollY();
Layout layout = widget.getLayout();
int line = layout.getLineForVertical(y);
int off = layout.getOffsetForHorizontal(line, x);
ClickableSpan[] links = buffer.getSpans(off, off, ClickableSpan.class);
if (links.length != 0) {
ClickableSpan link = links[0];
if (action == MotionEvent.ACTION_UP) {
if (link instanceof TextLinkSpan) {
((TextLinkSpan) link).onClick(
widget, TextLinkSpan.INVOCATION_METHOD_TOUCH);
} else {
link.onClick(widget);
}
} else if (action == MotionEvent.ACTION_DOWN) {
if (widget.getContext().getApplicationInfo().targetSdkVersion
>= Build.VERSION_CODES.P) {
// Selection change will reposition the toolbar. Hide it for a few ms for a
// smoother transition.
widget.hideFloatingToolbar(HIDE_FLOATING_TOOLBAR_DELAY_MS);
}
Selection.setSelection(buffer,
buffer.getSpanStart(link),
buffer.getSpanEnd(link));
}
return true;
} else {
Selection.removeSelection(buffer);
}
}
return super.onTouchEvent(widget, buffer, event);
}
在他的OnToucheEvent中可以发现,当点击位置区域有设置span即,link.length!=0了会在ACTION_UP的时候调用 ClickableSpan 的 onClick。知道了原因。就可以找到想到了对应的解决思路。
三、解决办法
首先在解决之前,参考了这stackoverflow上的一个解决思路。原文在这边原文链接
在这个文章里面讲到一个点:因为项目中只是TextView可以部分点击,实际上,不需要大多数的功能,所以采用了自定义MovementMethod.
public class ClickableMovementMethod extends BaseMovementMethod {
private static ClickableMovementMethod sInstance;
public static ClickableMovementMethod getInstance() {
if (sInstance == null) {
sInstance = new ClickableMovementMethod();
}
return sInstance;
}
//action_down 是的时间
//因为自己项目中用到了长按复杂的功能,不设置加以判断,就不会触发TextView的setOnLongClickListener
private long downTime = 0;
@Override
public boolean canSelectArbitrarily() {
return false;
}
@Override
public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) {
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
if (action == MotionEvent.ACTION_DOWN) {
downTime = System.currentTimeMillis();
}
int x = (int) event.getX();
int y = (int) event.getY();
x -= widget.getTotalPaddingLeft();
y -= widget.getTotalPaddingTop();
x += widget.getScrollX();
y += widget.getScrollY();
Layout layout = widget.getLayout();
int line = layout.getLineForVertical(y);
int off = layout.getOffsetForHorizontal(line, x);
ClickableSpan[] link = buffer.getSpans(off, off, ClickableSpan.class);
if (link.length > 0) {
if (action == MotionEvent.ACTION_UP) {
long upTime = System.currentTimeMillis();
//按下的时间和抬起的时间差小于500ms就响应对应点击事件,大于的发返回false,不处理
if (upTime - downTime < 500) {
link[0].onClick(widget);
} else {
return false;
}
}
//设置了这个会导致,点击文字时会出现偏移,里面源码会去更新对应buffer的span
// else {
// Selection.setSelection(buffer, buffer.getSpanStart(link[0]), buffer.getSpanEnd(link[0]));
// }
return true;
} else {
//添加富文本点击事件 和 对应的parentView 点击事件时,富文本的点击事件会拦截 TextView的父容器(ParentView)的点击事件;所以要手动调用
//因为项目中文本有长按复制,也会和ClickSpan冲突,所以手动设置
if (action == MotionEvent.ACTION_UP) {
ViewParent parent = widget.getParent();
long upTime = System.currentTimeMillis();
if (upTime - downTime < 500) {
if (parent instanceof ViewGroup) {
((ViewGroup) parent).performClick();
}
}
}
Selection.removeSelection(buffer);
}
}
return false;
}
@Override
public void initialize(TextView widget, Spannable text) {
Selection.removeSelection(text);
}
}
然后使用它ClickableMovementMethod
,移动方法将不再消耗触摸事件。但是,TextView.setMovementMethod()
调用TextView.fixFocusableAndClickableSettings()
将可点击,可长按和可聚焦设置为true,这使得View.onTouchEvent()
消耗触摸事件。要解决此问题,只需重置三个属性即可。
textView.setMovementMethod(ClickableMovementMethod.getInstance());
textView.setClickable(false);
textView.setLongClickable(false);
但是对应上面的设置了TextView富文本点击事件,会影响到其父View的点击事件,这个具体原因还有待楼主去研究和验证。所以这边就不阐述原因只提高一个解决方案,如果你们知道,欢迎留言告知,不甚感激。。。。
使用方法:
布局文件代码:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/llRoot"
android:padding="10dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:textSize="14sp"
android:lineSpacingExtra="4dp"
android:text="文本内容"
android:textColor="#000000"
android:id="@+id/tvSpanText"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
能达到的效果:
1.点击LinearLayout能响应其的点击事件,
2.点击span的点击事件不会触发TextView的点击事件(因为如果文本中设置了ClickSpan,就不设置TextView的点击事件,而是把点击事件交给其父级LinearLayou响应来提高整体ItemView的点击事件范围,从而避免了点击了因ClickableSpan.onClick而触发TextView自身的点击事件,而点击了非span文本内容,把点击事件上移到父级响应)
3.TextView的setOnLongClickListener能够正常响应,且不触发其他点击事件
具体效果请参考效果图
具体的代码实现请参考:附上源码地址