Android 可折疊TextView

本篇文章已授權(quán)微信公眾號(hào) guolin_blog (郭霖)獨(dú)家發(fā)布

當(dāng)文字內(nèi)容超過(guò)指定行數(shù)后,顯示省略號(hào)和全文。



上圖的效果在微博,b站上都有艾蓝。這里我選擇繼承AppCompatTextView實(shí)現(xiàn)。

實(shí)現(xiàn)思路
  • 當(dāng)內(nèi)容超過(guò)指定行數(shù)后斗塘,計(jì)算最大行數(shù)第一個(gè)(start)和最后一個(gè)字符(end)在整個(gè)字符串里面的位置
  • 測(cè)量要拼接的內(nèi)容(demo中是... 全文)的寬度
  • 計(jì)算跟拼接內(nèi)容寬度相當(dāng)?shù)淖址麄€(gè)數(shù)(num)
  • 將整個(gè)字符串從0到(end-num)進(jìn)行截取
  • 拼接要顯示的內(nèi)容,設(shè)置點(diǎn)擊事件
  • 設(shè)置文字

這里會(huì)有一點(diǎn)小小的問(wèn)題赢织,提示內(nèi)容是緊跟著原來(lái)的文本,而不是在TextView的邊界上馍盟。而且用ClickableSpan設(shè)置點(diǎn)擊事件敌厘,如果TextView設(shè)置了點(diǎn)擊事件,會(huì)跟TextView本身的點(diǎn)擊事件同時(shí)觸發(fā)朽合。沒(méi)有設(shè)置的話俱两,點(diǎn)擊其他文字,點(diǎn)擊事件不會(huì)傳遞到父View曹步,而是被TextView消費(fèi)掉宪彩。如下圖:


同時(shí)響應(yīng)點(diǎn)擊事件

點(diǎn)擊事件無(wú)法傳遞給父View
這里再提供一種思路
  • 前幾步跟上面相同,都是對(duì)內(nèi)容進(jìn)行截取讲婚,設(shè)置文字
  • 文字繪制完畢后尿孔,手動(dòng)把提示的內(nèi)容繪制上去
  • 添加點(diǎn)擊事件

這種方法可以保證提示在TextView的邊界上,但是點(diǎn)擊事件需要自己重寫onTouchEvent()自己設(shè)置,略顯麻煩活合。

腦殼痛

下面講講具體實(shí)現(xiàn)的關(guān)鍵步驟:

內(nèi)容截取
 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--;
            }

當(dāng)內(nèi)容行數(shù)超過(guò)最大行數(shù)時(shí)雏婶,對(duì)文本進(jìn)行截取。layout是TextView的layout白指,這里的getLineStart()getLineEnd()分別獲取該行的第一個(gè)和最后一個(gè)字符的位置(這里的位置是從第一個(gè)字符開始算的)留晚。breakText()方法計(jì)算出要截取的字符個(gè)數(shù)。
這里簡(jiǎn)單說(shuō)下breakText()這個(gè)方法:
參數(shù):

  • 測(cè)量的字符串
  • 測(cè)量開始的位置
  • 測(cè)量結(jié)束的位置
  • 測(cè)量方向,true從前往后告嘲,false從后往前
  • 截取的字符串最大寬度
  • 截取字符串實(shí)際寬度

最后返回需要截取的字符個(gè)數(shù)错维。
內(nèi)容截取完后,對(duì)提示文本進(jìn)行處理橄唬,下面分別對(duì)兩種方法進(jìn)行簡(jiǎn)單講解

方法一:直接拼接內(nèi)容赋焕,設(shè)置點(diǎn)擊事件
  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);

通過(guò)SpannableString在截取后的內(nèi)容上進(jìn)行文字的拼接,并且設(shè)置相應(yīng)的點(diǎn)擊事件仰楚。

方法二:重寫onDraw()方法繪制提示文字隆判,重寫onTouchEvent()設(shè)置點(diǎn)擊事件

折疊狀態(tài):

    @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()方法僧界,計(jì)算坐標(biāo)繪制文字侨嘀。
PS:minX,maxX,minY,maxY這四個(gè)值是用于后面的點(diǎn)擊事件,如果不需要可以忽略.這四個(gè)值分別對(duì)應(yīng)提示語(yǔ)的左上角跟右下角坐標(biāo)捎泻。
展開狀態(tài):

 //文字展開
            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);

