實現各種展開、收回TextView需求

前言

最近慢慢習慣了新環境,也漸漸的變得忙碌起來。之前暴雷的事情有同學還是比較關注,我想說的是,已經一而再再而三的展期了,老賴加上老賴平臺,結果是相當明確的,不說了,說多了都是淚。

前兩天接到一個需求,需要完成以下效果。 
+ 1、內容超過指定行數需要摺疊起來; 
+ 2、內容中有鏈接的話,需要隱藏鏈接,將鏈接顯示成“網頁鏈接”,並實現點擊跳轉網頁; 
+ 3、內容中含有@+“內容”,需要攜帶“內容”跳轉指定頁面。 
+ 4、有可能會在“展開”或者“收回”前面附加顯示其他內容,比如demo裏面的時間串

目標效果

Demo效果實現

下面是實現的效果圖,@用戶和鏈接會高亮顯示,可以點擊,包含展開和回收功能。以下做了不同情況下的顯示效果:

tips.jpg

實現思路

主流思路有兩個:一個是曲線救國,另一個是對着TextView直接擼

思路一、曲線救國

用兩個TextView來分別顯示,上面的主要負責顯示內容,下面的負責展開和收回的功能。這種方式實現起來的好處是實現比較簡單,缺點是很難做到如圖所示在文字的最後添加展開和收回兩個字,也就是很難還原設計稿;而且對於內容還是需要額外處理@用戶和鏈接的操作,不太方便。

思路二、對着TextView直接擼

所謂“對着TextView直接擼”就是自定義View繼承TextView,在自定義View裏面去處理所有的邏輯,好處是用起來方便點,而且也能儘量還原設計稿。在這裏我們採用第二種方式,第一種方式提供一個思路,大家感興趣的可以自己試試。

具體實現

考慮在先

在開始寫代碼之前,我們需要考慮幾個點 
一、怎麼保證“展開”或者“收回”放在文字的最後面 
二、如何識別文字中的@用戶 
三、如何識別文字中的鏈接 
四、處理@用戶,鏈接和“展開”或者“收回”三者的高亮顯示和點擊事件

解決問題

一、怎麼保證“展開”或者“收回”放在文字的最後面

其實這個問題算是整個實現中最難的一個吧!在此之前也是讓我頭疼的一個問題,不過後來我遇到了DynamicLayout,使用它我們可以獲取行的最後位置,行的開始位置,行的行寬以及指定內容的所佔的行數。

        //用來計算內容的大小
        DynamicLayout mDynamicLayout =
                new DynamicLayout(mFormatData.formatedContent, mPaint, mWidth, Layout.Alignment.ALIGN_NORMAL, 1.2f, 0.0f,
                        true);
        //獲取行數
        int mLineCount = mDynamicLayout.getLineCount();
        int index = currentLines - 1;
        //獲取指定行的最後位置
        int endPosition = mDynamicLayout.getLineEnd(index);
        //獲取指定行的開始位置
        int startPosition = mDynamicLayout.getLineStart(index);
        //獲取指定行的行寬
        float lineWidth = mDynamicLayout.getLineWidth(index);

下面這個圖會對上面的參數進行簡單的說明: 
參數說明
有了這些東西經過簡單的計算我們就可以獲取到我們需要截取的內容長度。對原內容進行截取再拼接上“展開”或“收回”即可!

     /**
     * 計算原內容被裁剪的長度
     *
     * @param endPosition
     * @param startPosition
     * @param lineWidth
     * @param endStringWith
     * @param offset
     * @return
     */
    private int getFitPosition(int endPosition, int startPosition, float lineWidth,
                               float endStringWith, float offset, String aimContent) {
        //最後一行需要添加的文字的字數                       
        int position = (int) ((lineWidth - (endStringWith + offset)) * (endPosition - startPosition)/ lineWidth);

        if (position < 0) return endPosition;
        //計算最後一行需要顯示的正文的長度
        float measureText = mPaint.measureText(
                (aimContent.substring(startPosition, startPosition + position)));
        //如果最後一行需要顯示的正文的長度比最後一行的長減去“展開”文字的長度要短就可以了  否則加個空格繼續算
        if (measureText <= lineWidth - endStringWith) {
            return startPosition + position;
        } else {
            return getFitPosition(endPosition, startPosition, lineWidth, endStringWith, offset + mPaint.measureText(" "));
        }
    }

