Android Span进阶之路——ClickableSpan

Posted 精装机械师

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android Span进阶之路——ClickableSpan相关的知识,希望对你有一定的参考价值。

一、前言

    在android中,可以使用强大的标记(Span)对象来实现富文本展示,相比 html 而言更高效实用。关于 Android Span 的入门篇可以阅读 Android中强大的标记对象-Span。本文将对 ClickableSpan (可点击的Span)展开深入的学习。

二、基本使用

    查看Android Doc 文档可以知道,ClickableSpan 是一个抽象类,它有两个子类,分别是 URLSpanTextLinks.TextLinkSpan(从 API Level 28 开始支持),对于这两个类的使用,这里不做详细讲解,我们主要讲解下如何通过继承 ClickableSpan 实现可点击的标记。

2.1 ClicableSpan 源码剖析

    首先,我们先来看看 ClickableSpan 抽象类的源码:

public abstract class ClickableSpan extends CharacterStyle implements UpdateAppearance 
    private static int sIdCounter = 0;

    private int mId = sIdCounter++;

    /**
     * Performs the click action associated with this span.
     */
    public abstract void onClick(@NonNull View widget);

    /**
     * Makes the text underlined and in the link color.
     */
    @Override
    public void updateDrawState(@NonNull TextPaint ds) 
        ds.setColor(ds.linkColor);
        ds.setUnderlineText(true);
    

    /**
     * Get the unique ID for this span.
     *
     * @return The unique ID.
     * @hide
     */
    public int getId() 
        return mId;
    

    从上面的源码来看,ClickableSpan 抽象类非常简单,继承该类需要重写的方法也是比较少,其中抽象方法 onClick() 是必须实现,下面讲解重写方法所能实现的效果:

  • public abstract void onClick(@NonNull View widget):抽象方法,必须实现。用以相应可点击标记被点击时的事件相应处理。
  • public void updateDrawState(@NonNull TextPaint ds):配置绘制参数,可以用来更改绘制样式,比如文字颜色、背景颜色、链接颜色、是否包含下划线等等。如果不重载此方法,将会使用默认的绘制样式。

2.2 自定义 ClickableSpan

    从前面的源码我们了解到 ClickableSpan 的成员方法,实现自己的自定义 ClickableSpan 就非常容易了:

/**
 * 自定义 ClickableSpan
 * @param textColor 可点击标记文字颜色
 * @param clickListener 点击时间监听
 */
class CSClickableSpan (@param:ColorInt private val textColor: Int,
                       private val clickListener: View.OnClickListener?) : ClickableSpan() 
    override fun onClick(widget: View) 
        clickListener?.onClick(widget)
    

    override fun updateDrawState(ds: TextPaint) 
        super.updateDrawState(ds)

        ds.color = textColor // 字体颜色(前景色)
        ds.bgColor = Color.TRANSPARENT  // 背景颜色
        ds.linkColor = textColor // 链接颜色
        ds.isUnderlineText = false // 是否显示下划线
        // 这里还可以配置其他绘制样式,比如下划线的粗细(如果启用下划线)、字体等等
    

2.3 使用自定义的 ClickableSpan

    接下来就可以在 SpannableString 或者 SpannableStringBuilder 中使用自定义的 CSClickableSpan 类。

