Android 可折叠TextView

栏目: IOS · Android · 发布时间: 6年前

内容简介:本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布当文字内容超过指定行数后,显示省略号和全文。上图的效果在微博,b站上都有。这里我选择继承AppCompatTextView实现。

本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

当文字内容超过指定行数后,显示省略号和全文。

Android 可折叠TextView

上图的效果在微博,b站上都有。这里我选择继承AppCompatTextView实现。

实现思路

  • 当内容超过指定行数后,计算最大行数第一个(start)和最后一个字符(end)在整个字符串里面的位置
  • 测量要拼接的内容(demo中是... 全文)的宽度
  • 计算跟拼接内容宽度相当的字符个数(num)
  • 将整个字符串从0到(end-num)进行截取
  • 拼接要显示的内容,设置点击事件
  • 设置文字

这里会有一点小小的问题,提示内容是紧跟着原来的文本,而不是在TextView的边界上。而且用ClickableSpan设置点击事件,如果TextView设置了点击事件,会跟TextView本身的点击事件同时触发。没有设置的话,点击其他文字,点击事件不会传递到父View,而是被TextView消费掉。如下图:

Android 可折叠TextView
Android 可折叠TextView

这里再提供一种思路

  • 前几步跟上面相同,都是对内容进行截取,设置文字
  • 文字绘制完毕后,手动把提示的内容绘制上去
  • 添加点击事件

这种方法可以保证提示在TextView的边界上,但是点击事件需要自己重写 onTouchEvent() 自己设置,略显麻烦。

Android 可折叠TextView

下面讲讲具体实现的关键步骤:

内容截取

SpannableStringBuilder span = new SpannableStringBuilder();
            int start = layout.getLineStart(mShowMaxLine - 1);
            int end = layout.getLineEnd(mShowMaxLine - 1);
            if (mTipGravity == END) {
                TextPaint paint = getPaint();
                StringBuilder builder = new StringBuilder(ELLIPSIZE_END).append("  ").append(mFoldText);
                end -= paint.breakText(mOriginalText, start, end, false, paint.measureText(builder.toString()), null);
            } else {
                end--;
            }
复制代码

当内容行数超过最大行数时,对文本进行截取。layout是TextView的layout,这里的 getLineStart()getLineEnd() 分别获取该行的第一个和最后一个字符的位置(这里的位置是从第一个字符开始算的)。 breakText() 方法计算出要截取的字符个数。 这里简单说下 breakText() 这个方法:

参数:

  • 测量的字符串
  • 测量开始的位置
  • 测量结束的位置
  • 测量方向,true从前往后,false从后往前
  • 截取的字符串最大宽度
  • 截取字符串实际宽度

最后返回需要截取的字符个数。 内容截取完后,对提示文本进行处理,下面分别对两种方法进行简单讲解

方法一:直接拼接内容,设置点击事件

CharSequence ellipsize = mOriginalText.subSequence(0, end);
  span.append(ellipsize);
  span.append(ELLIPSIZE_END);
 if (mTipGravity == END) {
                span.append("  ");
            } else {
                span.append("\n");
            }
            int length;
            if (isExpand) {
                span.append(mExpandText);
                length = mExpandText.length();
            } else {
                span.append(mFoldText);
                length = mFoldText.length();
            }
            if (mTipClickable) {
                span.setSpan(mSpan, span.length() - length, span.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
                setMovementMethod(LinkMovementMethod.getInstance());
            }
            span.setSpan(new ForegroundColorSpan(mTipColor), span.length() - length, span.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        }
        super.setText(span, type);
复制代码

通过SpannableString在截取后的内容上进行文字的拼接,并且设置相应的点击事件。

方法二:重写onDraw()方法绘制提示文字,重写onTouchEvent()设置点击事件

折叠状态:

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (isOverMaxLine && !isExpand) {
            //折叠
            if (mTipGravity == END) {
                minX = getWidth() - getPaddingLeft() - getPaddingRight() - getTextWidth("  全文");
                maxX = getWidth() - getPaddingLeft() - getPaddingRight();
                minY = getHeight() - (getPaint().getFontMetrics().descent - getPaint().getFontMetrics().ascent) - getPaddingBottom();
                maxY = getHeight() - getPaddingBottom();
                canvas.drawText("  全文", minX,
                        getHeight() - getPaint().getFontMetrics().descent - getPaddingBottom(), mPaint);
            } else {
                minX = getPaddingLeft();
                maxX = minX + getTextWidth("全文");
                minY = getHeight() - (getPaint().getFontMetrics().descent - getPaint().getFontMetrics().ascent) - getPaddingBottom();
                maxY = getHeight() - getPaddingBottom();
                canvas.drawText("全文", minX, getHeight() - getPaint().getFontMetrics().descent - getPaddingBottom(), mPaint);
            }
        }
    }
复制代码

文字截取后,重写 onDraw() 方法,计算坐标绘制文字。