二、如何識別文字中的@用戶

使用正則表達式對原內容進行匹配,下面是正則表達式:

@[\w\p{InCJKUnifiedIdeographs}-]{1,26}

將匹配到內容做一下記錄,最後再使用SpannableStringBuilder對匹配到的內容設置可點擊的span並設置其他顏色等具體樣式。在以下代碼中,我們將匹配到的信息的內容和位置信息保存下來,後面會用到的。對於@用戶這塊,後面會提到怎麼添加高亮顯示和添加點擊事件。

    //對@用戶 進行正則匹配
    Pattern pattern = Pattern.compile(regexp_mention, Pattern.CASE_INSENSITIVE);
    Matcher matcher = pattern.matcher(newResult.toString());
    List<FormatData.PositionData> datasMention = new ArrayList<>();
    while (matcher.find()) {
        //將匹配到的內容進行統計處理
        datasMention.add(new FormatData.PositionData(matcher.start(), matcher.end(), matcher.group(), LinkType.MENTION_TYPE));
    }

三、如何識別文字中的鏈接

在開始的時候,找了很多的匹配文字中鏈接的正則表達式,後來發現好多都有問題。聯想到TextView本身就有對鏈接跳轉的支持,就想着TextView的內部一定有相關的正則來匹配,後來查看TextView的源碼,發現還真有。

對於鏈接,後面會提到怎麼添加高亮顯示和添加點擊事件。下面是匹配鏈接的代碼:

        List<FormatData.PositionData> datas = new ArrayList<>();
        //對鏈接進行正則匹配
        Pattern pattern = AUTOLINK_WEB_URL;
        Matcher matcher = pattern.matcher(content);
        StringBuffer newResult = new StringBuffer();
        int start = 0;
        int end = 0;
        int temp = 0;
        while (matcher.find()) {
            start = matcher.start();
            end = matcher.end();
            newResult.append(content.toString().substring(temp, start));
            //將匹配到的內容進行統計處理
            datas.add(new FormatData.PositionData(newResult.length() + 1, newResult.length() + 2 + TARGET.length(), matcher.group(), LinkType.LINK_TYPE));
            newResult.append(" " + TARGET + " ");
            temp = end;
        }

除了對鏈接進行匹配以外,我們還需要將識別到的鏈接用掩碼隱藏起來。如何掩碼呢?也就是把原文中的鏈接用“網頁鏈接”替換掉。那麼如何替換掉呢?上面的代碼中我們會獲取到對應的鏈接以及鏈接所在的位置,那麼我們只需要使用“網頁鏈接”替換掉匹配到的鏈接即可。

//newResult是最終會顯示在頁面上的內容容器
newResult.append(content.toString().substring(end, content.toString().length()));

四、處理@用戶,鏈接和“展開”或者“收回”三者的高亮顯示和點擊事件

對於@用戶,鏈接和“展開”或者“收回”三者的實現,最終都是使用SpannableStringBuilder來處理。之前我們在對原內容進行解析的時候,將匹配到的鏈接或者@用戶進行了存儲,並且存儲了他們所在的位置(start,end)以及類型。

    //定義類型的枚舉類型
    public enum LinkType {
        //普通鏈接
        LINK_TYPE,
        //@用戶
        MENTION_TYPE
    }

有了這些數據的集合,我們只需要遍歷這些數據,並分別對這些數據進行setSpan處理,並且在setSpan的過程中設置字體顏色,以及點擊事件的回調即可。

