HenCoder Android 開發進階:自定義 View 1-3 文字的繪製

扔物線發表於2017-07-24

這期是 HenCoder 自定義繪製的第三期:文字的繪製。

之前的內容在這裡:
HenCoder Android 開發進階 自定義 View 1-1 繪製基礎
HenCoder Android 開發進階 自定義 View 1-2 Paint 詳解

如果你沒聽說過 HenCoder,可以先看看這個:
HenCoder:給高階 Android 工程師的進階手冊

簡介

上期的 Paint 詳解裡已經說過,文字的繪製所能控制的內容太多太細,必須拆成單獨的一期專門來講。今天這期,就是來把這些細節講清楚的。

需要說明的有兩點:

  1. 和上期一樣,這期講的是細節,其中有一部分內容並不是很常用,所以這期你不必要求自己把內容全部背會,而只要做到全部理解,知道都有什麼東西,大概怎麼用就好,到你真正需要用的時候再拐回來看就是;
  2. 除了常用和不常用的內容,本期還會講到一些比較偏門的細節。這些偏門幾乎永遠不會用到,我講這些偏門的目的也只是為了做到知識的全覆蓋,幫你破開迷霧解開謎團,把那些「始終沒有搞懂,也不知道有沒有用」的 API 解釋出來。有的時候,一樣東西你確定了它確實沒用,也就夠了。所以如果遇到這些偏門的內容,你感興趣,看看就好;不感興趣,不看也罷——總之,不要把太多精力放在它們身上。

下面進入正題。

1 Canvas 繪製文字的方式

Canvas 的文字繪製方法有三個:drawText() drawTextRun()drawTextOnPath()

1.1 drawText(String text, float x, float y, Paint paint)

drawText()Canvas 最基本的繪製文字的方法:給出文字的內容和位置, Canvas 按要求去繪製文字。

String text = "Hello HenCoder";

...

canvas.drawText(text, 200, 100, paint);複製程式碼

方法的引數很簡單: text 是文字內容,xy 是文字的座標。但需要注意:這個座標並不是文字的左上角,而是一個與左下角比較接近的位置。大概在這裡:

而如果你像繪製其他內容一樣,在繪製文字的時候把座標填成 (0, 0),文字並不會顯示在 View 的左上角,而是會幾乎完全顯示在 View 的上方,到了 View 外部看不到的位置:

canvas.drawText(text, 0, 0, paint);複製程式碼

↑ 這裡沒有貼錯圖哦

再附上一張圖,應該能更清楚地表達:

這是為什麼?為什麼其它的 Canvas.drawXXX() 方法,都是以左上角作為基準點的,而 drawText() 卻是文字左下方?

先別覺得日了狗,這種設計其實是有道理的。drawText() 引數中的 y ,指的是文字的基線( baseline ) 的位置。也就是這條線:

眾所周知,不同的語言和文字,每個字元的高度和上下位置都是不一樣的。要讓不同的文字並排顯示的時候整體看起來穩當,需要讓它們上下對齊。但這個對齊的方式,不能是簡單的「底部對齊」或「頂部對齊」或「中間對齊」,而應該是一種類似於「重心對齊」的方式。就像電線上的小鳥一樣:

每隻小鳥的最高點和最低點都不一樣,但畫面很平衡

而這個用來讓所有文字互相對齊的基準線,就是基線( baseline )drawText() 方法引數中的 y 值,就是指定的基線的位置。

說完 y 值,再說說 x 值。從前面圖中的標記可以看出來,「Hello HenCoder」繪製出來之後的 x 點並不是字母 "H" 左邊的位置,而是比它的左邊再往左一點點。那麼這個「往左的一點點」是什麼呢?

它是字母 "H" 的左邊的空隙。絕大多數的字元,它們的寬度都是要略微大於實際顯示的寬度的。字元的左右兩邊會留出一部分空隙,用於文字之間的間隔,以及文字和邊框的間隔。就像這樣:

用豎線標記出邊界後的文字。

所以,明白為什麼 x 座標在 "H" 的左邊再往左一點點的位置,而不是緊貼著 "H" 的左邊線了嗎?就是因為 "H" 的這個留出的空隙。

除了 drawText(text, x, y, paint) 之外, drawText() 還有幾個過載方法,使用方式跟這個都差不多,我就不說了,你自己看吧。

1.2 drawTextRun()

宣告:這個方法對中國人沒用。所以如果你有興趣,可以繼續看;而如果你想省時間,直接跳過這個方法看後面的就好了,沒有任何毒副作用。

