TextView应用笔记
TextView的使用
TextView是Android原生控件中最常用的控件之一
动态的替换字符串中某个字符,这是一个很常用的需求,在android中,可以使用XLIFF,全称叫 XML 本地化数据交换格式,英文全称 XML Localization Interchange File Format,来实现字符的替换,比如“参与话题XX人”,在string资源中设置字符串<string>你好A,欢迎使用我们的App。</string>,然后在代码中赋值:
String welcome = getString(R.string.welcome, "小丸子");
TextView中富文本的使用及原理简介
在TextView中,setText的前置参数是CharSquence接口,而不是String类,这给了TextView很强大的空间,比如,可以通过SpannableString设置富文本显示。首先,看一下CharSquence与String以及SpannableString的继承关系:
通过设置Span改变文本样式的原理
在TextView中,文本的最终样式由TextView的onDraw中的pain决定,而setText传入的是一个CharSquence,我们可以通过实例化一个SpannableString去设置需要的Span,而所有的Span类都继承了CharacterStyle抽象类,并重写了内部的抽象方法。由此,我们可以判断,TextView最终是通过调用CharacterStyle来更新pain的样式的。先看一下CharacterStyle的内部方法:
public abstract class CharacterStyle {
public abstract void updateDrawState(TextPaint tp);
/**
* A given CharacterStyle can only applied to a single region of a given
* Spanned. If you need to attach the same CharacterStyle to multiple
* regions, you can use this method to wrap it with a new object that
* will have the same effect but be a distinct object so that it can
* also be attached without conflict.
*/
public static CharacterStyle wrap(CharacterStyle cs) {
if (cs instanceof MetricAffectingSpan) {
return new MetricAffectingSpan.Passthrough((MetricAffectingSpan) cs);
} else {
return new Passthrough(cs);
}
}
/**
* Returns "this" for most CharacterStyles, but for CharacterStyles
* that were generated by {@link #wrap}, returns the underlying
* CharacterStyle.
*/
public CharacterStyle getUnderlying() {
return this;
}
/**
* A Passthrough CharacterStyle is one that
* passes {@link #updateDrawState} calls through to the
* specified CharacterStyle while still being a distinct object,
* and is therefore able to be attached to the same Spannable
* to which the specified CharacterStyle is already attached.
*/
private static class Passthrough extends CharacterStyle {
……
}
}
可以看到有一个抽象方法updateDrawState(TextPaint tp),明显这就是修改Pain的入口了。我们看一下TextView中onDraw相关的源码:
protected void onDraw(Canvas canvas) {
restartMarqueeIfNeeded();
// Draw the background for this view
super.onDraw(canvas);
final int compoundPaddingLeft = getCompoundPaddingLeft();
final int compoundPaddingTop = getCompoundPaddingTop();
final int compoundPaddingRight = getCompoundPaddingRight();
final int compoundPaddingBottom = getCompoundPaddingBottom();
final int scrollX = mScrollX;
final int scrollY = mScrollY;
final int right = mRight;
final int left = mLeft;
final int bottom = mBottom;
final int top = mTop;
final boolean isLayoutRtl = isLayoutRtl();
final int offset = getHorizontalOffsetForDrawables();
final int leftOffset = isLayoutRtl ? 0 : offset;
final int rightOffset = isLayoutRtl ? offset : 0 ;
final Drawables dr = mDrawables;
……
final int cursorOffsetVertical = voffsetCursor - voffsetText;
Path highlight = getUpdatedHighlightPath();
if (mEditor != null) {
mEditor.onDraw(canvas, layout, highlight, mHighlightPaint, cursorOffsetVertical);
} else {
layout.draw(canvas, highlight, mHighlightPaint, cursorOffsetVertical);
}
if (mMarquee != null && mMarquee.shouldDrawGhost()) {
final float dx = mMarquee.getGhostOffset();
canvas.translate(layout.getParagraphDirection(0) * dx, 0.0f);
layout.draw(canvas, highlight, mHighlightPaint, cursorOffsetVertical);
}
canvas.restore();
}
在TextView的OnDraw中,并未直接调用updateDrawState(TextPaint tp),重点看layout.draw:
public void draw(Canvas canvas, Path highlight, Paint highlightPaint,
int cursorOffsetVertical) {
final long lineRange = getLineRangeForDraw(canvas);
int firstLine = TextUtils.unpackRangeStartFromLong(lineRange);
int lastLine = TextUtils.unpackRangeEndFromLong(lineRange);
if (lastLine < 0) return;
drawBackground(canvas, highlight, highlightPaint, cursorOffsetVertical,
firstLine, lastLine);
drawText(canvas, firstLine, lastLine);
}
里面有一个drawText方法,在该方法中会通过TextLine去调用draw方法,最终会嵌套调用到handleRun方法:
for (int i = start, inext; i < measureLimit; i = inext) {
TextPaint wp = mWorkPaint;
wp.set(mPaint);
inext = mMetricAffectingSpanSpanSet.getNextTransition(mStart + i, mStart + limit) -
mStart;
int mlimit = Math.min(inext, measureLimit);
ReplacementSpan replacement = null;
for (int j = 0; j < mMetricAffectingSpanSpanSet.numberOfSpans; j++) {
// Both intervals [spanStarts..spanEnds] and [mStart + i..mStart + mlimit] are NOT
// empty by construction. This special case in getSpans() explains the >= & <= tests
if ((mMetricAffectingSpanSpanSet.spanStarts[j] >= mStart + mlimit) ||
(mMetricAffectingSpanSpanSet.spanEnds[j] <= mStart + i)) continue;
MetricAffectingSpan span = mMetricAffectingSpanSpanSet.spans[j];
if (span instanceof ReplacementSpan) {
replacement = (ReplacementSpan)span;
} else {
// We might have a replacement that uses the draw
// state, otherwise measure state would suffice.
span.updateDrawState(wp);
}
}
if (replacement != null) {
x += handleReplacement(replacement, wp, i, mlimit, runIsRtl, c, x, top, y,
bottom, fmi, needWidth || mlimit < measureLimit);
continue;
}
for (int j = i, jnext; j < mlimit; j = jnext) {
jnext = mCharacterStyleSpanSet.getNextTransition(mStart + j, mStart + mlimit) -
mStart;
wp.set(mPaint);
for (int k = 0; k < mCharacterStyleSpanSet.numberOfSpans; k++) {
// Intentionally using >= and <= as explained above
if ((mCharacterStyleSpanSet.spanStarts[k] >= mStart + jnext) ||
(mCharacterStyleSpanSet.spanEnds[k] <= mStart + j)) continue;
CharacterStyle span = mCharacterStyleSpanSet.spans[k];
span.updateDrawState(wp);
}
x += handleText(wp, j, jnext, i, inext, runIsRtl, c, x,
top, y, bottom, fmi, needWidth || jnext < measureLimit);
}
}
该方法会遍历文字的所有Span,并调用updateDrawState(wp)方法。整个流程大致为TextView的onDraw----》Layout的draw----》TextLine的Draw----》CharacterStyle的updateDrawState(如果设置的有Span样式)。
SpannableString的使用,类微博中#话题和@提到的人的实现
在实现微博的话题和提到的人功能时,需要对文字进行检索匹配,而在android中,对于文字的处理,提供了Linkify工具类可以方便的使用正则来匹配位置并添加一个可自定义的schema的可点击样式。关于Linkidy官方给出的介绍如下:
Linkify take a piece of text and a regular expression and turns all of the regex matches in the text into clickable links.
但该方法存在局限性,所有的点击事件都会触发一个startActivity,且必须要在配置清单中设置对应的intent-filter来相应该事件,这就需要我们自己规范shecma,当然我们可以通过自定义的URLSpan来重写对应的跳转请求。以Miss项目为例,在Miss一期中,只要求了对话题的匹配,具体的步骤如下:
- 使用Linkify工具类具体正则匹配:
Linkify.addLinks(value, NewsPatterns.TOPIC_URL, NewsPatterns.TOPIC_SCHEME);
- 替换URLSpan:
URLSpan[] urlSpans = value.getSpans(0, value.length(), URLSpan.class);
for (final URLSpan urlSpan : urlSpans) {
if (urlSpan.getURL().startsWith(NewsPatterns.TOPIC_SCHEME)) {
String topic = urlSpan.getURL().substring(NewsPatterns.TOPIC_SCHEME.length(), urlSpan.getURL().length());
//不识别空格话题和大于30字话题
String group = topic.substring(1, topic.length() - 1).trim();
if (1 > group.length() || group.length() > 30) {
value.removeSpan(urlSpan);
continue;
}
}
final MBlogURLSpan newsSpan = new MBlogURLSpan(urlSpan.getURL(), id, topicId);
int start = value.getSpanStart(urlSpan);
int end = value.getSpanEnd(urlSpan);
value.removeSpan(urlSpan);
value.setSpan(new MissClickableSpan() {
@Override
public void onClick(View widget) {
newsSpan.onClick(widget);
}
}, start, end, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
value.setSpan(newsSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
将URLSpan替换为自定义的MBlogURLSpan,同时设置了一个ClickableSpan,在该类中自定义了点击事件:
public void onClick(View widget) {
if (!TextUtils.isEmpty(getURL())) {
Uri uri = Uri.parse(getURL());
if (uri.getScheme().startsWith(NewsPatterns.TOPIC_COMPARE_SCHEME)) {
String topic = getURL();
topic = topic.substring(NewsPatterns.TOPIC_SCHEME.length(), topic.length());
ActivityDetailTopic.startActivity(widget.getContext(), mId, mTopicId, topic);
} else if (uri.getScheme().startsWith(NewsPatterns.MENTION_COMPARE_SCHEME)) {
// TODO: 16/6/7 跳转@人页面
}
}
}
- 开启点击事件
在设置完一个ClickSpan后,点击对应文字区域并不能触发对应的点击事件,这是由于 一般情况下,Android为TextView提供了一个setMovementMethod方法,通过传入一个LinkMovementMethod来实现对点击事件的触发响应:
mContentText.setMovementMethod(LinkMovementMethod.getInstance());
但是,在RecyclerView中使用的话,会出现点击事件冲突的情况,比如你要对单个ItemView进行事件的响应,而在你对ItmeView中的TextView使用了setMovementMethod方法后,所有事件都被拦截了,我们可以看一下LinkMovementMethod的源码:
@Override
public boolean onTouchEvent(TextView widget, Spannable buffer,
MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_UP ||
……
ClickableSpan[] link = buffer.getSpans(off, off, ClickableSpan.class);
if (link.length != 0) {
if (action == MotionEvent.ACTION_UP) {
link[0].onClick(widget);
} else if (action == MotionEvent.ACTION_DOWN) {
Selection.setSelection(buffer,
buffer.getSpanStart(link[0]),
buffer.getSpanEnd(link[0]));
}
return true;
} else {
Selection.removeSelection(buffer);
}
}
return super.onTouchEvent(widget, buffer, event);
}
在onTouchEvent,做了判断,如果为在ClickSpan范围内,就会向上传个父类的onTouchEvnet,最后会调用到Touch类里的onTouchEvnet方法:
public static boolean onTouchEvent(TextView widget, Spannable buffer,
MotionEvent event) {
DragState[] ds;
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
ds = buffer.getSpans(0, buffer.length(), DragState.class);
for (int i = 0; i < ds.length; i++) {
buffer.removeSpan(ds[i]);
}
buffer.setSpan(new DragState(event.getX(), event.getY(),
widget.getScrollX(), widget.getScrollY()),
0, 0, Spannable.SPAN_MARK_MARK);
return true;
case MotionEvent.ACTION_UP:
ds = buffer.getSpans(0, buffer.length(), DragState.class);
for (int i = 0; i < ds.length; i++) {
buffer.removeSpan(ds[i]);
}
if (ds.length > 0 && ds[0].mUsed) {
return true;
} else {
return false;
}
……
return false;
}
这里可以看到,在MotionEvent.ACTION_DOWN响应的处理中,会返回一个true方法,这就导致了所有的事件都被TextView所拦截。国外有大神给出了解决方案,通过自定义TextView的setOnTouchListener方法,并自定义一个OnTouchListener类,其思路是在响应MotionEvent.ACTION_DOWN点击事件时,先判断点击的响应点是否在ClickSpan样式文字范围内,然后在决定是否拦截,如果是,把事件传递到LinkMovementMethod的onTouch事件中,自定义的OnTouchListener如下:
@Override
public boolean onTouch(View v, MotionEvent event) {
Layout layout = ((TextView) v).getLayout();
if (layout == null) {
return false;
}
int x = (int) event.getX();
int y = (int) event.getY();
int line = layout.getLineForVertical(y);
int offset = layout.getOffsetForHorizontal(line, x);
TextView tv = (TextView) v;
SpannableString value = SpannableString.valueOf(tv.getText());
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN: //监听按下事件,默认不拦截
MBlogURLSpan[] urlSpans = value.getSpans(0, value.length(), MBlogURLSpan.class);
int findStart = 0;
int findEnd = 0;
for (MBlogURLSpan urlSpan : urlSpans) {
int start = value.getSpanStart(urlSpan);
int end = value.getSpanEnd(urlSpan);
if (start <= offset && offset <= end) { //在话题范围内
find = true;
findStart = start;
findEnd = end;
break;
}
}
ClickableSpan[] clickableSpens = value.getSpans(0, value.length(), ClickableSpan.class);
for (ClickableSpan clickSpan : clickableSpens) {
int start = value.getSpanStart(clickSpan);
int end = value.getSpanEnd(clickSpan);
if (start <= offset && offset <= end) { //在话题范围内
find = true;
findStart = start;
findEnd = end;
break;
}
}
float lineWidth = layout.getLineWidth(line);
find &= (lineWidth >= x);
if (find) {//话题内,将事件传递到LinkMovementMethod处理,该类会响应富文本中的点击事件
LinkMovementMethod.getInstance().onTouchEvent(tv, value, event);
ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(v.getResources().getColor(R.color.home_item_special_click));
value.setSpan(foregroundColorSpan, findStart, findEnd,
Spanned.SPAN_INCLUSIVE_INCLUSIVE);
//Android has a bug, sometime TextView wont change its value when you modify SpannableString,
// so you must setText again, test on Android 4.3 Nexus4
tv.setText(value);
}
return find;
case MotionEvent.ACTION_MOVE:
if (find) {
LinkMovementMethod.getInstance().onTouchEvent(tv, value, event);
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if (find) {
LinkMovementMethod.getInstance().onTouchEvent(tv, value, event);
BackgroundColorSpan[] backgroundColorSpans = value
.getSpans(0, value.length(), BackgroundColorSpan.class);
for (BackgroundColorSpan backgroundColorSpan : backgroundColorSpans) {
value.removeSpan(backgroundColorSpan);
}
tv.setText(value);
find = false;
}
break;
}
return false;
}
通过对TextView的setOnTouchListener方法设置自定义OnTouchListener:
mContent.setOnTouchListener(new ClickableTextViewMentionLinkOnTouchListener());
以上的方法即可实现话题功能。