Android撩妹特效系列!仿instagram文字自動排版功能實現!

yilian發表於2020-04-13

1 概述

玩過ins的朋友應該知道ins裡面有一個編輯文字自動排版的功能,應用會根據使用者輸入的每行文字自動進行排版,以達到一個緊湊美觀的效果。

效果圖如下:

因為最近剛好在做這樣的需求,於是對其實現原理做了研究,現在寫下這篇部落格希望能幫到有需要的人。

下面是我實現的效果圖:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-30yaU4ex-1586782824130)(https://upload-images.jianshu.io/upload_images/3117364-60933e448b6a9565?imageMogr2/auto-orient/strip)]

2 思路探究

因為網上找不到什麼相關的資料,所以就直接透過玩ins猜測大概的實現思路,我整理下自己一開始的一些疑問。

當輸入的文字越來越多,字型越來越小時怎麼保證每行最多能夠顯示的文字不變?

正常情況下,當你字型越來越小而輸入框寬度不變時,那麼你每行可輸入的文字就會變多,但是你發現ins無論字型多大,每行最多能容納的文字是不變。我猜測可能是輸入框會隨著字型的變化而改變。

透過開啟開發者選項的應用佈局邊界,可以看到確實ins的輸入框的寬度是動態變化的。

下面是開啟應用佈局邊界後的效果圖:

當然,這裡可能會引入一個新的問題,那就是輸入框的寬度是怎麼動態改變的?

好讓它剛好能夠在字型大小變化的過程中最多可容納的文字數不變。

這個問題會在下面說,這裡先不展開。

每行字型大小是怎麼確定的,又是怎樣聯動變化的?

這個問題一開始想了很久,我覺得如果把這個問題搞明白基本就已經成功一半了。大部分人一開始可能都會很容易陷入區域性思維,包括我也一樣,一直在糾結每行字型是怎麼變化的,但其實應該要從整體考慮,從整體考慮一切都會變得很簡單,程式碼實現上也會變得更加容易,不需要處理各種特殊情況。

具體思路:

遍歷每行文字,以適應最大文字寬度算出每行的字型大小,然後以每行的字型大小算出每行行高度,把每行行高度累加得到文字總高度,然後判斷文字總高度是否大於最大文字高度,如果大於則按比例縮小每行的字型大小,以縮小每行的行高度,得到新的文字總高度,直到文字總高度小於最大文字高度。

上面的這麼大段文字總結起來其實就4個步驟:

  1. 拆行

  2. 按匹配最大寬度計算每行字型大小

  3. 按匹配最大高度計算每行字型大小

  4. 重新調整EditText寬度

3 知識儲備

在動手之前我們需要知道幾個相關知識點:

span

span可以使TextView分段顯示不同樣式的文字。在自動排版中因為每行文字字型大小不一樣,所以我們需要為每行文字設定不同的span。

Layout

Layout是一個用於各種文字計算的輔助類,TextView的文字排版佈局都是依賴於Layout實現的。因為Layout是完全跟TextView解耦的,所以我們可以構建合適的Layout來幫助我們計算字型大小。

下面是Layout的官方定義:

A base class that manages text layout in visual elements on the screen.

For text that will be edited, use a DynamicLayout, which will be updated as the text changes. For text that will not change, use a StaticLayout.

Layout有幾個子類,其中較常用的是DynamicLayout和StaticLayout,按照官方的說法,當你的文字是可編輯的則使用的是DynamicLayout,當你的文字不可編輯那麼就使用StaticLayout。

所以說EditText的文字計算工作應該都是交給了DynamicLayout實現。

4 具體實現

首先我們需要監聽文字的輸入變化,當文字變化時去計算每行的字型大小,最終渲染到螢幕。監聽文字變化的程式碼如下:

  addTextChangedListener(new TextWatcher() {
            @Override
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
            }
            @Override
            public void onTextChanged(CharSequence s, int start, int before, int count) {
                refresh();
            }
            @Override
            public void afterTextChanged(Editable s) {
            }
        });

一.拆行

監聽到文字變化後需要對文字進行拆行,得到每行的文字。我們可以透過Layout實現,程式碼如下:

		String text = layout.getText().toString();
		int lineCount = layout.getLineCount();
        for (int i = 0; i < lineCount; i++) {
            int start = layout.getLineStart(i);
            int end = layout.getLineEnd(i);
            String rowStr = text.substring(start,end);
        }

但是,但是,但是,這裡有一個點需要特別注意, 不能透過EditText自帶的Layout來計算每行文字,不然拿到的每行文字是錯誤的。

為什麼呢?

舉個例子:

如下圖所示,當你在第二行將要輸入“好”時,因為你輸入"好"後該行文字寬度已經大於此時EditText的寬度了,所以“好”字會被認為是重啟一行,這樣你得到的每行文字就是錯的了,因為“好”應該顯示在第二行才對。