drawTextRun() 是在 API 23 新加入的方法。它和 drawText() 一樣都是繪製文字,但加入了兩項額外的設定——上下文和文字方向——用於輔助一些文字結構比較特殊的語言的繪製。

  • 額外設定一:上下文。

    有些語言的文字,字元的形狀會互相之間影響:一個字你單獨寫是一個樣,和別的字放在一起寫又是另外一個樣。不過由於我們最熟悉的語言——漢語和英語——都沒有這種情況,所以只靠說可能不太好理解,我就用圖說明一下吧。

    以阿拉伯文為例。阿拉伯文裡的「عربى(阿拉伯)」是一個四字詞,它的中間兩個字元「رب」在這個詞裡的樣子,和單獨寫的時候的樣子是不同的。也就是說,當這四個字寫在一起的時候,中間兩個字由於受到兩邊的字的影響,形狀被改變了。看圖吧:

    上面第二行和第三行的文字是完全一樣的倆字,你敢信?

    哇塞,是不是特別神奇?

    不過我們就不用管它為什麼這麼神奇了,也不用替阿拉伯人操心這麼複雜的文字他們使用起來會不會很痛苦,人家都已經用了幾百上千年了。我還說回到 drawTextRun()drawTextRun() 除了文字的內容和位置之外,還可以設定文字的上下文(也就是要繪製的文字的左邊和右邊是什麼文字,雖然這些文字並不會被繪製出來),從而讓同樣的文字可以按需表現出不同的顯示效果。

  • 額外設定二:文字方向。

    除了上下文, drawTextRun() 還可以設定文字的方向,即文字是從左到右還是從右到左排列的。

介紹完這兩類額外設定,來看一下具體的方法吧:

drawTextRun(CharSequence text, int start, int end, int contextStart, int contextEnd, float x, float y, boolean isRtl, Paint paint)

引數:
text:要繪製的文字
start:從那個字開始繪製
end:繪製到哪個字結束
contextStart:上下文的起始位置。contextStart 需要小於等於 start
contextEnd:上下文的結束位置。contextEnd 需要大於等於 end
x:文字左邊的座標
y:文字的基線座標
isRtl:是否是 RTL(Right-To-Left,從右向左)

要實現上面圖中的「同樣的字有不同的顯示」效果,調節 contextStartcontextEnd 就可以了,至於具體的實現,你有興趣的話就自己試試吧。

這就是 drawTextRun() ,一個增加了「上下文」和「RTL」支援的增強版本的 drawText() 。不過就像剛才說過的,這個方法對中國人其實沒什麼用……

1.3 drawTextOnPath()

沿著一條 Path 來繪製文字。這是一個耍雜技的方法。

canvas.drawPath(path, paint); // 把 Path 也繪製出來,理解起來更方便
canvas.drawTextOnPath("Hello HeCoder", path, 0, 0, paint);複製程式碼

籲,拐角處的文字怎麼那麼難看?

所以記住一條原則: drawTextOnPath() 使用的 Path ,拐彎處全用圓角,別用尖角。

具體的方法很簡單:

drawTextOnPath(String text, Path path, float hOffset, float vOffset, Paint paint)

引數裡,需要解釋的只有兩個: hOffsetvOffset。它們是文字相對於 Path 的水平偏移量和豎直偏移量,利用它們可以調整文字的位置。例如你設定 hOffset 為 5, vOffset 為 10,文字就會右移 5 畫素和下移 10 畫素。

1.4 StaticLayout

額外講一個 StaticLayout。這個也是使用 Canvas 來進行文字的繪製,不過並不是使用 Canvas 的方法。

Canvas.drawText() 只能繪製單行的文字,而不能換行。它:

  • 不能在 View 的邊緣自動折行

    String text = "Lorem Ipsum is simply dummy text of the printing and typesetting industry.";
    
    ...
    
    canvas.drawText(text, 50, 100, paint);複製程式碼

    到了 View 的邊緣處,文字繼續向後繪製到看不見的地方,而不是自動換行

  • 不能在換行符 \n 處換行

    String text = "a\nbc\ndefghi\njklm\nnopqrst\nuvwx\nyz";
    
    ...
    
    canvas.drawText(text, 50, 100, paint);複製程式碼

    在換行符 \n 的位置並沒有換行,而只是加了個空格

如果需要繪製多行的文字,你必須自行把文字切斷後分多次使用 drawText() 來繪製,或者——使用 StaticLayout