//處理鏈接或者@用戶
    private void dealLinksOrMention(FormatData formatData,SpannableStringBuilder ssb) {
        List<FormatData.PositionData> positionDatas = formatData.getPositionDatas();
        HH:
        for (FormatData.PositionData data : positionDatas) {
            if (data.getType().equals(LinkType.LINK_TYPE)) {
                int fitPosition = ssb.length() - getHideEndContent().length();
                if (data.getStart() < fitPosition) {
                    SelfImageSpan imageSpan = new SelfImageSpan(mLinkDrawable, ImageSpan.ALIGN_BASELINE);
                    //設置鏈接圖標
                    ssb.setSpan(imageSpan, data.getStart(), data.getStart() + 1, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
                    //設置鏈接文字樣式
                    int endPosition = data.getEnd();
                    if (fitPosition > data.getStart() + 1 && fitPosition < data.getEnd()) {
                        endPosition = fitPosition;
                    }
                    if (data.getStart() + 1 < fitPosition) {
                        ssb.setSpan(new ClickableSpan() {
                            @Override
                            public void onClick(View widget) {
                                if (linkClickListener != null)
                                    linkClickListener.onLinkClickListener(LinkType.LINK_TYPE, data.getUrl());
                            }

                            @Override
                            public void updateDrawState(TextPaint ds) {
                                ds.setColor(mLinkTextColor);
                                ds.setUnderlineText(false);
                            }
                        }, data.getStart() + 1, endPosition, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
                    }
                }
            } else {
                int fitPosition = ssb.length() - getHideEndContent().length();
                if (data.getStart() < fitPosition) {
                    int endPosition = data.getEnd();
                    if (fitPosition < data.getEnd()) {
                        endPosition = fitPosition;
                    }
                    ssb.setSpan(new ClickableSpan() {
                        @Override
                        public void onClick(View widget) {
                            if (linkClickListener != null)
                                linkClickListener.onLinkClickListener(LinkType.MENTION_TYPE, data.getUrl());
                        }

                        @Override
                        public void updateDrawState(TextPaint ds) {
                            ds.setColor(mLinkTextColor);
                            ds.setUnderlineText(false);
                        }
                    }, data.getStart(), endPosition, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
                }
            }
        }
    }

    /**
     * 設置 "展開"
     * @param ssb
     * @param formatData
     */
    private void setExpandSpan(SpannableStringBuilder ssb,FormatData formatData){
        int index = currentLines - 1;
        int endPosition = mDynamicLayout.getLineEnd(index);
        int startPosition = mDynamicLayout.getLineStart(index);
        float lineWidth = mDynamicLayout.getLineWidth(index);

        String endString = getHideEndContent();

        //計算原內容被截取的位置下標
        int fitPosition =
                getFitPosition(endPosition, startPosition, lineWidth, mPaint.measureText(endString), 0);

        ssb.append(formatData.formatedContent.substring(0, fitPosition));

        //在被截斷的文字後面添加 展開 文字
        ssb.append(endString);

        int expendLength = TextUtils.isEmpty(mEndExpandContent) ? 0 : 2 + mEndExpandContent.length();
        ssb.setSpan(new ClickableSpan() {
            @Override
            public void onClick(View widget) {
                action();
            }

            @Override
            public void updateDrawState(TextPaint ds) {
                super.updateDrawState(ds);
                ds.setColor(mExpandTextColor);
                ds.setUnderlineText(false);
            }
        }, ssb.length() - TEXT_EXPEND.length() - expendLength, ssb.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
    }

在處理這一塊的時候有個細節需要注意,那就是假如在文字切割後的末尾正好有個一個鏈接,而這個地方又要顯示“展開”或者“收回”,這個地方要特別注意鏈接setSpan的範圍,一不注意就可能連同把後面的“展開”或者“收回”也一起設置了,導致事件不對。處理“收回”是差不多的,就不貼代碼了。最後還有一個附加功能就是在最後添加時間串的功能,其實也就是在“展開”和“收回”前面加一個串,做好這方面的判斷就好了,代碼裏面已經做了處理。具體可以去Github上面去看。

項目地址和結語

Github地址: ExpandableTextView

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章