這就涉及到我在思路探究中提到的第一個問題,無論我字型怎麼縮小放大,如何保證每行最多可顯示的文字都是一樣的?

那麼如何保證呢?

其實很簡單,因為影響到文字自動換行的因素主要就是 字型大小和最大文字寬度,那麼只 要保證這兩個因素不變,無論你輸入什麼文字,都能準確一致的拆分出每一行的文字。

因為EditText的每行字型在變,而且寬度也在變,所以透過EditText自帶的Layout算出的每行文字肯定是錯誤的。

所以,思路應該是這樣的, 你需要構建一個用於計算的Layout,這個Layout的字型大小和寬度必須是固定不變的,這樣它就能夠保證每行最多可容納的文字始終是一樣的,這樣我就能夠準確拆分出每行文字。

前面已經說過EditText的計算工作都是交給DynamicLayout,所以我們需要建立的是DynamicLayout。程式碼如下

protected Layout buildCalculateLayout(CharSequence text,TextView host){
        TextPaint paint = new TextPaint(host.getPaint());
        paint.setTextSize(mDefFontSize);
        return new DynamicLayout(text,paint, mDefMaxTextWidth,host.getLayout().getAlignment(),host.getLayout().getSpacingMultiplier(),host.getLayout().getSpacingAdd(),host.getIncludeFontPadding());
    }

需要注意的是,這裡除了字型大小和寬度,其他的引數都需要跟EditText的引數一樣。

其中mDefFontSize是一開始定義的一個預設字型大小,mDefMaxTextWidth是EditText在沒動態調整寬度前的寬度(需要減去padding)。

這樣子每次都是透過自構建的Layout去計算每行的文字,就不需要考慮EditText的字型和寬度的動態變化。

二.按匹配最大寬度計算每行字型大小

搞定了第一步拆行後,其實已經離成功不遠了,接下來就是如何確定每行字型大小了。

確定字型大小說簡單簡單,說難也難,關鍵是看你有沒有想到那個點。比如一開始我一直糾結於每行文字是怎麼隨著輸入文字個數和行數變化動態改變的,陷入了區域性細節,搞得自己暈頭轉向,如果按照這個方向思考我感覺估計是怎麼做都搞不定的。

後來, 想了兩天後還是沒搞明白,我就試著換個思維方式,從整體來考慮,接下來就有種恍然大悟的感覺,原來其實沒那麼難。

首先,有一個規律是很顯然的:

每行文字越多,它的字型就越小,文字越少,字型就越大。

那麼我就想一開始時你 把每行文字的寬度放大到最大文字寬度,算出匹配這個寬度的字型應該多大,這樣文字越少的行,字型就越大,文字越多的行,字型就越小,這個不就是符合那個規律嗎。

計算文字寬度的程式碼如下:

  float width = paint.measureText(text);

因為需要透過不斷更改字型大小,去算出匹配最大寬度的字型,所以為了減少計算量,一開始可以做一個初始字型大小的換算。

當字型大小是mDefFontSize時對應的文字寬度是mDefMaxTextWidth,那麼當文字寬度是x時,對應的字型大小是y,因為字型大小和寬度成反比(寬度越小,字型越大),所以y的計算公式就是:

y = mDefMaxTextWidth \ x * mDefFontSizex = paint.measureText(text);

這樣我們就可以得到一個比較接近目標值的字型大小,這時候再去判斷此時文字寬度是否匹配最大文字寬度,不等於的話再去改變字型大小,直到文字寬度匹配最大文字寬度為止。

程式碼如下:

  public float calculateMatchWidthSize(Paint paint,String text,int maxWidth){
          float textSize = paint.getTextSize();
          float width = paint.measureText(text);
        
          if(maxWidth >= width && maxWidth - width <= text.length()){
              return textSize;
          }
          if(width > maxWidth){
              textSize = getNarrowFitTextSize(paint,text,maxWidth,1);
          }else{
              textSize = getZoomFitTextSize(paint,text,maxWidth,1);
          }
  
          return textSize;
      }
      private float getNarrowFitTextSize(Paint paint,String text,int maxWidth,float rate){
          float textSize = paint.getTextSize();
          textSize -= 1 * rate;
          paint.setTextSize(textSize);
          float width = paint.measureText(text);
          if(maxWidth >= width && maxWidth - width <= text.length()){
              return textSize;
          }
          //結束條件
          if(width < maxWidth){
              return getZoomFitTextSize(paint,text,maxWidth,rate);
          }else{
              return getNarrowFitTextSize(paint,text,maxWidth,rate);
          }
      }
      private float getZoomFitTextSize(Paint paint,String text,int maxWidth,float rate){
          float textSize = paint.getTextSize();
          textSize += 1 * rate;
          paint.setTextSize(textSize);
          float width = paint.measureText(text);
          if(maxWidth >= width && maxWidth - width <= text.length()){
              return textSize;
          }
          //結束條件
          if(width < maxWidth){
              return getZoomFitTextSize(paint,text,maxWidth,rate);
          }else{
              return getNarrowFitTextSize(paint,text,maxWidth,rate);
          }
      }