StaticLayout 並不是一個 View 或者 ViewGroup ,而是 android.text.Layout 的子類,它是純粹用來繪製文字的。 StaticLayout 支援換行,它既可以為文字設定寬度上限來讓文字自動換行,也會在 \n 處主動換行。

String text1 = "Lorem Ipsum is simply dummy text of the printing and typesetting industry.";
StaticLayout staticLayout1 = new StaticLayout(text1, paint, 600,
        Layout.Alignment.ALIGN_NORMAL, 1, 0, true);
String text2 = "a\nbc\ndefghi\njklm\nnopqrst\nuvwx\nyz";
StaticLayout staticLayout2 = new StaticLayout(text2, paint, 600,
        Layout.Alignment.ALIGN_NORMAL, 1, 0, true);

...

canvas.save();
canvas.translate(50, 100);
staticLayout1.draw(canvas);
canvas.translate(0, 200);
staticLayout2.draw(canvas);
canvas.restore();複製程式碼

上面程式碼中出現的 Canvas.save() Canvas.translate() Canvas.restore() 配合起來可以對繪製的內容進行移動。它們的具體用法我會在下期講,這期你就先依葫蘆畫瓢照搬著用吧。

StaticLayout 的構造方法是 StaticLayout(CharSequence source, TextPaint paint, int width, Layout.Alignment align, float spacingmult, float spacingadd, boolean includepad),其中引數裡:

width 是文字區域的寬度,文字到達這個寬度後就會自動換行;
align 是文字的對齊方向;
spacingmult 是行間距的倍數,通常情況下填 1 就好;
spacingadd 是行間距的額外增加值,通常情況下填 0 就好;
includeadd 是指是否在文字上下新增額外的空間,來避免某些過高的字元的繪製出現越界。

如果你需要進行多行文字的繪製,並且對文字的排列和樣式沒有太複雜的花式要求,那麼使用 StaticLayout 就好。

2 Paint 對文字繪製的輔助

Paint 對文字繪製的輔助,有兩類方法:設定顯示效果的和測量文字尺寸的。

2.1 設定顯示效果類

2.1.1 setTextSize(float textSize)

設定文字大小。

paint.setTextSize(18);
canvas.drawText(text, 100, 25, paint);
paint.setTextSize(36);
canvas.drawText(text, 100, 70, paint);
paint.setTextSize(60);
canvas.drawText(text, 100, 145, paint);
paint.setTextSize(84);
canvas.drawText(text, 100, 240, paint);複製程式碼

很簡單,不再詳細解釋。

2.1.2 setTypeface(Typeface typeface)

設定字型。

paint.setTypeface(Typeface.DEFAULT);
canvas.drawText(text, 100, 150, paint);
paint.setTypeface(Typeface.SERIF);
canvas.drawText(text, 100, 300, paint);
paint.setTypeface(Typeface.createFromAsset(getContext().getAssets(), "Satisfy-Regular.ttf"));
canvas.drawText(text, 100, 450, paint);複製程式碼

設定不同的 Typeface 就可以顯示不同的字型。我們中國人談到「字型」,比較熟悉的詞是 font, typeface 和 font 是一個意思,都表示字型。 Typeface 這個類的具體用法,需要了解的話可以直接看文件,很簡單。

嚴格地說,其實 typeface 和 font 意思不完全一樣。typeface 指的是某套字型(即 font family ),而 font 指的是一個 typeface 具體的某個 weight 和 size 的分支。不過無所謂啦~做人最緊要系開心啦。

2.1.3 setFakeBoldText(boolean fakeBoldText)

是否使用偽粗體。

paint.setFakeBoldText(false);
canvas.drawText(text, 100, 150, paint);
paint.setFakeBoldText(true);
canvas.drawText(text, 100, 230, paint);複製程式碼

之所以叫偽粗體( fake bold ),因為它並不是通過選用更高 weight 的字型讓文字變粗,而是通過程式在執行時把文字給「描粗」了。

2.1.4 setStrikeThruText(boolean strikeThruText)

是否加刪除線。

paint.setStrikeThruText(true);
canvas.drawText(text, 100, 150, paint);複製程式碼

2.1.5 setUnderlineText(boolean underlineText)

是否加下劃線。

paint.setUnderlineText(true);
canvas.drawText(text, 100, 150, paint);複製程式碼

2.1.6 setTextSkewX(float skewX)

設定文字橫向錯切角度。其實就是文字傾斜度的啦。

paint.setTextSkewX(-0.5f);
canvas.drawText(text, 100, 150, paint);複製程式碼

2.1.7 setTextScaleX(float scaleX)

設定文字橫向放縮。也就是文字變胖變瘦。

