如何修改TextView链接点击实现(包含链接生成与点击原理分析)

** 这篇文章的主要目的是想要大家学习如何了解实现,修改实现,以达到举一反三,自行解决问题的目的。*

某天遇到这么一个需求:在TextView中的文本链接要支持跳转,嗯,这个好办,TextView本身是支持的,我们只用添加一项属性就可以搞定:

  android:autoLink="web"

在添加后发现确实是有效果了。但是如果我们不想使用系统默认的浏览器,而是想要这个地址跳入某个页面或者自己应用内的浏览器该怎么办呢?

好,接下来就是我们要实现的步骤。

俗话说,知己知彼,百战不殆。所以将我们的步骤分为两步:

  • 1.了解autoLink的实现。
  • 2.修改autoLink的实现。
  • 3.运行&测试

了解autoLink的实现

既然我们可以知道设置autoLink属性就可以实现链接的自动识别与跳转,那么我们就从autoLink开始分析。

打开TextView.java,寻找autoLink的相关配置读取参数:

            case com.android.internal.R.styleable.TextView_autoLink:
                mAutoLinkMask = a.getInt(attr, 0);
                break;

我们发现,与autoLink有关的是一个名为mAutoLinkMask的成员属性,那也就是说:所有与autoLink有关的配置都有这个成员属性脱不了干系。

那我们就可以在整个TextView的实现中寻找mAutoLinkMask的身影:


    public void append(CharSequence text, int start, int end) {
        if (!(mText instanceof Editable)) {
            setText(mText, BufferType.EDITABLE);
        }

        ((Editable) mText).append(text, start, end);

        if (mAutoLinkMask != 0) {
            boolean linksWereAdded = Linkify.addLinks((Spannable) mText, mAutoLinkMask);
            if (linksWereAdded && mLinksClickable && !textCanBeSelected()) {
                setMovementMethod(LinkMovementMethod.getInstance());
            }
        }
    }

    ...

    private void setText(CharSequence text, BufferType type,
                         boolean notifyBefore, int oldlen) {

        ...

        if (mAutoLinkMask != 0) {
            Spannable s2;

            if (type == BufferType.EDITABLE || text instanceof Spannable) {
                s2 = (Spannable) text;
            } else {
                s2 = mSpannableFactory.newSpannable(text);
            }

            if (Linkify.addLinks(s2, mAutoLinkMask)) {
                text = s2;
                type = (type == BufferType.EDITABLE) ? BufferType.EDITABLE : BufferType.SPANNABLE;

                /*
                 * We must go ahead and set the text before changing the
                 * movement method, because setMovementMethod() may call
                 * setText() again to try to upgrade the buffer type.
                 */
                mText = text;

                // Do not change the movement method for text that support text selection as it
                // would prevent an arbitrary cursor displacement.
                if (mLinksClickable && !textCanBeSelected()) {
                    setMovementMethod(LinkMovementMethod.getInstance());
                }
            }
        }

        ...
    }

    ...

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        ...

            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 = ((Spannable) mText).getSpans(getSelectionStart(),
                        getSelectionEnd(), ClickableSpan.class);

                if (links.length > 0) {
                    links[0].onClick(this);
                    handled = true;
                }
            }

        ...

        return superResult;
    }

mAutoLinkMask出现的地方并不多,除了基本的get、set方法之外,它出现在了3个地方,分别是:append(CharSequence text, int start, int end)、setText(CharSequence text, BufferType type)和onTouchEvent(MotionEvent event)。

其中,append方法与setText方法都是用于添加文本的方法,也就说,所有填入TextView的文本都会被加上autoLink的功能。这两个方法内部都调用了Linkify.addLinks(Spannable text, int mask)方法。

Linkify.addLinks(Spannable text, int mask)的注释是这么写的:

Scans the text of the provided Spannable and turns all occurrences of the link types indicated in the mask into clickable links. If the mask is nonzero, it also removes any existing URLSpans attached to the Spannable, to avoid problems if you call it repeatedly on the same text.

这段话说了什么呢,翻译一下:

首先对给定的文本进行扫描,然后将所有的链接文本转换为可点击的链接。如果第二个参数不为空,那么它还是会将已有的URLSpan移除,来避免一些问题。