PS:minX,maxX,minY,maxY这四个值是用于后面的点击事件,如果不需要可以忽略.这四个值分别对应提示语的左上角跟右下角坐标。

展开状态:

//文字展开
            SpannableStringBuilder spannable = new SpannableStringBuilder(mOriginalText);
            if (isShowTipAfterExpand) {
                spannable.append(" 收起全文");
                spannable.setSpan(new ForegroundColorSpan(mTipColor), spannable.length() - 5, spannable.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
            }
            super.setText(spannable, type);
复制代码

这里的坐标计算比上面稍稍复杂一点,这里提示语可能会出现换行的情况。

Android 可折叠TextView

所以需要增加多一个变量记录多一个y值。

int mLineCount = getLineCount();
            Layout layout = getLayout();
            minX = getPaddingLeft() + layout.getPrimaryHorizontal(spannable.toString().lastIndexOf("收") - 1);
            maxX = getPaddingLeft() + layout.getSecondaryHorizontal(spannable.toString().lastIndexOf("文") + 1);
            Rect bound = new Rect();
            if (mLineCount > originalLineCount) {
                //不在同一行
                layout.getLineBounds(originalLineCount - 1, bound);
                minY = getPaddingTop() + bound.top;
                middleY = minY + getPaint().getFontMetrics().descent - getPaint().getFontMetrics().ascent;
                maxY = middleY + getPaint().getFontMetrics().descent - getPaint().getFontMetrics().ascent;
            } else {
                //同一行
                layout.getLineBounds(originalLineCount - 1, bound);
                minY = getPaddingTop() + bound.top;
                maxY = minY + getPaint().getFontMetrics().descent - getPaint().getFontMetrics().ascent;
            }
复制代码

PS:这里我选择直接拼接的方式,如果展开状态也需要贴着边界,请参考折叠状态自行实现。

最后重写 onTouchEvent() 设置点击事件

@Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mTipClickable) {
            switch (event.getActionMasked()) {
                case MotionEvent.ACTION_DOWN:
                    clickTime = System.currentTimeMillis();
                    if (!isClickable()) {
                        if (isInRange(event.getX(), event.getY())) {
                            return true;
                        }
                    }
                    break;

                case MotionEvent.ACTION_CANCEL:
                case MotionEvent.ACTION_UP:
                    long delTime = System.currentTimeMillis() - clickTime;
                    clickTime = 0L;
                    if (delTime < ViewConfiguration.getTapTimeout() && isInRange(event.getX(), event.getY())) {
                        isExpand = !isExpand;
                        setText(mOriginalText);
                        return true;
                    }
                    break;
                default:
                    break;
            }
        }
        return super.onTouchEvent(event);
    }
复制代码

PS:ACTION_DOWN这里需要判断一下TextView本身是否设置了点击事件,如果没有的话,需要人为的retrun true,否则点击事件无法传递,若果觉得点击范围过小,可以自行调节 isInRange 方法。

Android 可折叠TextView

下面来看看方法一的两个问题产生的原因:

  • 为什么ClickableSpan的点击事件会跟TextView的点击事件同时触发

先来看看TextView的 onTouchEvent() 方法

@Override
    public boolean onTouchEvent(MotionEvent event) {
        final int action = event.getActionMasked();
        if (mEditor != null) {
            mEditor.onTouchEvent(event);

            if (mEditor.mSelectionModifierCursorController != null
                    && mEditor.mSelectionModifierCursorController.isDragAcceleratorActive()) {
                return true;
            }
        }

        final boolean superResult = super.onTouchEvent(event);
        //省略...
        if ((mMovement != null || onCheckIsTextEditor()) && isEnabled()
                && mText instanceof Spannable && mLayout != null) {
            boolean handled = false;
            if (mMovement != null) {
                handled |= mMovement.onTouchEvent(this, (Spannable) mText, event);
            }
          //省略...
            if (handled) {
                return true;
            }
        }

        return superResult;
    }
复制代码

这里可以看到先执行了父类的 onTouchEvent() 方法,然后执行了MovementMethod的 onTouchEvent() 方法。我们这里设置的是LinkMovementMethod,跟踪进去看看。

@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) {
                if (action == MotionEvent.ACTION_UP) {
                    links[0].onClick(widget);
                } else if (action == MotionEvent.ACTION_DOWN) {
                    Selection.setSelection(buffer,
                        buffer.getSpanStart(links[0]),
                        buffer.getSpanEnd(links[0]));
                }
                return true;
            } else {
                Selection.removeSelection(buffer);
            }
        }

        return super.onTouchEvent(widget, buffer, event);
    }
复制代码

前面一大段是计算点击的位置是否有ClickableSpan,有的话, ACTION_UPACTION_DOWN 就会返回true,并且在 ACTION_UP 事件调用ClickableSpan的 onClick() 方法。没有的话就返回 super.onTouchEvent(widget, buffer, event) ,断点发现这个值一直都是true(后面会用到)。

