iOS 文本對齊,如何像素般精確還原設計稿

問題

在工程師實現設計稿的時候,文本框的對齊是一個經常遇到且棘手的問題。明明已經遵照設計師的標註實現,但結果卻與設計稿有很大差異。

「label 為什麼有這麼大的上下邊距呢?」

「行距是 1.2 倍但是效果完全不一樣!」

這時候只能靠手工一點一點試,而且由於 app 開發不能像 web 一樣及時生效,很浪費時間,且不夠精確。

我們的目標是: 只需按照標註 coding,即可像素般準確實現設計稿中的文字對齊與行間距樣式。為了解決這個問題,我們需要先明確一些概念。

文本的度量

文字的排版不只是把方塊字依次排列起來即可。對於拉丁字母,f 與 g 以什麼方式上下對齊?為了準確的描述,字體中有以下幾個概念:

我們只看縱向:

  • baseline: 相當於坐標原點。大部分的拉丁字母底部與此對齊,漢字的中下部與此對齊(這是設定)
  • ascent, descent: 相當於字體可繪製區域的上下最大值。根據自己的觀察,ascent 並不一定是最高字元的高度(比如上圖的 f),在大部分字體中,ascent 會比最高的字元還要高一些,上面會有個空間。 desecnt 同理。(descent 為負值)
  • leading: 即行間距。但這個行間距與平時所說的行間距並不是一個東西。在文本編輯器中,選擇不同字體的時候,視覺上的每行的距離並不是一樣的。有可能就是 leading 不同。這個值可能為 0。(對於 iOS 上的 SF 系列字體,它的值就是 0 )
  • line height: 即行高。它的值定義為 ascent + descent + leading。(descent 為負值,所以準確的寫應該取絕對值)。這也是我們最關心的一個值。

這些值都是字體的屬性,是字體的設計者制定的,不可變,不同的字體會不一樣。

平時用來表示字體大小的「字型大小」並不對應上圖中的任何值,也就沒有一個直接的幾何意義。字型大小準確的說法是 point size。對於一個 point size 是 15 的 SFUI 字體,它的 line height 為 17.900390625, 約為 point size 的 1.2 倍。所以對於這個字體的一行文字,它的行高為 17.900390625。如果硬要顯示在 15.0 高度的矩形內,g 和 f 應該會顯示不全。

行間距

line height 所代表的高度只是一行文字的高度。可以把一行文字看做以 line height 為高的矩形,多行文字就是這些矩形縱向排列。矩形的間距就是通常我們說的行間距:

而通常所說的「行間距」「行高」「line height mutiple」 這樣的詞語,描述的就是這個間距的大小。

  • 「行間距」: 直接對應間距的值
  • 「行高」: line height + 間距。可以認為是,除了首行與尾行,每行實際所佔的高度
  • line height mutiple: 即是「 x 倍行高」中的數值。line_height_mutiple = 「行高」/ line_height

不同平台的實現效果

iOS

使用 autolayout 的一行 UIlabel 的高度即為所使用字體的 line height。但 autolayout 中,view 的 frame 的小數點精度會對齊到像素精度。所以 15 號字體的 label 高度為 18.0 point 。

對於多行文字的行間距,可以通過 attributedString 中的 paragraph style 來控制。paragraph style 可以設定如下值:

  • lineHeightMultiple: 同上面所說的。
  • minimumLineHeight/maximumLineHeight: 即「行高」

這兩個值都會改變行高,只是寫法不同而已。但使用它們控制行間距有一個問題,如果行高大於字體的 line height,那麼多餘的空間將會放在這行的上面: baseline 所在的位置是矩形底邊 + ( leading + descent ) 的位置。一個常見的情況,圓形的 avatar 與右側的 label 頂端對齊,如果使用 lineHeightMultiple,那麼為了達到視覺上的對齊,avatar 與 label 的 frame.y 就會不一樣。不是很理想。(在使用 insets 或 background color 的時候就會很麻煩)

  • lineSpacing: 即行間距。

