TextView应用笔记

2018-07-21  本文已影响36人  BooQin

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.addLinks(value, NewsPatterns.TOPIC_URL, NewsPatterns.TOPIC_SCHEME);
        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());

以上的方法即可实现话题功能。

上一篇 下一篇

猜你喜欢

热点阅读