然后我们进入这个方法探一探究竟,看看它是怎么实现的:

    public static final boolean addLinks(@NonNull Spannable text, @LinkifyMask int mask) {
        if (mask == 0) {
            return false;
        }

        URLSpan[] old = text.getSpans(0, text.length(), URLSpan.class);

        for (int i = old.length - 1; i >= 0; i--) {
            text.removeSpan(old[i]);
        }

        ArrayList<LinkSpec> links = new ArrayList<LinkSpec>();

        if ((mask & WEB_URLS) != 0) {
            gatherLinks(links, text, Patterns.AUTOLINK_WEB_URL,
                new String[] { "http://", "https://", "rtsp://" },
                sUrlMatchFilter, null);
        }

        if ((mask & EMAIL_ADDRESSES) != 0) {
            gatherLinks(links, text, Patterns.AUTOLINK_EMAIL_ADDRESS,
                new String[] { "mailto:" },
                null, null);
        }

        if ((mask & PHONE_NUMBERS) != 0) {
            gatherTelLinks(links, text);
        }

        if ((mask & MAP_ADDRESSES) != 0) {
            gatherMapLinks(links, text);
        }

        pruneOverlaps(links);

        if (links.size() == 0) {
            return false;
        }

        for (LinkSpec link: links) {
            applyLink(link.url, link.start, link.end, text);
        }

        return true;
    }

这个方法做了以下工作:

  • 1.对旧的Span进行移除,我们看到,这里获取Span返回的类型是URLSpan,请留意一下,我们待会会看到它很多次。
  • 2.对给定的WEB_URLS、EMAIL_ADDRESSES、PHONE_NUMBERS、MAP_ADDRESSES类型进行链接查找。
  • 3.生成新的Span。

这是最后生成新的Span的方法,它这里用了URLSpan:

    private static final void applyLink(String url, int start, int end, Spannable text) {
        URLSpan span = new URLSpan(url);

        text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

这里的URLSpan是个什么鬼?和我们想了解的有什么关系?

其实我们才刚刚了解到生成,我们应该还没忘记,TextView的onTouchEvent方法还没讲到,onTouchEvent方法内部也是有mAutoLinkMask标志的,我们回去看。

在onTouchEvent方法内有很重要的一段:

            if (touchIsFinished && mLinksClickable && mAutoLinkMask != 0 && textIsSelectable) {
                ClickableSpan[] links = ((Spannable) mText).getSpans(getSelectionStart(),
                        getSelectionEnd(), ClickableSpan.class);

                if (links.length > 0) {
                    links[0].onClick(this);
                    handled = true;
                }
            }

我们这个时候应该明白,那些链接也走的是TextView的onTouchEvent方法,这当然是理所当然的。不过在这里,链接的点击是通过ClickableSpan的onClick方法实现的,那这里的ClickableSpan究竟是谁呢?

我们通过查阅文档发现,ClickableSpan的唯一子类就是我们刚刚见过的URLSpan。但这仅仅是我们的猜测,我们还需要通过实际的运行来查看是否就是URLSpan在作用链接的点击事件。

我们写一个小小的实现:

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:autoLink="web"
        android:text="Hello! https://developer.android.google.cn/reference/android/text/style/ClickableSpan.html" />

然后运行看看TextView的mText的属性内部组成:


这里写图片描述

我们可以发现在mText的mSpans属性中的有一个URLSpan的存在。那到此为止点击的处理就确信是URLSpan的作用无疑了。

那我们可以看看URLSpan自己是怎么实现的:

public class URLSpan extends ClickableSpan implements ParcelableSpan {

    private final String mURL;

    public URLSpan(String url) {
        mURL = url;
    }

    public URLSpan(Parcel src) {
        mURL = src.readString();
    }

    public int getSpanTypeId() {
        return TextUtils.URL_SPAN;
    }

    public int describeContents() {
        return 0;
    }

    public void writeToParcel(Parcel dest, int flags) {
        dest.writeString(mURL);
    }

    public String getURL() {
        return mURL;
    }

    @Override
    public void onClick(View widget) {
        Uri uri = Uri.parse(getURL());
        Context context = widget.getContext();
        Intent intent = new Intent(Intent.ACTION_VIEW, uri);
        intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName());
        context.startActivity(intent);
    }
}