使用 lineSpacing 只會在每行之間添加間距。在首行與尾行外側並沒有額外的空白(當然,line height 里所帶的空白仍然存在)。比較符合我們行間距的設定,不存在上面提到的問題。但不同 point size 為了有同樣的效果,需要設定不同的 lineSpacing,不如 lineHeightMultiple 使用方便。

Sketch App

Sketch 是常見的 UI 設計工具。sketch 中的一個單行文本框的高度同 ios 一樣,即精確到整數的 line height。(以前並不是,至於從什麼版本開始我也不清楚。)(但文本框的高度可以設為小於 line height,而且並不會截斷文字顯示。此時文字會居中對齊,但在 iOS 中如此操作,文字會頂端對齊,截斷下面。)

sketch 對於多行文本設定非常簡單,只有一個 lineHeight 值,對應於上文中的「行高」。設定 lineHeight 的效果是文字的每一行都變高了,原有的文字在每行內居中對齊。這個效果與 iOS 中使用 minimumLineHeight 是不一樣的,後者是向下對齊。而與 iOS 中使用 lineSpacing 不同,首行和尾行外側也會多出同樣的空白邊距。

sketch 官方在 Medium 有一篇文章,說明了 sketch 的行高的設計原則: 讓行高更接近設計師的直觀感受。

As you may have noticed, digital typography is an extremely complex issue. We』re living in an exciting era, where we have realized that it』s almost impossible to make a design look exactly the same on every device and platform.

解決方案

可以看出,不同平台對於這些參數的實現形式是不一樣的,這就造成了開頭所說的問題。為了解決這個問題,需要統一兩個平台的效果:

我們這樣約定顯示規則: (這種約定不是唯一的選擇,但這樣設定更方便使用和理解)

  • 使用相同的字體: SF UI(或 SF Pro)
  • 單行文本: 文本框高度等於 font 的 line height
  • 多行文本: 只在行與行之間加入 spacing,同使用 iOS lineSpacing 的效果。並使用 multiple 來描述 spacing 的大小。

通過以下的方式可以實現上述效果:

  • 字體:
    • iOS : 使用系統默認字體 SF UI,可以使用 systemFontOfSize 方法獲得(systemFontOfSize:weight: 方法也可以,字重沒有影響)。注意不能直接使用 PingFang SC 字體,它與 SF UI 的 line height 不同。

    • sketch: 使用 SF UI Text 或 SF UI Display,兩者在橫向上都會有誤差,原因後面會說明。同樣不能直接使用 PingFang SC,雖然在使用 SF UI 的中文會 fall back 到 PingFang SC, 但兩者的 line height 不同,PingFang SC 會更大一點。
  • 單行文本:
    • iOS: UILabel,使用 autolayout(view 高度精確到像素)。或者其他等同效果的 view,內部使用 textKit 的都有一樣的效果1。UILabel 在(只有一行,有中文,paragraph style 中的 line spacing 不為零)的情況下有個小 bug,view 的高度會比 line height 大。解決辦法是去掉 lineSpacing,或者設定 view 高度等於 font.lineHeight。
    • sketch: 對於純英文的情況下,使用 text layer 的默認高度即可,不要手動更改行高。如果有中文,view 高度會比 SF 字體的 line height 高。需要使用插件設定文本的 line height multiple 為 1(後面介紹)。
  • 多行文本:
    • iOS: 行間距的設置使用 lineSpacing 屬性實現。設定 lineSpacing = font.lineHeight * (multiple - 1.0 ) 。其中存在取整的問題需要注意2。
    • sketch: 使用插件直接設置 line height multiple。比如想要 1.2 倍行高,直接輸入 1.2。

因為 sketch 無法直接達到想要的效果,為此寫了一個插件 sketch-engineer-friendly-text。它可以通過輸入的 multiple 值和字體的本身的 line height 自動計算行高。並把文本上下多餘的「行間距」切掉。

這樣,我們統一了兩個平台的顯示效果,工程師在開發的時候只需直接遵照設計圖上的參數,即可快速準確實現文字的顯示樣式。