這里的坐標(biāo)計(jì)算比上面稍稍復(fù)雜一點(diǎn),這里提示語(yǔ)可能會(huì)出現(xiàn)換行的情況埋哟。



所以需要增加多一個(gè)變量記錄多一個(gè)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:這里我選擇直接拼接的方式,如果展開狀態(tài)也需要貼著邊界赤赊,請(qǐng)參考折疊狀態(tài)自行實(shí)現(xiàn)闯狱。
最后重寫onTouchEvent()設(shè)置點(diǎn)擊事件

  @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本身是否設(shè)置了點(diǎn)擊事件,如果沒(méi)有的話抛计,需要人為的retrun true哄孤,否則點(diǎn)擊事件無(wú)法傳遞,若果覺(jué)得點(diǎn)擊范圍過(guò)小吹截,可以自行調(diào)節(jié)isInRange方法瘦陈。

大功告成

下面來(lái)看看方法一的兩個(gè)問(wèn)題產(chǎn)生的原因:
  • 為什么ClickableSpan的點(diǎn)擊事件會(huì)跟TextView的點(diǎn)擊事件同時(shí)觸發(fā)

先來(lái)看看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;
    }

這里可以看到先執(zhí)行了父類的onTouchEvent()方法,然后執(zhí)行了MovementMethod的onTouchEvent()方法波俄。我們這里設(shè)置的是LInkMovementMethod晨逝,跟蹤進(jìn)去看看。

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

前面一大段是計(jì)算點(diǎn)擊的位置是否有ClickableSpan懦铺,有的話捉貌,ACTION_UPACTION_DOWN就會(huì)返回true,并且在ACTION_UP事件調(diào)用ClickableSpan的onClick()方法。沒(méi)有的話就返回super.onTouchEvent(widget, buffer, event),斷點(diǎn)發(fā)現(xiàn)這個(gè)值一直都是true(后面會(huì)用到)趁窃。
也就是說(shuō)牧挣,無(wú)論點(diǎn)擊的地方有沒(méi)有ClickSpan,handled都是true,那么TxetView的onTouchEvent()就會(huì)返回true醒陆,也就是說(shuō)TextView會(huì)消費(fèi)這個(gè)點(diǎn)擊事件瀑构。
PS:不是很懂為什么要這么設(shè)計(jì),按我的理解應(yīng)該是先判斷有沒(méi)有ClickableSpan的事件统求,有的話就執(zhí)行检碗,返回true,告訴TextView码邻,我消費(fèi)了事件折剃。沒(méi)有的話TextView才執(zhí)行父類的onTouchEvent()方法。
既然先執(zhí)行了TextView的點(diǎn)擊事件再去判斷有沒(méi)有ClickableSpan像屋,那是不是沒(méi)辦法解決呢怕犁?也不是。
通過(guò)打印日志發(fā)現(xiàn)己莺,TextView會(huì)先執(zhí)行ClickableSpan的點(diǎn)擊方法奏甫,然后再執(zhí)行view的點(diǎn)擊方法,原因不做深究(其實(shí)我也不知道)凌受。
所以阵子,可以通過(guò)重寫TextView的setOnClickListener()onClick()方法,增加一個(gè)變量進(jìn)行判斷胜蛉。

    @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);
        }
    }
  • 設(shè)置了ClickableSpan挠进,為什么TextView的點(diǎn)擊事件無(wú)法傳遞到父view

上面說(shuō)到,在TextView的onTouchEvent()方法里誊册,會(huì)先調(diào)用super.onTouchEvent(event),然后調(diào)用mMovement.onTouchEvent(this, (Spannable) mText, event)领突,最后返回。因?yàn)閔andled的值一直都是true案怯,也就是事件一直都被TextView消費(fèi)了君旦,導(dǎo)致無(wú)法傳遞。所以嘲碱,我們要修改一下LInkMovementMethod的onTouchEvent()方法金砍,當(dāng)點(diǎn)擊范圍內(nèi)沒(méi)有ClickableSpan的時(shí)候返回false。
修改完畢后發(fā)現(xiàn)點(diǎn)擊事件還是無(wú)法傳遞麦锯。重新回到TextView的onTouchEvent()方法捞魁,現(xiàn)在它的返回值是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這個(gè)變量谱俭,如果view可以點(diǎn)擊奉件、長(zhǎng)按、上下文點(diǎn)擊昆著,clickable為true县貌。一路斷點(diǎn)到if這里,clickable為true凑懂,所以直接返回true煤痕,而這個(gè)值在TextViewonTouchEvent()里會(huì)當(dāng)作返回值,就告訴父view接谨,這個(gè)事件被消費(fèi)了摆碉。可是textView默認(rèn)是不能點(diǎn)擊脓豪,長(zhǎng)按的巷帝。也就是設(shè)置ClickableSpan的時(shí)候改變了這些設(shè)置。

    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()方法里面有個(gè)fixFocusableAndClickableSettings()方法扫夜,跟蹤進(jìn)去

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