paint.setTextScaleX(1);
canvas.drawText(text, 100, 150, paint);
paint.setTextScaleX(0.8f);
canvas.drawText(text, 100, 230, paint);
paint.setTextScaleX(1.2f);
canvas.drawText(text, 100, 310, paint);複製程式碼

2.1.8 setLetterSpacing(float letterSpacing)

設定字元間距。預設值是 0。

paint.setLetterSpacing(0.2f);
canvas.drawText(text, 100, 150, paint);複製程式碼

為什麼在預設的字元間距為 0 的情況下,字元和字元之間也沒有緊緊貼著,這個我在前面講 Canvas.drawText()x 引數的時候已經說過了,在這裡應該沒有疑問吧?

2.1.9 setFontFeatureSettings(String settings)

用 CSS 的 font-feature-settings 的方式來設定文字。

paint.setFontFeatureSettings("smcp"); // 設定 "small caps"
canvas.drawText("Hello HenCoder", 100, 150, paint);複製程式碼

CSS 全稱是 Cascading Style Sheets ,是網頁開發用來設定頁面各種元素的樣式的。咦,網頁開發的設定怎麼會出現在 Android 的 API 裡?

大多數 Android 開發者都不瞭解這個 CSS 的 font-feature-settings 屬性,不過沒關係,這個屬性設定的都是文字的一些次要特性,所以不用著急瞭解這個方法。當然有興趣的話也可以看一看哈,文件在這裡

2.1.10 setTextAlign(Paint.Align align)

設定文字的對齊方式。一共有三個值:LEFT CETNERRIGHT。預設值為 LEFT

paint.setTextAlign(Paint.Align.LEFT);
canvas.drawText(text, 500, 150, paint);
paint.setTextAlign(Paint.Align.CENTER);
canvas.drawText(text, 500, 150 + textHeight, paint);
paint.setTextAlign(Paint.Align.RIGHT);
canvas.drawText(text, 500, 150 + textHeight * 2, paint);複製程式碼

2.1.11 setTextLocale(Locale locale) / setTextLocales(LocaleList locales)

設定繪製所使用的 Locale

Locale 直譯是「地域」,其實就是你在系統裡設定的「語言」或「語言區域」(具體名稱取決於你用的是什麼手機),比如「簡體中文(中國)」「English (US)」「English (UK)」。有些同源的語言,在文化發展過程中對一些相同的字衍生出了不同的寫法(比如中國大陸和日本對於某些漢字的寫法就有細微差別。注意,不是繁體和簡體這種同音同義不同字,而真的是同樣的一個字有兩種寫法)。系統語言不同,同樣的一個字的顯示就有可能不同。你可以試一下把自己手機的語言改成日文,然後開啟微信看看聊天記錄,你會明顯發現文字的顯示發生了很多細微的變化,這就是由於系統的 Locale 改變所導致的。

Canvas 繪製的時候,預設使用的是系統設定裡的 Locale。而通過 Paint.setTextLocale(Locale locale) 就可以在不改變系統設定的情況下,直接修改繪製時的 Locale

paint.setTextLocale(Locale.CHINA); // 簡體中文
canvas.drawText(text, 150, 150, paint);
paint.setTextLocale(Locale.TAIWAN); // 繁體中文
canvas.drawText(text, 150, 150 + textHeight, paint);
paint.setTextLocale(Locale.JAPAN); // 日語
canvas.drawText(text, 150, 150 + textHeight * 2, paint);複製程式碼

有意思吧?

另外,由於 Android 7.0 ( API v24) 加入了多語言區域的支援,所以在 API v24 以及更高版本上,還可以使用 setTextLocales(LocaleList locales) 來為繪製設定多個語言區域。

2.1.12 setHinting(int mode)

設定是否啟用字型的 hinting (字型微調)。

現在的 Android 裝置大多數都是是用的向量字型。向量字型的原理是對每個字型給出一個字形的向量描述,然後使用這一個向量來對所有的尺寸的字型來生成對應的字形。由於不必為所有字號都設計它們的字型形狀,所以在字號較大的時候,向量字型也能夠保持字型的圓潤,這是向量字型的優勢。不過當文字的尺寸過小(比如高度小於 16 畫素),有些文字會由於失去過多細節而變得不太好看。 hinting 技術就是為了解決這種問題的:通過向字型中加入 hinting 資訊,讓向量字型在尺寸過小的時候得到針對性的修正,從而提高顯示效果。效果圖盜一張維基百科的:

功能很強,效果很贊。不過在現在( 2017 年),手機螢幕的畫素密度已經非常高,幾乎不會再出現字型尺寸小到需要靠 hinting 來修正的情況,所以這個方法其實……沒啥用了。可以忽略。

2.1.13 setElegantTextHeight(boolean elegant)

宣告:這個方法對中國人沒用,不想看的話可以直接跳過,無毒副作用。

設定是否開啟文字的 elegant height 。開啟之後,文字的高度就變優雅了(誤)。下面解釋一下所謂的 elegant height:

在有些語言中,可能會出現一些非常高的字形:

左邊那幾個泰文文字,挺高的吧?但其實它們已經是被壓縮過了的,它們本來比這還要高。

這些比較高的文字,通常都有兩個版本的字型:一個原始版本,一個壓縮了高度的版本。壓縮版本可以保證讓這些「大高個」文字在和普通文字(例如拉丁文字)放在一起的時候看起來不會顯得太奇怪。事實上,Paint 繪製文字時是用的預設版本就是壓縮版本,就像上圖這樣。

不過有的時候,開發者會需要使用它們的原始(優雅)版本。使用 setElegantTextHeight() 就可以切換到原始版本:

paint.setElegantTextHeight(true);複製程式碼

這字得有多高?2 米 26 ?

那麼,setElegantTextHeight() 的作用到這裡就很清晰了:

  1. 把「大高個」文字的高度恢復為原始高度;
  2. 增大每行文字的上下邊界,來容納被加高了的文字。

其實這個問題我已經在 stackoverflow 回答過一次,原回答在這裡

不過就像前面說的,由於中國人常用的漢語和英語的文字並不會達到這種高度,所以這個方法對於中國人基本上是沒用的。

2.1.14 setSubpixelText(boolean subpixelText)

是否開啟次畫素級的抗鋸齒( sub-pixel anti-aliasing )。

次畫素級抗鋸齒這個功能解釋起來很麻煩,簡單說就是根據程式所執行的裝置的螢幕型別,來進行鍼對性的次畫素級的抗鋸齒計算,從而達到更好的抗鋸齒效果。更詳細的解釋可以看這篇文章

不過,和前面講的字型 hinting 一樣,由於現在手機螢幕畫素密度已經很高,所以預設抗鋸齒效果就已經足夠好了,一般沒必要開啟次畫素級抗鋸齒,所以這個方法基本上沒有必要使用。

2.1.15 setLinearText(boolean linearText)

這個方法老實說我從沒用過,也始終沒有搞懂它是什麼意思,就不強行裝逼了。把文件中的解釋照搬過來,各位自己研究吧。

Helper for setFlags(), setting or clearing the LINEAR_TEXT_FLAG bit

上面這句中提到的 LINEAR_TEXT_FLAG:

Paint flag that enables smooth linear scaling of text.

Enabling this flag does not actually scale text, but rather adjusts text draw operations to deal gracefully with smooth adjustment of scale. When this flag is enabled, font hinting is disabled to prevent shape deformation between scale factors, and glyph caching is disabled due to the large number of glyph images that will be generated.

SUBPIXEL_TEXT_FLAG should be used in conjunction with this flag to prevent glyph positions from snapping to whole pixel values as scale factor is adjusted.

以上就是 Paint 的對文字的顯示效果設定類方法。下面介紹它的第二類方法:測量文字尺寸類。

2.2 測量文字尺寸類

不論是文字,還是圖形或 Bitmap,只有知道了尺寸,才能更好地確定應該擺放的位置。由於文字的繪製和圖形或 Bitmap 的繪製比起來,尺寸的計算複雜得多,所以它有一整套的方法來計算文字尺寸。

2.2.1 float getFontSpacing()

獲取推薦的行距。

即推薦的兩行文字的 baseline 的距離。這個值是系統根據文字的字型和字號自動計算的。它的作用是當你要手動繪製多行文字(而不是使用 StaticLayout)的時候,可以在換行的時候給 y 座標加上這個值來下移文字。

canvas.drawText(texts[0], 100, 150, paint);
canvas.drawText(texts[1], 100, 150 + paint.getFontSpacing, paint);
canvas.drawText(texts[2], 100, 150 + paint.getFontSpacing * 2, paint);複製程式碼

2.2.2 FontMetircs getFontMetrics()

獲取 PaintFontMetrics

FontMetrics 是個相對專業的工具類,它提供了幾個文字排印方面的數值:ascent, descent, top, bottom, leading

