【需求解決系列之三】Android 自定義可展開收回的ExpandableTextView

前言

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

前兩天接到一個需求,須要完成如下效果。git

  • 一、內容超過指定行數須要摺疊起來;
  • 二、內容中有連接的話,須要隱藏連接,將連接顯示成「網頁連接」,並實現點擊跳轉網頁;
  • 三、內容中含有@+「內容」,須要攜帶「內容」跳轉指定頁面。
  • 四、有可能會在「展開」或者「收回」前面附加顯示其餘內容,好比demo裏面的時間串

目標效果

Demo效果實現

下面是實現的效果圖,@用戶和連接會高亮顯示,能夠點擊,包含展開和回收功能。如下作了不一樣狀況下的顯示效果:github

tips.jpg

Demo下載體驗

Demo下載正則表達式

掃描二維碼下載 bash

掃描二維碼下載

實現思路

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

思路1、曲線救國

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

思路2、對着TextView直接擼

所謂「對着TextView直接擼」就是自定義View繼承TextView,在自定義View裏面去處理全部的邏輯,好處是用起來方便點,並且也能儘可能還原設計稿。在這裏咱們採用第二種方式,第一種方式提供一個思路,你們感興趣的能夠本身試試。字體

具體實現

考慮在先

在開始寫代碼以前,咱們須要考慮幾個點ui

  • 1、怎麼保證「展開」或者「收回」放在文字的最後面
  • 2、如何識別文字中的@用戶
  • 3、如何識別文字中的連接
  • 4、處理@用戶,連接和「展開」或者「收回」三者的高亮顯示和點擊事件

解決問題

1、怎麼保證「展開」或者「收回」放在文字的最後面

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

//用來計算內容的大小
        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(" "));
        }
    }
複製代碼

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

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

@[\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));
    }
複製代碼

3、如何識別文字中的連接

在開始的時候,找了不少的匹配文字中連接的正則表達式,後來發現好多都有問題。聯想到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()));
複製代碼

4、處理@用戶,連接和「展開」或者「收回」三者的高亮顯示和點擊事件

對於@用戶,連接和「展開」或者「收回」三者的實現,最終都是使用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

若是鏈接失效就直接點擊這個連接吧!github.com/MZCretin/Ex…

您的star就是對我最大的鼓勵!

關於個人

我就是比較喜歡用代碼解決生活中的問題,感受很開心,哈哈哈。也但願你們關注個人簡書,掘金,Github和CSDN。

簡書首頁,連接是 www.jianshu.com/u/123f97613…

掘金首頁,連接是 juejin.im/user/5838d5…

Github首頁,連接是 github.com/MZCretin

CSDN首頁,連接是 blog.csdn.net/u010998327

我是Cretin,一個可愛的小男孩。

相關文章
相關標籤/搜索