Android TextView局部下划线及点击弹出popu

最近在项目中遇到一个需求,产品要求实现一个可以部分点击的 TextView,可点击的部分需要有虚线下划线以及在点击区域弹出 Popupwindow,这里把我的实现过程记录下来。

文章目录

  • 实现思路
  • Layout
  • 画下划线
  • 点击并弹出 Popu
  • 遇到的问题
    • 点击冲突
    • ListView 中位置错乱
    • 点击背景色

实现思路

实现点击很简单,google 给提供了 SpannableString 来实现对 TextView 的各种操作,包括点击、字体、文字颜色等等,当然也包括有下划线,但是产品要求下划线是虚线,google 提供的 api 这个时候就不能满足要求了;而且产品还要求在点击的位置弹出 popu。当然第一时间想到的就是自定义 View 了,这里一开始想了两种方案:

  • 把一段文字根据需要拆分成多个段落,然后在一个 viewGroup 中动态的添加 Textview,这样就能实现对每一个词的精准控制。
  • 在一个 TextView 中想办法去计算被点击位置的坐标,重写 TextView 的 draw 方法,手动画下划线。

后来经过衡量选择了第二种方案,难点就在于坐标的计算上,因为不管是划线,还是弹出 popu 都需要一个坐标,这里 google 给提供了一个类——Layout,layout 提供了一系列的操作文字元素的 api,它就是接下来实现功能的关键。

最终实现的效果图如下:
Android TextView局部下划线及点击弹出popu_第1张图片

Layout

Android TextView局部下划线及点击弹出popu_第2张图片
管理屏幕中视觉元素文本布局的基类。它提供了几个关键的方法:

  • getLineForOffset(int offset) 获取指定字符的行号;
  • getLineBounds(int line, Rect bounds) 获取指定行的所在的区域;
  • getPrimaryHorizontal(int offset) 获取指定字符的左坐标;
  • getSecondaryHorizontal(int offset) 获取指定字符的辅助水平偏移量;
  • getLineMax(int line) 获取指定行的宽度,包含缩进但是不包含后面的空白,可以认为是获取文本区域显示出来的一行的宽度;
  • getLineStart(int line) 获取指定行的第一个字符的下标;

根据以上的一些方法,基本上就可以计算出要点击位置的坐标,以及弹出 popu 的位置。下面开始编码。

画下划线

这里我是定义了一个 UnderLineOptions 类来实现下划线类型的配置,以方便进行扩展,这里是用 kotlin 来写的。

class UnderLineOptions {
    enum class Style {
        LINE_STYLE_DOTTED, LINE_STYLE_STROKE
    }
    // 线宽 -1 表示默认
    var lineHeight = -1
    // 线类型
    var lineStyle = Style.LINE_STYLE_DOTTED
    // 颜色
    var lineColor = Color.RED
    var lineStart = 0
    var lineEnd = 0
    var contentStr: String = ""
    //存储下划线起始坐标的列表,0和偶数位表示开始,奇数位表示结束,坐标用一个数组来存储分别为左,上,右,下
    var linesXY: List? = null

    var myClickableSpan: MyClickableSpan? = null

    constructor(lineStart: Int, lineEnd: Int, lineStyle: Style, lineColor: Int) {
        this.lineStyle = lineStyle
        this.lineColor = lineColor
        this.lineStart = lineStart
        this.lineEnd = lineEnd
    }

    constructor(lineStart: Int, lineEnd: Int) : this(lineStart, lineEnd, Style.LINE_STYLE_DOTTED, Color.RED, null)

    constructor(lineStart: Int, lineEnd: Int, lineColor: Int) : this(lineStart, lineEnd, Style.LINE_STYLE_DOTTED, lineColor, null)

    constructor(lineStart: Int, lineEnd: Int, lineStyle: Style, lineColor: Int) : this(lineStart, lineEnd, lineStyle, lineColor, null)
}

然后重写 TextView 类为 MyTextViewJ,由于代码太多这里只贴出关键代码;
首先是计算某一个字符的坐标的方法,它返回一个数组里面存储了坐标信息,依次是左,上,右,下:

    private float[] measureXY(int poi) {
        float[] floats = new float[4];
        Layout layout = getLayout();
        int line = layout.getLineForOffset(poi);
        Rect rect = new Rect();
        layout.getLineBounds(line, rect);
        // 原因不明,获取到的坐标左右相同
        //左
        floats[0] = layout.getPrimaryHorizontal(poi) + getPaddingLeft();
        //上
        floats[1] = rect.top + getPaddingTop();
        //右
        floats[2] = layout.getSecondaryHorizontal(poi) + getPaddingLeft();
        //下
        floats[3] = rect.bottom + getPaddingTop();
        return floats;
    }