如圖,圖中有兩行文字,每一行都有 5 條線:top, ascent, baseline, descent, bottom。(leading 並沒有畫出來,因為畫不出來,下面會給出解釋)

  • baseline: 上圖中黑色的線。前面已經講過了,它的作用是作為文字顯示的基準線。

  • ascent / descent: 上圖中綠色橙色的線,它們的作用是限制普通字元的頂部和底部範圍。
    普通的字元,上不會高過 ascent ,下不會低過 descent ,例如上圖中大部分的字形都顯示在 ascentdescent 兩條線的範圍內。具體到 Android 的繪製中, ascent 的值是圖中綠線和 baseline 的相對位移,它的值為負(因為它在 baseline 的上方); descent 的值是圖中橙線和 baseline 相對位移,值為正(因為它在 baseline 的下方)。

  • top / bottom: 上圖中藍色紅色的線,它們的作用是限制所有字形( glyph )的頂部和底部範圍。
    除了普通字元,有些字形的顯示範圍是會超過 ascentdescent 的,而 topbottom 則限制的是所有字形的顯示範圍,包括這些特殊字形。例如上圖的第二行文字裡,就有兩個泰文的字形分別超過了 ascentdescent 的限制,但它們都在 topbottom 兩條線的範圍內。具體到 Android 的繪製中, top 的值是圖中藍線和 baseline 的相對位移,它的值為負(因為它在 baseline 的上方); bottom 的值是圖中紅線和 baseline 相對位移,值為正(因為它在 baseline 的下方)。

  • leading: 這個詞在上圖中沒有標記出來,因為它並不是指的某條線和 baseline 的相對位移。 leading 指的是行的額外間距,即對於上下相鄰的兩行,上行的 bottom 線和下行的 top 線的距離,也就是上圖中第一行的紅線第二行的藍線的距離(對,就是那個小細縫)。

leading 這個詞的本意其實並不是行的額外間距,而是行距,即兩個相鄰行的 baseline 之間的距離。不過對於很多非專業領域,leading 的意思被改變了,被大家當做行的額外間距來用;而 Android 裡的 leading ,同樣也是行的額外間距的意思。

另外,leading 在這裡應該讀作 "ledding" 而不是 "leeding" 哦。原因就不說了,我這越扯越遠沒邊了。

FontMetrics 提供的就是 Paint 根據當前字型和字號,得出的這些值的推薦值。它把這些值以變數的形式儲存,供開發者需要時使用。

  • FontMetrics.ascent:float 型別。
  • FontMetrics.descent:float 型別。
  • FontMetrics.top:float 型別。
  • FontMetrics.bottom:float 型別。
  • FontMetrics.leading:float 型別。

另外,ascentdescent 這兩個值還可以通過 Paint.ascent()Paint.descent() 來快捷獲取。

FontMetrics 和 getFontSpacing():

從定義可以看出,上圖中兩行文字的 font spacing (即相鄰兩行的 baseline 的距離) 可以通過 bottom - top + leading (top 的值為負,前面剛說過,記得吧?)來計算得出。

但你真的執行一下會發現, bottom - top + leading 的結果是要大於 getFontSpacing() 的返回值的。

兩個方法計算得出的 font spacing 竟然不一樣?

這並不是 bug,而是因為 getFontSpacing() 的結果並不是通過 FontMetrics 的標準值計算出來的,而是另外計算出來的一個值,它能夠做到在兩行文字不顯得擁擠的前提下縮短行距,以此來得到更好的顯示效果。所以如果你要對文字手動換行繪製,多數時候應該選取 getFontSpacing() 來得到行距,不但使用更簡單,顯示效果也會更好。

getFontMetrics() 的返回值是 FontMetrics 型別。它還有一個過載方法 getFontMetrics(FontMetrics fontMetrics) ,計算結果會直接填進傳入的 FontMetrics 物件,而不是重新建立一個物件。這種用法在需要頻繁獲取 FontMetrics 的時候效能會好些。

另外,這兩個方法還有一對同樣結構的對應的方法 getFontMetricsInt()getFontMetricsInt(FontMetricsInt fontMetrics) ,用於獲取 FontMetricsInt 型別的結果。

2.2.3 getTextBounds(String text, int start, int end, Rect bounds)

獲取文字的顯示範圍。

引數裡,text 是要測量的文字,startend 分別是文字的起始和結束位置,bounds 是儲存文字顯示範圍的物件,方法在測算完成之後會把結果寫進 bounds

paint.setStyle(Paint.Style.FILL);
canvas.drawText(text, offsetX, offsetY, paint);