三.按匹配最大高度計算每行字型大小

按照匹配最大寬度計算出來的字型會很大,導致文字高度很高,這時候就需要再動態調整每行字型大小,直到文字高度匹配最大高度為止。

動態調整字型大小時,每行文字的字型大小需要按比例調整,比如每行字型都調整為原來的0.9倍大小。

計算每行文字高度的程式碼:

 int height = paint.getFontMetricsInt(null);

為了讓每行文字高度的累加值等於文字實際總高度,需要設定EditText的邊距為0並且去掉文字上下的空白部分。程式碼如下:

        //去掉文字上下空白區域
        mHost.setIncludeFontPadding(false);
        //不設定行間距
        mHost.setLineSpacing(0,1);

為了提高計算速度,採用二分法來動態調整字型大小,程式碼如下:

    /**
     * 二分法查詢合適的字型大小,字型大小按比例調整
     * @return
     */
    private void calculateMatchHeightSizeByRate(float lowRate,float highRate,int minHeight,int maxHeight){
        if(highRate - lowRate <= RATE_SCALE_ERROR_VALUE){
            return;
        }
        float middleRate= (lowRate+highRate)/2;
        scaleFontSizeByRate(middleRate);
        int height = getTextHeight();
        if(height > maxHeight){
            //縮小字型後文字高度大於最大值,需要繼續縮小字型
            highRate = middleRate;
            calculateMatchHeightSizeByRate(lowRate,highRate,minHeight,maxHeight);
        } else if(height < minHeight){
            //縮小字型後文字高度小於最小值,需要放大字型
            lowRate = middleRate;
            calculateMatchHeightSizeByRate(lowRate,highRate,minHeight,maxHeight);
        }
    }
    
	private int getTextHeight(){
        int totalHeight = 0;
        for(CustomSpanData customSpanData: mCustomTextSpanDataList){
            int lineHeight = getSingleLineHeight(customSpanData.getTextSize());
            totalHeight += lineHeight;
        }
        return totalHeight;
    }
    private int getSingleLineHeight(float fontSize){
        Paint paint = new Paint(mHost.getPaint());
        paint.setTextSize(fontSize);
        return paint.getFontMetricsInt(null);
    }
    private void scaleFontSizeByRate(float rate){
        for(int i=0;i<mOriFontSizePxList.size();i++){
            float fontSize = mOriFontSizePxList.get(i) * rate;
            mCustomTextSpanDataList.get(i).setTextSize(UNIT_PX,fontSize);
        }
    }

四.重新調整EditText的寬度

前面有提到一個問題那就是輸入框的寬度是怎麼動態改變的?

閱讀了前三個步驟後是不是已經有了答案,首先透過自構建的Layout確定每行需要顯示什麼文字,然後動態調整每行字型大小以適應輸入框的寬高,這時候可能每行的字型已經很小了,如果不調整EditText的寬度必然會導致不同行的文字頂到同一行顯示。

所以,最後一步需要把輸入框的寬度調整為所有行中寬度最大的那一行的寬度。

5 總結

這裡說下我在這個過程中踩的坑。

一開始我用的span是自己寫的一個繼承ReplacementSpan的自定義span,然後就一直存在刪除文字時都是整行刪除的問題,不能刪除單個字元,後來看了原始碼發現EditText對ReplacementSpan的處理是直接當成一個整體,所以刪除也是整個ReplacementSpan都刪除掉。

監聽文字的變化一開始我是放在onPreDraw方法實現的,後來發現會出現文字換行時跳動的問題,正確的方法應該是放在TextWatcher的onTextChanged方法實現。

一開始陷入了區域性思維,一直在思考每行字型大小是怎麼變化的,後來才發現應該要整體考慮,不需要考慮區域性,這個浪費了我很多時間。

原始碼

這是 ,如果覺得對你有幫助麻煩幫忙點個Star哈,您的支援是我不斷創作的動力

最後我想說,作為一個Android程式設計師,要學的東西有太多太多了,對於進階這條路而言,學習是會有回報的!

你把你的時間投資在學習上,就意味著你可以收穫技能,更有機會增加收入。

分享我的Android學習PDF大全來學習,這份Android學習PDF大全真的包含了方方面面了,內含Java基礎知識點、Android基礎、Android進階延伸、演算法合集等等

獲取方式:點贊+關注,私信我,或直接  免費領取

我的這份學習合集,可以有效的幫助大家掌握知識點。

總之也是在這裡幫助大家學習提升進階,也節省大家在網上搜尋資料的時間來學習,也可以分享給身邊好友一起學習


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69952849/viewspace-2685902/,如需轉載,請註明出處,否則將追究法律責任。

相關文章