计算配置类相关信息并添加进列表的方法:

//下划线配置列表
private List<UnderLineOptions> lineOptions = new ArrayList<>();


private boolean addOptions(UnderLineOptions option) {
        int s = option.getLineStart();
        int e = option.getLineEnd();
        if (s > getText().toString().length()) {
            return false;
        }
        if (e < 0) {
            return false;
        }
        int start = s < 0 ? 0 : s;
        int end = e > getText().toString().length() ? getText().toString().length() : e;
        float[] startXY = measureXY(start);
        float[] endXY = measureXY(end);
        List<float[]> listXY = new ArrayList<>();
        if (startXY[1] == endXY[1]) { // 如果只有一行
            listXY.add(startXY);
            listXY.add(endXY);
            option.setLinesXY(listXY);
        } else {
            // 这里处理折行的情况
            int lineStart = getLayout().getLineForOffset(start);
            int lineEnd = getLayout().getLineForOffset(end);
            int lineNum = lineStart;

            while (lineNum <= lineEnd) {
                Rect rect1 = new Rect();
                getLayout().getLineBounds(lineNum, rect1);
                if (lineNum == lineStart) { // 第一行
                    float[] endXYN = new float[]{getLayout().getLineMax(lineNum) + measureXY(getLayout().getLineStart(lineNum))[0],
                            startXY[0],
                            getLayout().getLineMax(lineNum) + measureXY(getLayout().getLineStart(lineNum))[0],
                            startXY[3]};
                    listXY.add(startXY);
                    listXY.add(endXYN);
                } else if (lineNum == lineEnd) { // 最后一行
                    float[] startXYN = new float[]{measureXY(getLayout().getLineStart(lineNum))[0],
                            endXY[0],
                            measureXY(getLayout().getLineStart(lineNum))[0],
                            endXY[3]};
                    listXY.add(startXYN);
                    listXY.add(endXY);
                } else { // 中间的行
                    Rect rect = new Rect();
                    getLayout().getLineBounds(lineNum, rect);
                    float[] startXYN = new float[]{measureXY(getLayout().getLineStart(lineNum))[0],
                            rect.top + getPaddingTop(),
                            measureXY(getLayout().getLineStart(lineNum))[0],
                            rect.bottom + getPaddingTop()};
                    float[] endXYN = new float[]{getLayout().getLineMax(lineNum) + measureXY(getLayout().getLineStart(lineNum))[0],
                            rect.top + getPaddingTop(),
                            getLayout().getLineMax(lineNum) + measureXY(getLayout().getLineStart(lineNum))[0],
                            rect.bottom + getPaddingTop()};
                    listXY.add(startXYN);
                    listXY.add(endXYN);
                }
                lineNum++;
            }
            option.setLinesXY(listXY);
        }
        lineOptions.add(option);
        return true;
    }

添加下划线的配置主要是要处理一个问题就是需要添加下划线的起始字符是跨越多行还是只有一行,如果涉及到多行就需要循环遍历并把每一段的起始位置添加进 option 中,所以 option 中用一个 list 来保存多段下划线的起始位置。
最后是 onDraw 方法修改:

    private Paint paint;

    private DashPathEffect effects = new DashPathEffect(new float[]{5f, 5f, 5f, 5f}, 3f);
    private Path path;
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 循环遍历每一个配置文件
        for (UnderLineOptions options: lineOptions) {
            if (options.getLinesXY() != null) {
                if (options.getLineStyle() == UnderLineOptions.Style.LINE_STYLE_DOTTED) {
                    paint.setPathEffect(effects);
                } else {
                    paint.setPathEffect(null);
                }
                int color = options.getLineColor();
                paint.setColor(color);
                //循环遍历来画下划线 画一行或者多行
                for (int i = 0; i < options.getLinesXY().size(); i++) {
                    if (i % 2 == 0) {
                        // 用下标的奇偶来表示开始还是结束
                        path.moveTo(options.getLinesXY().get(i)[0], options.getLinesXY().get(i)[3]);
                    } else {
                        path.lineTo(options.getLinesXY().get(i)[0], options.getLinesXY().get(i)[3]);
                        canvas.drawPath(path, paint);
                        path.reset();
                    }
                }
            }
        }
    }

到这里画下划线的关键代码已经都有了,然后再添加一些 set 方法就可以了,完整代码已经上传到 github 上戳这里。

点击并弹出 Popu

使用 SpannableString 很容易就能实现点击事件,同时我们上面已经有了点击位置的坐标,两者结合起来很简单的就能实现在特定位置弹出 popu 了。

关于 SpannableString 的使用请戳这里。
首先重写了 ClickableSpan 为 MyClickableSpan,这个类也是用 kotlin 来写的。