發(fā)現(xiàn)這里改變了設(shè)置楞泼,引發(fā)了上面的一系列問(wèn)題。
所以笤闯,我們還需要在設(shè)置ClickableSpan之后調(diào)用

setFocusable(false);
setClickable(false);
setLongClickable(false);

這樣堕阔,事件才能傳遞到父view


最終效果

有任何疑問(wèn)或者demo有問(wèn)題的可以在下方留言,看到了會(huì)回復(fù)的颗味。


修復(fù)了recyclerView中復(fù)用問(wèn)題超陆,具體使用參考demo_Java版的

參考:
https://blog.csdn.net/zhuhai__yizhi/article/details/53760663
附上demo:
Java
Kotlin

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市浦马,隨后出現(xiàn)的幾起案子时呀,更是在濱河造成了極大的恐慌,老刑警劉巖捐韩,帶你破解...
    沈念sama閱讀 216,692評(píng)論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件退唠,死亡現(xiàn)場(chǎng)離奇詭異鹃锈,居然都是意外死亡荤胁,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,482評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門屎债,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)仅政,“玉大人,你說(shuō)我怎么就攤上這事盆驹≡驳ぃ” “怎么了?”我有些...
    開封第一講書人閱讀 162,995評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵躯喇,是天一觀的道長(zhǎng)辫封。 經(jīng)常有香客問(wèn)我硝枉,道長(zhǎng),這世上最難降的妖魔是什么倦微? 我笑而不...
    開封第一講書人閱讀 58,223評(píng)論 1 292
  • 正文 為了忘掉前任妻味,我火速辦了婚禮,結(jié)果婚禮上欣福,老公的妹妹穿的比我還像新娘责球。我一直安慰自己,他們只是感情好拓劝,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,245評(píng)論 6 388
  • 文/花漫 我一把揭開白布雏逾。 她就那樣靜靜地躺著,像睡著了一般郑临。 火紅的嫁衣襯著肌膚如雪栖博。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,208評(píng)論 1 299
  • 那天牧抵,我揣著相機(jī)與錄音笛匙,去河邊找鬼。 笑死犀变,一個(gè)胖子當(dāng)著我的面吹牛妹孙,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播获枝,決...
    沈念sama閱讀 40,091評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼蠢正,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了省店?” 一聲冷哼從身側(cè)響起嚣崭,我...
    開封第一講書人閱讀 38,929評(píng)論 0 274
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎懦傍,沒(méi)想到半個(gè)月后雹舀,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,346評(píng)論 1 311
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡粗俱,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,570評(píng)論 2 333
  • 正文 我和宋清朗相戀三年说榆,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片寸认。...
    茶點(diǎn)故事閱讀 39,739評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡签财,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出偏塞,到底是詐尸還是另有隱情唱蒸,我是刑警寧澤,帶...
    沈念sama閱讀 35,437評(píng)論 5 344
  • 正文 年R本政府宣布灸叼,位于F島的核電站神汹,受9級(jí)特大地震影響庆捺,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜屁魏,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,037評(píng)論 3 326
  • 文/蒙蒙 一疼燥、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧蚁堤,春花似錦醉者、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,677評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至呈队,卻和暖如春剥槐,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背宪摧。 一陣腳步聲響...
    開封第一講書人閱讀 32,833評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工粒竖, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人几于。 一個(gè)月前我還...
    沈念sama閱讀 47,760評(píng)論 2 369
  • 正文 我出身青樓蕊苗,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親沿彭。 傳聞我的和親對(duì)象是個(gè)殘疾皇子朽砰,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,647評(píng)論 2 354

推薦閱讀更多精彩內(nèi)容