paint.getTextBounds(text, 0, text.length(), bounds);
bounds.left += offsetX;
bounds.top += offsetY;
bounds.right += offsetX;
bounds.bottom += offsetY;
paint.setStyle(Paint.Style.STROKE);
canvas.drawRect(bounds, paint);複製程式碼

它有一個過載方法 getTextBounds(char[] text, int index, int count, Rect bounds),用法非常相似,不再介紹。

2.2.4 float measureText(String text)

測量文字的寬度並返回。

canvas.drawText(text, offsetX, offsetY, paint);
float textWidth = paint.measureText(text);
canvas.drawLine(offsetX, offsetY, offsetX + textWidth, offsetY, paint);複製程式碼

咦,前面有了 getTextBounds(),這裡怎麼又有一個 measureText()

如果你用程式碼分別使用 getTextBounds()measureText() 來測量文字的寬度,你會發現 measureText() 測出來的寬度總是比 getTextBounds() 大一點點。這是因為這兩個方法其實測量的是兩個不一樣的東西。

  • getTextBounds: 它測量的是文字的顯示範圍(關鍵詞:顯示)。形象點來說,你這段文字外放置一個可變的矩形,然後把矩形儘可能地縮小,一直小到這個矩形恰好緊緊包裹住文字,那麼這個矩形的範圍,就是這段文字的 bounds。

  • measureText(): 它測量的是文字繪製時所佔用的寬度(關鍵詞:佔用)。前面已經講過,一個文字在介面中,往往需要佔用比他的實際顯示寬度更多一點的寬度,以此來讓文字和文字之間保留一些間距,不會顯得過於擁擠。上面的這幅圖,我並沒有設定 setLetterSpacing() ,這裡的 letter spacing 是預設值 0,但你可以看到,圖中每兩個字母之間都是有空隙的。另外,下方那條用於表示文字寬度的橫線,在左邊超出了第一個字母 H 一段距離的,在右邊也超出了最後一個字母 r(雖然右邊這裡用肉眼不太容易分辨),而就是兩邊的這兩個「超出」,導致了 measureText()getTextBounds() 測量出的寬度要大一些。

在實際的開發中,測量寬度要用 measureText() 還是 getTextBounds() ,需要根據情況而定。不過你只要掌握了上面我所說的它們的本質,在選擇的時候就不會為難和疑惑了。

measureText(String text) 也有幾個過載方法,用法和它大同小異,不再介紹。

2.2.5 getTextWidths(String text, float[] widths)

獲取字串中每個字元的寬度,並把結果填入引數 widths

這相當於 measureText() 的一個快捷方法,它的計算等價於對字串中的每個字元分別呼叫 measureText() ,並把它們的計算結果分別填入 widths 的不同元素。

getTextWidths() 同樣也有好幾個變種,使用大同小異,不再介紹。

2.2.6 int breakText(String text, boolean measureForwards, float maxWidth, float[] measuredWidth)

這個方法也是用來測量文字寬度的。但和 measureText() 的區別是, breakText() 是在給出寬度上限的前提下測量文字的寬度。如果文字的寬度超出了上限,那麼在臨近超限的位置截斷文字。

int measuredCount;
float[] measuredWidth = {0};

// 寬度上限 300 (不夠用,截斷)
measuredCount = paint.breakText(text, 0, text.length(), true, 300, measuredWidth);
canvas.drawText(text, 0, measuredCount, 150, 150, paint);

// 寬度上限 400 (不夠用,截斷)
measuredCount = paint.breakText(text, 0, text.length(), true, 400, measuredWidth);
canvas.drawText(text, 0, measuredCount, 150, 150 + fontSpacing, paint);

// 寬度上限 500 (夠用)
measuredCount = paint.breakText(text, 0, text.length(), true, 500, measuredWidth);
canvas.drawText(text, 0, measuredCount, 150, 150 + fontSpacing * 2, paint);

// 寬度上限 600 (夠用)
measuredCount = paint.breakText(text, 0, text.length(), true, 600, measuredWidth);
canvas.drawText(text, 0, measuredCount, 150, 150 + fontSpacing * 3, paint);複製程式碼

breakText() 的返回值是擷取的文字個數(如果寬度沒有超限,則是文字的總個數)。引數中, text 是要測量的文字;measureForwards 表示文字的測量方向,true 表示由左往右測量;maxWidth 是給出的寬度上限;measuredWidth 是用於接受資料,而不是用於提供資料的:方法測量完成後會把擷取的文字寬度(如果寬度沒有超限,則為文字總寬度)賦值給 measuredWidth[0]