class MyClickableSpan(start: Int, end: Int) : ClickableSpan() {

    var mStart = start
    var mEnd = end

	// 点击回调
    lateinit var onClickListener: (v: View, str: String, x: Int, y: Int) -> Unit
    var contentStr = ""
    // x y 用来记录弹出框在 x 和 y 轴的偏移量,这里也可以用一个数组来将被点击位置的起始坐标传递回来
    var x = 0
    var y = 0

    override fun onClick(p0: View?) {
        if (!::onClickListener.isInitialized) {
            return
        }
        p0?.let {
            onClickListener(p0, contentStr, x, y)
        }
    }

    override fun updateDrawState(ds: TextPaint?) {
    	// android默认被点击位置是有下划线的 源码如下:
    	//ds.setColor(ds.linkColor);
    	//ds.setUnderlineText(true);
    	// 在这个方法中我们可以自己指定被点击位置的样式,这里我偷了个懒直接设置了红色
        ds?.color = Color.RED
    }
}

在 UnderLineOptions 中添加 ClickableSpan 字段

var myClickableSpan: MyClickableSpan? = null

在 MyTextViewJ.addOptions() 中将计算出来的坐标信息设置给 myClickableSpan:

    private boolean addOptions(UnderLineOptions option) {
    	···
          if (option.getMyClickableSpan() != null) {
            option.getMyClickableSpan().setMStart(start);
            option.getMyClickableSpan().setMEnd(end);
            // 设置被点击的具体内容。
            option.getMyClickableSpan().setContentStr(getText().toString().subSequence(start, end).toString());
        }
         if (startXY[1] == endXY[1]) {
            listXY.add(startXY);
            listXY.add(endXY);
            //找到弹出框的中间点
            int x = (int) (startXY[0] + (endXY[0] - startXY[0]) / 2);
            if(option.getMyClickableSpan() != null){
            	option.getMyClickableSpan().setX(x);
            	option.getMyClickableSpan().setY((int) endXY[1]);
            }
            option.setLinesXY(listXY);
        } else {
        	// 对于折行的文本,可以根据不同的产品要求来确定具体的弹窗位置。
        }
    	···
    }

对于折行点击的情况在这里我没有给出来,因为不同的产品需求是不同的,在此次实现中我们只考虑一行的情况。
到这里所有的关键代码就都有了,然后就可以根据 MyClickableSpan 的回调来进行弹窗。测试的代码就不给出了,都在 github 上,在这里可以看到。

遇到的问题

点击冲突

最大的问题就是点击冲突, TextView 的点击和它的局部点击事件冲突,在很多情况下我们当然是不希望他们有冲突的,解决办法有很多种,主要有这两种

  • 设置标记位
  • 重写 OnTouchEvent 方法

翻了一下 TextView 的源码,研究了 TextView 局部点击的实现原理,关键代码其实很简单

public boolean onTouchEvent(MotionEvent event) {
			···
            if (touchIsFinished && mLinksClickable && mAutoLinkMask != 0 && textIsSelectable) {
                // 检测被点击的位置是否设置了 ClickableSpan,如果有就调用它的onClick 方法
                ClickableSpan[] links = ((Spannable) mText).getSpans(getSelectionStart(),
                    getSelectionEnd(), ClickableSpan.class);
                if (links.length > 0) {
                    links[0].onClick(this);
                    handled = true;
                }
            }
            ····
            if (handled) {
                return true;
            }
}

那么其实可以在 performClick() 方法中对它进行一个拦截:

    @Override
    public boolean performClick() {
        ClickableSpan[] links = ((Spannable) getText()).getSpans(getSelectionStart(),
                getSelectionEnd(), ClickableSpan.class);
        if (links.length > 0) {
            return false;
        }
        return super.performClick();
    }

ListView 中位置错乱

这里其实有两点

  • adapter 的 gitView 中写了 if 记得 写 else
  • MyTextViewJ 中 onDetachedFromWindow 方法中调用 lineOptions.clear(); 在 view 被销毁的时候清空一下下划线配置信息。

点击背景色

在用 kotlin 写的 activity 中运行后出现了被点击位置总有一个和colorAccent 一样的背景色,原因不明,最后给 view 设置 theme,把 colorAccent 颜色设置成和 TextView 一样的背景色来解决问题。用java 写的代码没有这个问题??如果大家也遇到相同的情况欢迎在评论区讨论。

以上就是本篇的所有内容,所有代码已经上传到 github 中——戳这里,欢迎 star。


版权声明:本文为博主原创文章,转载请声明出处,请尊重别人的劳动成果,谢谢!

你可能感兴趣的:(Android,Kotlin)