實際效果

通過這樣的方式,實現了一個 demo, 結果如下:

結果對比(原圖很大,可以看到細節)

其中黑色和綠色部分是 ios 的結果, 白色和紫色的 sketch 反色的結果。左側是一個文本框的全部,兩者文字對齊,文本框大小一樣,在縱向完全一致。右側的放大版,g 和 r 雖然在縱向上有差異,但 sketch 中的 g 比 iOS 中的上下都要小,可能是字體的原因。baseline 仍然是完全一致的。

中文的對比結果,原圖在此。在 30px 字體上,完全一致。在 60px 字體上,sketch 會偏下 1px,暫時沒有解決,但也可以接受。

至於橫向上的差異,原因在此,已經超出了本文的範圍。

至此,我們完成了目標。

Appendix I - Sketch 插件的使用方法

sketch-engineer-friendly-text 如何使用:

選中所要更改的 text layer,選擇菜單中的 「set line height multiple ..」, 輸入想要的值(比如 1.2 )。該 text layer 的行高就會設定成正確的值,並且會使用一個 mask 把 text layer 的多餘邊距切掉。

text layer 會與 mask 放在同一個 group 內。菜單中的功能可以直接應用在 text layer 上,也可以應用在 group3 上 (插件會自動尋找內部的 text layer 並更改)。

如果更改了文字內容,使得 text layer 的大小發生變化,可以使用「fix text margin for iOS」來重新設定 group 和 mask 的大小。

插件支持同時選中多個進行操作。

Appendix II - 如何寫 Sketch 插件

sketch 插件可以理解為通過 sketch 執行的腳本。

插件文件本身是一個 bundle,也就是一個文件夾。最基本的配置是一個配置文件和一個腳本文件。配置文件可以控制 sketch 菜單欄上的 plugin 選項。當用戶點擊菜單的選項之後,會觸發對應的腳本。

腳本使用 CocoaScript 書寫。它只是 JS 的一個擴展,可以調用 Cocoa 的 API。從腳本回調的 context 中可以得到工作區域中的以 object 表示的樹形元素。通過改變 object 的屬性,即可改變 sketch 中的內容。比如設置 layer 的 frame,可以改變它的大小。

sketch 官方提供一個 native js 的 api 文檔,方便使用但功能較少。另外一種方式是直接使用 cocoa object,即 sketch 源代碼中的 ObjC 對象。CocoaScript 把原生的 object 都橋接成 JS object 給我們使用,並把我們寫的 JS 代碼內容在 cocoa 中執行(很像 JSPatch)。 以這種方式寫插件,就像在給 sketch 代碼開發功能,只不過使用的語言是 JS。可以調用任何 cocoa api,比如彈 alert,或者打開文件選擇器。但是官方並沒有給我們頭文件和任何文檔,只能參考 classdump 出來的頭文件,比較低效。

官方介紹 developer.sketchapp.com

  1. 如果使用 textkit,可能存在中英混排行高的問題,可以使用 Neat 來解決 ?

  2. 其實使用的時候會稍微複雜一點。sketch 似乎只能按照整數渲染,而 line height * multiple 會出現小數,設定給 sketch 的行高會被約成整數(雖然界面中仍然是小數,但渲染時會按照整數來渲染)。所以需要在代碼中保持 line height + line spacing 為整數(sketch 一般以 2x 圖畫,所以這裡是取到像素精確)。let finalHeight = ceilToPixel( font.lineHeight * lineHeightMultiple ); style.lineSpacing = finalHeight - font.lineHeight; 具體可以參照 demo。 ?

  3. 不是任何 group。是通過插件生成的包含一個 text layer 和 一個 mask 的 group。 ?

推薦閱讀:

想了解、學習APP的UI設計應從何下手?
第二話——什麼是 dp、pt、sp?
這五個心理學常識,作為UX 設計師的你應該了解
用基於組件的設計提升你的設計效率
如何開始設計一個 App ?

TAG:iOS | Sketch | 用户界面设计 |