這個方法可以用於多行文字的折行計算。

breakText() 也有幾個過載方法,使用大同小異,不再介紹。

2.2.7 游標相關

對於 EditText 以及類似的場景,會需要繪製游標。游標的計算很麻煩,不過 API 23 引入了兩個新的方法,有了這兩個方法後,計算游標就方便了很多。

2.2.7.1 getRunAdvance(CharSequence text, int start, int end, int contextStart, int contextEnd, boolean isRtl, int offset)

對於一段文字,計算出某個字元處游標的 x 座標。 start end 是文字的起始和結束座標;contextStart contextEnd 是上下文的起始和結束座標;isRtl 是文字的方向;offset 是字數的偏移,即計算第幾個字元處的游標。

int length = text.length();
float advance = paint.getRunAdvance(text, 0, length, 0, length, false, length);
canvas.drawText(text, offsetX, offsetY, paint);
canvas.drawLine(offsetX + advance, offsetY - 50, offsetX + advance, offsetY + 10, paint);複製程式碼

其實,說是測量游標位置的,本質上這也是一個測量文字寬度的方法。上面這個例子中,startcontextStart 都是 0, end contextEndoffset 都等於 text.length()。在這種情況下,它是等價於 measureText(text) 的,即完整測量一段文字的寬度。而對於更復雜的需求,getRunAdvance() 能做的事就比 measureText() 多了。

// 包含特殊符號的繪製(如 emoji 表情)
String text = "Hello HenCoder \uD83C\uDDE8\uD83C\uDDF3" // "Hello HenCoder ??"

...

float advance1 = paint.getRunAdvance(text, 0, length, 0, length, false, length);
float advance2 = paint.getRunAdvance(text, 0, length, 0, length, false, length - 1);
float advance3 = paint.getRunAdvance(text, 0, length, 0, length, false, length - 2);
float advance4 = paint.getRunAdvance(text, 0, length, 0, length, false, length - 3);
float advance5 = paint.getRunAdvance(text, 0, length, 0, length, false, length - 4);
float advance6 = paint.getRunAdvance(text, 0, length, 0, length, false, length - 5);

...複製程式碼

如上圖,?? 雖然佔了 4 個字元(\uD83C\uDDE8\uD83C\uDDF3),但當 offset 是表情中間處時, getRunAdvance() 得出的結果並不會在表情的中間處。為什麼?因為這是用來計算游標的方法啊,游標當然不能出現在符號中間啦。

2.2.7.2 getOffsetForAdvance(CharSequence text, int start, int end, int contextStart, int contextEnd, boolean isRtl, float advance)

給出一個位置的畫素值,計算出文字中最接近這個位置的字元偏移量(即第幾個字元最接近這個座標)。

方法的引數很簡單: text 是要測量的文字;start end 是文字的起始和結束座標;contextStart contextEnd 是上下文的起始和結束座標;isRtl 是文字方向;advance 是給出的位置的畫素值。填入引數,對應的字元偏移量將作為返回值返回。

getOffsetForAdvance() 配合上 getRunAdvance() 一起使用,就可以實現「獲取使用者點選處的文字座標」的需求。

2.2.8 hasGlyph(String string)

檢查指定的字串中是否是一個單獨的字形 (glyph)。最簡單的情況是,string 只有一個字母(比如 a)。

以上這些內容,就是文字繪製的相關知識。它們有的常用,有的不常用,有的甚至可以說是在某些情況下沒用,不過你把它們全部搞懂了,在實際的開發中,就知道哪些事情可以做到,哪些事情做不到,以及應該怎麼做了。

練習專案

為了避免轉頭就忘,強烈建議你趁熱打鐵,做一下這個練習專案:HenCoderPracticeDraw3

下期預告

基本繪製(Canvas.drawXXX())以及高階繪製(Paint)都講完了,下節的內容是 Canvas 對繪製的輔助:範圍裁切和幾何變換:

感謝

感謝參與這期預釋出內測的讀者:

林子洋、Rookie run、showwiki、孫志帥、Tim Aimee、明天,你好、Mr.Coder、特倫特、承香墨影&version=12020810&lang=zh_CN&nettype=WIFI&a8scene=0&fontScale=100&pass_ticket=3ZSTFlOd8P3j6MV7KWW3Yz6Vk%2FG9MLwZDkm668BrQCc%3D)、HanDongFizz、停停走走、Vinctor

覺得贊?

如果你看完覺得有收穫,把文章轉發到你的微博、微信群、朋友圈、公眾號,讓其他需要的人也看到吧。

相關文章