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

Roll圈圈發表於2018-08-30

前言

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

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

  • 1、內容超過指定行數需要摺疊起來;
  • 2、內容中有連結的話,需要隱藏連結,將連結顯示成“網頁連結”,並實現點選跳轉網頁;
  • 3、內容中含有@+“內容”,需要攜帶“內容”跳轉指定頁面。
  • 4、有可能會在“展開”或者“收回”前面附加顯示其他內容,比如demo裡面的時間串

目標效果

Demo效果實現

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

tips.jpg

Demo下載體驗

Demo下載

掃描二維碼下載

掃描二維碼下載

實現思路

主流思路有兩個:一個是曲線救國,另一個是對著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

如果連線失效就直接點選這個連結吧!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,一個可愛的小男孩。

相關文章