val tvNormal = findViewById<TextView>(R.id.tv_normal_clickable_span)
// 必须设置 TextView 的 movementMethod 为 LinkMovementMethod,否则标记无法响应点击事件
tvNormal.movementMethod = LinkMovementMethod.getInstance()
tvNormal.setText(SpannableString("我是普通的ClickableSpan").apply 
    setSpan(CSClickableSpan(Color.BLUE, View.OnClickListener 
        Toast.makeText(this@ClickableSpanActivity, tvNormal.text, Toast.LENGTH_SHORT).show()
    ), 5, 18, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
)

注意事项:在 TextView 中使用 ClickableSpan 时,必须要设置 TextView 对象的 movementMethod 属性为 LinkMovementMethod,否则 ClickableSpan 标记不会响应点击事件。

    运行之后,可以看看效果。

  • 点击前
  • 点击后

        根据上面的例子,我们会发现标记点击后,会有一个背景色,其实这个背景色是 TextView 的高亮颜色,因为 LinkMovementMethod 在标记点击后,会选中标记部分文本。解决这个问题也很简单,只要将 TextViewhighlightColor 设置为透明即可,如下示例:
val tvNormalNoSelection = findViewById<TextView>(R.id.tv_normal_clickable_span_no_selection)
// 将 TextView 的高两色设置为透明,可去除点击后的选择高亮色
tvNormalNoSelection.highlightColor = Color.TRANSPARENT
// 必须设置 TextView 的 movementMethod 为 LinkMovementMethod,否则标记无法响应点击事件
tvNormalNoSelection.movementMethod = LinkMovementMethod.getInstance()
tvNormalNoSelection.setText(SpannableString("我是普通的ClickableSpan(无选中背景)").apply 
    setSpan(CSClickableSpan(Color.BLUE, View.OnClickListener 
        Toast.makeText(this@ClickableSpanActivity, tvNormalNoSelection.text, Toast.LENGTH_SHORT).show()
    ), 5, 18, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
)

    运行之后看效果,点击标记之后选中高亮色为透明,看起来就是没有高两色的效果,如下图:

三、高手进阶

3.1 在 ClickableSpan 中实现点击效果

    在前面篇幅中,虽然可以去掉标记选中高亮色,但是这样也并不完美,没有点击效果,用户体验还是有所欠缺。我们首先会想到用TextView 的高亮色,然而高亮色只能设置整型的颜色值,并不能设置ColorList。于是就猜想通过 TextView 的高亮色结合自定义的 CSClickableSpan 实现,笔者刚开始也是从这个角度着手,预想将高亮色设置成按下状态颜色,然后再将高亮色设置为透明色,后来发现这样无法实现,因为 ClickableSpan 这个过程中,会在 onClick() 方法调用之前,前后均会调用 updateDrawState() 更新绘制文本,在如此的调用逻辑下,这种方案是不可行的。既然无法从 TextView 下手,在示例代码中,我们唯一能寄予希望的就是 TextViewmovementMethod 属性了(也就是 LinkMovementMethod)。

  • LinkMovementMethod类源码剖析
package android.text.method;

import android.annotation.UnsupportedAppUsage;
import android.os.Build;
import android.text.Layout;
import android.text.NoCopySpan;
import android.text.Selection;
import android.text.Spannable;
import android.text.style.ClickableSpan;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.textclassifier.TextLinks.TextLinkSpan;
import android.widget.TextView;

/**
 * A movement method that traverses links in the text buffer and scrolls if necessary.
 * Supports clicking on links with DPad Center or Enter.
 */
public class LinkMovementMethod extends ScrollingMovementMethod 
    private static final int CLICK = 1;
    private static final int UP = 2;
    private static final int DOWN = 3;

    private static final int HIDE_FLOATING_TOOLBAR_DELAY_MS = 200;

    @Override
    public boolean canSelectArbitrarily() 
        return true;
    

    @Override
    protected boolean handleMovementKey(TextView widget, Spannable buffer, int keyCode,
            int movementMetaState, KeyEvent event) 
        switch (keyCode) 
            case KeyEvent.KEYCODE_DPAD_CENTER:
            case KeyEvent.KEYCODE_ENTER:
                if (KeyEvent.metaStateHasNoModifiers(movementMetaState)) 
                    if (event.getAction() == KeyEvent.ACTION_DOWN &&
                            event.getRepeatCount() == 0 && action(CLICK, widget, buffer)) 
                        return true;
                    
                
                break;
        
        return super.handleMovementKey(widget, buffer, keyCode, movementMetaState, event);
    

    @Override
    protected boolean up(TextView widget, Spannable buffer) 
        if (action(UP, widget, buffer)) 
            return true;
        

        return super.up(widget, buffer);
    

    @Override
    protected boolean down(TextView widget, Spannable buffer) 
        if (action(DOWN, widget, buffer)) 
            return true;
        

        return super.down(widget, buffer);
    

    @Override
    protected boolean left(TextView widget, Spannable buffer) 
        if (action(UP, widget, buffer)) 
            return true;
        

        return super.left(widget, buffer);
    

    @Override
    protected boolean right(TextView widget, Spannable buffer) 
        if (action(DOWN, widget, buffer)) 
            return true;
        

        return super.right(widget, buffer);
    

    private boolean action(int what, TextView widget, Spannable buffer) 
        Layout layout = widget.getLayout();

        int padding = widget.getTotalPaddingTop() +
                      widget.getTotalPaddingBottom();
        int areaTop = widget.getScrollY();
        int areaBot = areaTop + widget.getHeight() - padding;

        int lineTop = layout.getLineForVertical(areaTop);
        int lineBot = layout.getLineForVertical(areaBot);

        int first = layout.getLineStart(lineTop);
        int last = layout.getLineEnd(lineBot);

        ClickableSpan[] candidates = buffer.getSpans(first, last, ClickableSpan.class);

        int a = Selection.getSelectionStart(buffer);
        int b = Selection.getSelectionEnd(buffer);

        int selStart = Math.min(a, b);
        int selEnd = Math.max(a, b);

        if (selStart < 0) 
            if (buffer.getSpanStart(FROM_BELOW) >= 0) 
                selStart = selEnd = buffer.length();
            
        

        if (selStart > last)
            selStart = selEnd = Integer.MAX_VALUE;
        if (selEnd < first)
            selStart = selEnd = -1;

        switch (what) 
            case CLICK:
                if (selStart == selEnd) 
                    return false;
                

                ClickableSpan[] links = buffer.getSpans(selStart, selEnd, ClickableSpan.class);

                if (links.length != 1) 
                    return false;
                

                ClickableSpan link = links[0];
                if (link instanceof TextLinkSpan) 
                    ((TextLinkSpan) link).onClick(widget, TextLinkSpan.INVOCATION_METHOD_KEYBOARD);
                 else 
                    link.onClick(widget);
                
                break;

            case UP:
                int bestStart, bestEnd;

                bestStart = -1;
                bestEnd = -1;

                for (int i = 0; i < candidates.length; i++) 
                    int end = buffer.getSpanEnd(candidates[i]);

                    if (end < selEnd || selStart == selEnd) 
                        if (end > bestEnd) 
                            bestStart = buffer.getSpanStart(candidates[i]);
                            bestEnd = end;
                        
                    
                

                if (bestStart >= 0) 
                    Selection.setSelection(buffer, bestEnd, bestStart);
                    return true;
                

                break;

            case DOWN:
                bestStart = Integer.MAX_VALUE;
                bestEnd = Integer.MAX_VALUE;

                for (int i = 0; i < candidates.length; i++) 
                    int start = buffer.getSpanStart(candidates[i]);

                    if (start > selStart || selStart == selEnd) 
                        if (start < bestStart) 
                            bestStart = start;
                            bestEnd = buffer.getSpanEnd(candidates[i]);
                        
                    
                

                if (bestEnd < Integer.MAX_VALUE) 
                    Selection.setSelection(buffer, bestStart, bestEnd);
                    return true;
                

                break;
        

        return false;
    

    @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);
    

    @Override
    public void initialize(TextView widget, Spannable text) 
        Selection.removeSelection(text);
        text.removeSpan(FROM_BELOW)

Android工程师进阶之路 :《Android开发进阶:从小工到专家》上市啦!

封面 目录1 目录2
技术分享 技术分享 技术分享

- 当当购买链接
- 京东购买链接

为什么写这本书

写这本书的念头由来已久了。也许是从我打算写《Android源码设计模式解析与实战》那时起就萌生了这个念头,因为设计模式属于仅次于架构之下的局部战术,阅读这类书籍能够让具备一定工作经验的开发人员提升自己的设计能力,构建更灵活的软件。但是,对于初、中级工程师而言,最重要的还是在于基础知识以及知识广度的掌握上。因此,在《Android源码设计模式解析与实战》交稿之后,我就立即开始了本书的写作之旅。

从面试经历和与开发群中网友的交流中,我发现很多有一定工作经验的开发人员对于Android的基础知识都还只停留在“会用”的阶段,而对于其基本原理一概不知,以至于工作多年之后依旧停留在很表面的层次。这样的知识结构的程序员往往是一旦开发的系统出现问题或者需要优化时就不能应对了。因此,仔细阅读一本深入讲述Android核心开发知识点的书是很有必要的。

目前,图书市场上关于Android的入门书籍大多是覆盖整个Android开发知识体系,这类书籍的特点是讲解的知识面多,也正是这个原因使得这类书籍缺乏深度,往往只是点到即止。例如,关于网络请求的技术,通常只讲解如何发送一个GET请求,但是,对于HTTP原理不会涉及,这使得很多读者在定制一些请求时根本无从下手,如上传图片、参数格式为Json等。

另一个问题就是,很多开发人员即使从业多年,可能都不知道什么是单元测试,不知道重构、面向对象基本原则,这使得他们的代码耦合度可能很高,难以测试和维护,这样带来的后果就是质量没法保证,随着时间的推移系统逐渐“腐化”。因此,读一本讲述设计软件的书也是必要的。

本书的目的就是解决上述两个问题,首先对Android开发的核心知识点进行深入讲解,然后介绍单元测试、代码规范、版本控制、重构、架构等重要知识点,使得读者在深入技术的同时开阔眼界,能够以更专业的方式设计应用软件,帮助读者完成从只会实现功能的“码农”到软件工程师、设计师的过渡。

本书的特色

本书主要分为3部分,第一部分是前6章,在第一部分中深入讲解了Android开发过程中的核心知识点,包括View与动画、多线程、网络、数据库、性能优化,使得读者深入了解开发中最为重要的知识;第二部分是第7~11章,涵盖的内容包括代码规范、单元测试、版本控制、OOP与模式、重构等内容,从代码规范化、专业化的角度着手,开阔读者的眼界,使读者具备构建低耦合、灵活性强的应用软件的基本能力;最后一部分是第12章,在第12章中通过一个完整的示例,演示了如何把一个充满问题的应用软件逐步演化为低耦合、清晰、可测试的实现过程,在其中展示了常见的重构手法、测试手段,使读者从真实的示例中汲取知识与经验,提升技术与设计能力,绕过编程中的诸多陷阱。

当然,书中的知识点很多都只是做了部分讲解,起到一个抛砖引玉的作用,因此,如果需要更深入地了解各领域的知识,希望读者阅读其他专业书籍。

面向的读者

本书面向的读者为初、中、高级Android工程师。本书的定位是学习Android开发的第二本书,因此,阅读的前提是读者需要有一定的Android开发知识。在阅读完本书之后,读者还可以选择《Android群英传》《Android开发艺术探索》《Android源码设计模式解析与实战》等书进行更深入地学习,从更深、更高的层次提升自己,完成从“码农”到专家的蜕变。

如何阅读本书

本书从整体结构上分为3部分,分别为Android核心开发知识、规范化与专业化开发基本知识、实战示例。初、中级工程师建议阅读全书,高级工程师可以选择自己感兴趣的部分进行阅读。实战示例部分需要第二部分的知识,因此,在阅读最后一章时,如果你学习了第二部分的知识,那么理解效果会更好。判定你是否需要阅读某个章节的标准是,当你看到标题时是否对这个知识点了然于心,如果答案是否定的,那么阅读该章节还是很有必要的。当然,通读全书自然是最好的选择。

“纸上得来终觉浅,绝知此事要躬行”,这放到任何一本书中都适用。因此,阅读本书时建议重新完成书中的示例,然后进行思考,从中体会为什么要这样做,这样做得到的好处是什么。读书、实践、思考结合起来,才会让你在技术道路上跑得更快、更远!


以上是关于Android Span进阶之路——ClickableSpan的主要内容,如果未能解决你的问题,请参考以下文章

Android进阶之路-详解MVC

Android进阶之路-详解MVP

Android资深工程师进阶之路

Androidproject师进阶之路 :《Android开发进阶:从小工到专家》上市啦!

Android 你应该知道的学习资源 进阶之路贵在坚持

未来式喵悟空 - 开发之路进阶全记录