TextView-SpannableString(花哨的TextView)

本文介绍如何使用SpannableString为TextView添加多种样式,并实现部分文字的点击事件,同时解决点击事件冲突的问题。通过使用CustomSpan和CustomLinkMovementMethod,实现了部分文字点击触发自定义事件,而整体TextView点击触发另一事件的功能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

利用SpannableString可以是TextView变的很花哨,比如某些字红,某些字绿,某些字有下划线

部分着色

来看个简单的例子

        String name = "郭敬明";
        String message = ":我是小白鼠";
        SpannableString spanStr = new SpannableString(name + message);
        spanStr.setSpan(new ForegroundColorSpan(Color.RED),0,3,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
        mClickableText.setText(spanStr);


有点击事件

还可以让部分文字有点击事件
        String name = "郭敬明";
        String message = ":我是小白鼠";
        SpannableString spanStr = new SpannableString(name + message);
        spanStr.setSpan(new ClickableSpan() {
            @Override
            public void onClick(View widget) {
               Toast.makeText(MainActivity.this,"点击了郭敬明",Toast.LENGTH_SHORT).show();
            }
        },0,3,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
        spanStr.setSpan(new ForegroundColorSpan(Color.RED),0,3,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
        mClickableText.setText(spanStr);
        mClickableText.setMovementMethod(LinkMovementMethod.getInstance());


setMovementMethod这句话写了才可能有点击事件
着色的setSpan和设置点击事件的setSpan之间的顺序,应该是后写着色,否则颜色不对
有了部分点击事件之后,会自动加一个下划线,如果我们不想要这个下划线怎么办?

去掉下划线

方法:重写ClickableSpan
public class CustomSpan extends ClickableSpan {
    private Context context;


    public CustomSpan(Context context) {
        this.context = context;
    }


    @Override
    public void updateDrawState(TextPaint ds) {
        ds.setColor(Color.RED);
        ds.setUnderlineText(false);
        ds.setTextSize(ScreenUtil.dip2px(16));


//        ds.setFakeBoldText(false);
    }


    @Override
    public void onClick(View widget) {
        Toast.makeText(context, "点击了郭敬明", Toast.LENGTH_SHORT).show();
    }


}



 在updateDrawState内设置颜色和取消下划线,这样只需要setSpan一次就可以了,Main代码如下
        String name = "郭敬明";
        String message = ":我是小白鼠";
        SpannableString spanStr = new SpannableString(name + message);
        spanStr.setSpan(new CustomSpan(this),0,3,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
        mClickableText.setText(spanStr);
        mClickableText.setMovementMethod(LinkMovementMethod.getInstance());

点击触发2次事件问题

如果对上面的TextView设置了点击事件,会发现,点击“郭敬明”,不仅仅会触发他自己的点击事件,也会触发TextView的点击事件,比较不合理,事实上的却如此,看TextView源码可以发现,ClickableSpan的onClick触发是,
handled |= mMovement.onTouchEvent(this, (Spannable) mText, event);   (在TextView的onTouchEvent内)

mMovement.onTouchEvent并不会消费点击事件,而且在这之前就走了 super.onTouchEvent(event),这里的super是View,这里面就会调post(mPerformClick),这就是准备处理点击事件(可以参考文章 http://blog.youkuaiyun.com/litefish/article/details/44172659)。可以看到这里是先走的super.onTouchEvent(event),然后走mMovement.onTouchEvent,可是实际效果相反,实际显示的onClick调用顺序是先 部分文字的,然后TextView的,这是怎么回事呢?看代码流程。首先post(mPerformClick),把TextView的点击事件丢到消息队列里,然后走mMovement.onTouchEvent,这里会直接调用 部分文字的 onClick事件 ,然后再消息泵内会处理TextView的点击事件。真想大白!
部分源码如下


        final int action = event.getActionMasked();

        if (mEditor != null) mEditor.onTouchEvent(event);

        final boolean superResult = super.onTouchEvent(event);

        /*
         * Don't handle the release after a long press, because it will
         * move the selection away from whatever the menu action was
         * trying to affect.
         */
        if (mEditor != null && mEditor.mDiscardNextActionUp && action == MotionEvent.ACTION_UP) {
            mEditor.mDiscardNextActionUp = false;
            return superResult;
        }

        final boolean touchIsFinished = (action == MotionEvent.ACTION_UP) &&
                (mEditor == null || !mEditor.mIgnoreActionUpEvent) && isFocused();

         if ((mMovement != null || onCheckIsTextEditor()) && isEnabled()
                && mText instanceof Spannable && mLayout != null) {
            boolean handled = false;

            if (mMovement != null) {
                handled |= mMovement.onTouchEvent(this, (Spannable) mText, event);
            }

不知道android为什么这么设计,mMovement.onTouchEvent不消费点击事件

触发2次onClick解决方案

如何解决呢?

解决方案1


/**
 * 改写{@link android.text.method.LinkMovementMethod},主要改写了onTouchEvent,避免了 “某些文字”的点击即会触发他们自己的OnClick也会触发TextView的OnClick,用CustomLinkMovementMethod之后就不写TextView的OnClick,
 * 而是写TextClickedListener
 */
public class CustomLinkMovementMethod extends ScrollingMovementMethod {
    private static final int CLICK = 1;
    private static final int UP = 2;
    private static final int DOWN = 3;

    private static CustomLinkMovementMethod sInstance;
    public static CustomLinkMovementMethod getInstance() {
        if (sInstance == null)
            sInstance = new CustomLinkMovementMethod();

        return sInstance;
    }


    public abstract interface TextClickedListener {
        public abstract void onTextClicked();
    }

    TextClickedListener listener = null;

    public void setOnTextClickListener(TextClickedListener listen) {
        listener = listen;
    }

    @Override
    public boolean onKeyDown(TextView widget, Spannable buffer,
                             int keyCode, KeyEvent event) {
        switch (keyCode) {
            case KeyEvent.KEYCODE_DPAD_CENTER:
            case KeyEvent.KEYCODE_ENTER:
                if (event.getRepeatCount() == 0) {
                    if (action(CLICK, widget, buffer)) {
                        return true;
                    }
                }
        }

        return super.onKeyDown(widget, buffer, keyCode, 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) {
        boolean handled = false;

        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[] link = buffer.getSpans(selStart, selEnd, ClickableSpan.class);

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

                link[0].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;
    }

    public boolean onKeyUp(TextView widget, Spannable buffer,
                           int keyCode, KeyEvent event) {
        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[] 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);

                if (action == MotionEvent.ACTION_UP) {
                    if (listener != null)
                        listener.onTextClicked();
                }
            }
        }

        return super.onTouchEvent(widget, buffer, event);
    }


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

    public void onTakeFocus(TextView view, Spannable text, int dir) {
        Selection.removeSelection(text);

        if ((dir & View.FOCUS_BACKWARD) != 0) {
            text.setSpan(FROM_BELOW, 0, 0, Spannable.SPAN_POINT_POINT);
        } else {
            text.removeSpan(FROM_BELOW);
        }
    }



    private static Object FROM_BELOW = new NoCopySpan.Concrete();
}


可以使用CustomLinkMovementMethod来代替默认的LinkMovementMethod,代码基本一致,主要区别是onTouchEvent的时候,加了点代码

                if (action == MotionEvent.ACTION_UP) {
                    if (listener != null)
                        listener.onTextClicked();
                }



这是什么意思呢?
如果点击了“郭敬明”区域,会走到 link[0].onClick(widget);,那就调用了ClickableSpan的onClick。
如果点击了TextView的其他部分,就会走到listener.onTextClicked();
所以我们可以去掉原来的TextView的onClick,用这里的onTextClicked代替,用法如下
        CustomLinkMovementMethod cc=new CustomLinkMovementMethod();
        cc.setOnTextClickListener(new CustomLinkMovementMethod.TextClickedListener() {
            @Override
            public void onTextClicked() {
            }
        });
        mClickableText.setMovementMethod(cc);

解决方案2

直接设置2个Span

        spanStr.setSpan(new CustomSpan(this),0,3,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
        spanStr.setSpan(new CustomSpan1(this),0,end,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);

CustomSpan里写,“郭敬明”的点击回调
CustomSpan1里写整个textView的点击回调
这里有个问题,我们可以看到CustomSpan是针对前3个字符,CustomSpan1是针对全部字符,那也就是说
“郭敬明” 对应了2个Span,那结果会怎么样呢?看源码(LinkMovementMethod.onTouchEvent)
            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);
            }

可以看到首先会去查当前字符有几个span,放入link里,然后只处理link[0]的onClick,所以这里,“郭敬明”也只会走一个onClick.
注意这里span是根据字符查找的,不是根据鼠标位置,他首先根据鼠标位置找到当前字符(如果当前位置没有字符,比如点在padding处,那他会找最近的字符),然后根据字符来查找span,再根据span来处理






评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值