它的实现很简洁,我们看到了我们想找的onClick方法,就是这处理了我们的链接点击事件了。那么我们该如何更改呢?

修改autoLink的实现

如果有对热修复了解的话,那么肯定对修改dexElements不会陌生。在这里我们也是相同的思路:通过反射将mSpans属性中URLSpan对象改为我们自己创建的自定义对象。

那么接下来就是我们的实现过程:

为了方便使用,我们扩展一下TextView:新建一个自定义View并继承TextView,我们将这个自定义View命名为:AutoLinkTextView。

我们在它的构造方法内分别设置WEB属性,否则不会自动识别网址链接。

代码实现如下:

    public AutoLinkTextView(Context context) {
        super(context);
        setAutoLinkMask(Linkify.WEB_URLS);
    }

    public AutoLinkTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        setAutoLinkMask(Linkify.WEB_URLS);
    }

    public AutoLinkTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setAutoLinkMask(Linkify.WEB_URLS);
    }

好,做好了铺垫之后,我们在上面了解到,mAutoLinkMask这个标志属性出现在了append(CharSequence text, int start, int end)及setText(CharSequence text, BufferType type)这两个方法内。所以,我们需要对这两个方法进行扩展。

在AutoLinkTextView的类中复写这两个方法:

    @Override
    public void setText(CharSequence text, BufferType type) {
        super.setText(text, type);
        replace();
    }

    @Override
    public void append(CharSequence text, int start, int end) {
        super.append(text, start, end);
        replace();
    }

这两个方法除了调用基类的方法之外,还调用了一个名为replace的方法。这个方法就是接下来我们对原有的URLSpan进行替换的地方。

replace()方法的实现如下:

    private void replace() {
        CharSequence text = getText();
        
        if (text instanceof SpannableString) {
            SpannableString spannableString = (SpannableString) text;
            Class<? extends SpannableString> aClass = spannableString.getClass();

            try {
                //mSpans属性属于SpannableString的父类成员
                Class<?> aClassSuperclass = aClass.getSuperclass();
                Field mSpans = aClassSuperclass.getDeclaredField("mSpans");
                mSpans.setAccessible(true);
                Object o = mSpans.get(spannableString);

                if (o.getClass().isArray()) {
                    Object objs[] = (Object[]) o;

                    if (objs.length > 1) {
                        //这里的第0个位置不稳妥,实际环境可能会有多个链接地址
                        Object obj = objs[0];
                        if (obj.getClass().equals(URLSpan.class)) {
                        
                            //获取URLSpan的mURL值,用于新的URLSpan的生成
                            Field oldUrlField = obj.getClass().getDeclaredField("mURL");
                            oldUrlField.setAccessible(true);
                            Object o1 = oldUrlField.get(obj);

                            //生成新的自定义的URLSpan,这里我们将这个自定义URLSpan命名为ExtendUrlSpan
                            Constructor<?> constructor = ExtendUrlSpan.class.getConstructor(String.class);
                            constructor.setAccessible(true);
                            Object newUrlField = constructor.newInstance(o1.toString());
                            
                            //替换
                            objs[0] = newUrlField;
                        }
                    }
                }
            } catch (NoSuchFieldException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
            } catch (InstantiationException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }
        }
    }
}

在上面的方法中提到了一个ExtendUrlSpan类,这是我们自己写的扩展类,用于定义自己的实现。代码如下:

public class ExtendUrlSpan extends URLSpan {
    public ExtendUrlSpan(String url) {
        super(url);
    }

    public ExtendUrlSpan(Parcel src) {
        super(src);
    }

    @Override
    public void onClick(View widget) {
        //这个方法会在点击链接的时候调用,可以实现自定义事件
        Toast.makeText(widget.getContext(), getURL(), Toast.LENGTH_SHORT).show();       
    }
}

为了示例说明,这里在点击时显示了一个吐司,吐司的内容是点击的链接地址。

到此为止,我们更改结束。接下来看运行效果。

运行&测试

我们将原有的TextView更换为刚刚实现的AutoLinkTextView:

    <com.sahadev.support.AutoLinkTextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:autoLink="web"
        android:text="Hello! https://developer.android.google.cn/reference/android/text/style/ClickableSpan.html" />

启动,运行:

这里写图片描述

这说明我们的更改是生效的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容