也就是说,无论点击的地方有没有ClickSpan, handled 都是true,那么TxetView的 onTouchEvent() 就会返回true,也就是说TextView会消费这个点击事件。

PS:不是很懂为什么要这么设计,按我的理解应该是先判断有没有ClickableSpan的事件,有的话就执行,返回true,告诉TextView,我消费了事件。没有的话TextView才执行父类的 onTouchEvent() 方法。

既然先执行了TextView的点击事件再去判断有没有ClickableSpan,那是不是没办法解决呢?也不是。 通过打印日志发现,TextView会先执行ClickableSpan的点击方法,然后再执行view的点击方法,原因不做深究(其实我也不知道)。 所以,可以通过重写TextView的 setOnClickListener()onClick() 方法,增加一个变量进行判断。

@Override
    public void setOnClickListener(@Nullable OnClickListener l) {
        listener = l;
        super.setOnClickListener(this);
    }
    @Override
    public void onClick(View v) {
        if (isExpandSpanClick) {
            isExpandSpanClick = false;
        } else {
            listener.onClick(v);
        }
    }
复制代码
  • 设置了ClickableSpan,为什么TextView的点击事件无法传递到父view

上面说到,在TextView的 onTouchEvent() 方法里,会先调用 super.onTouchEvent(event) ,然后调用 mMovement.onTouchEvent(this, (Spannable) mText, event) ,最后返回。因为handled的值一直都是true,也就是事件一直都被TextView消费了,导致无法传递。所以,我们要修改一下LInkMovementMethod的 onTouchEvent() 方法,当点击范围内没有ClickableSpan的时候返回false。 修改完毕后发现点击事件还是无法传递。重新回到TextView的 onTouchEvent() 方法,现在它的返回值是superResult,也就是 super.onTouchenent() 。 看下view的 onTouchEvent() 方法

public boolean onTouchEvent(MotionEvent event) {
        final float x = event.getX();
        final float y = event.getY();
        final int viewFlags = mViewFlags;
        final int action = event.getAction();

        final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
                || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
                || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;
        //...省略
        if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
            switch (action) {
                case MotionEvent.ACTION_UP:
                    //省略...
                    break;

                case MotionEvent.ACTION_DOWN:
                   //省略...
                    break;

                case MotionEvent.ACTION_CANCEL:
                   //省略...
                    break;

                case MotionEvent.ACTION_MOVE:
                   //省略...
                    break;
            }

            return true;
        }

        return false;
    }
复制代码

首先clickable这个变量,如果view可以点击、长按、上下文点击,clickable为true。一路断点到 if 这里,clickable为true,所以直接返回true,而这个值在 TextViewonTouchEvent() 里会当作返回值,就告诉父view,这个事件被消费了。可是textView默认是不能点击,长按的。也就是设置ClickableSpan的时候改变了这些设置。

public final void setMovementMethod(MovementMethod movement) {
        if (mMovement != movement) {
            mMovement = movement;

            if (movement != null && !(mText instanceof Spannable)) {
                setText(mText);
            }

            fixFocusableAndClickableSettings();

            // SelectionModifierCursorController depends on textCanBeSelected, which depends on
            // mMovement
            if (mEditor != null) mEditor.prepareCursorControllers();
        }
    }
复制代码

setMovementMethod() 方法里面有个 fixFocusableAndClickableSettings() 方法,跟踪进去

private void fixFocusableAndClickableSettings() {
        if (mMovement != null || (mEditor != null && mEditor.mKeyListener != null)) {
            setFocusable(FOCUSABLE);
            setClickable(true);
            setLongClickable(true);
        } else {
            setFocusable(FOCUSABLE_AUTO);
            setClickable(false);
            setLongClickable(false);
        }
    }
复制代码

发现这里改变了设置,引发了上面的一系列问题。 所以,我们还需要在设置ClickableSpan之后调用

setFocusable(false);
setClickable(false);
setLongClickable(false);
复制代码

这样,事件才能传递到父view

Android 可折叠TextView

有任何疑问或者demo有问题的可以在下方留言,看到了会回复的。

修复了recyclerView中复用问题,具体使用参考demo_Java版的

参考: blog.csdn.net/zhuhai__yiz…

附上demo:

Java

Kotlin


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

The Smashing Book

The Smashing Book

Jacob Gube、Dmitry Fadeev、Chris Spooner、Darius A Monsef IV、Alessandro Cattaneo、Steven Snell、David Leggett、Andrew Maier、Kayla Knight、Yves Peters、René Schmidt、Smashing Magazine editorial team、Vitaly Friedman、Sven Lennartz / 2009 / $ 29.90 / € 23.90

The Smashing Book is a printed book about best practices in modern Web design. The book shares technical tips and best practices on coding, usability and optimization and explores how to create succes......一起来看看 《The Smashing Book》 这本书的介绍吧!

SHA 加密
SHA 加密

SHA 加密工具

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具