モバイル基盤部のヴァンサン(@vincentisambart)です。
使っているアプリのフォントサイズを変えたいと思ったことありますか?目があまり良くないから文字を大きくしたい。逆にもっと多くの情報を一目で見られるために文字を少し小さくしたい。
フォントサイズをアプリ内で調整できるアプリもありますが、iOSではシステム全体のフォントサイズを調整できる「Dynamic Type」と呼ばれる機能があります。iOS全体の設定に一般→アクセシビリティ→さらに大きな文字(英語だとSettingsにGeneral→Accessibility→Larger Text)で変えられます。対応しているアプリではフォントサイズがその設定に合わせられます。
標準より少し小さくできるとはいえ、「アクセシビリティ」設定に入っているのもあって文字を大きくする方がメインのユースケースの気がします。
Dynamic TypeはiOS 7から使えるようになりましたが、関連している様々な便利機能がその後のiOSバージョンに追加されました。
一応Dynamic Typeを使うにはAuto Layoutは必須ではないのですが、手動レイアウトと一緒に使うのはおすすめできません。
システム設定変更
設定を変える前に、簡単に戻せるようにまずは標準設定を覚えておきましょう。標準設定は以下のスクリーンショットのように「さらに大きな文字」が無効で、下部のスライダーがど真ん中です。
スライダーを動かすとフォントサイズがどれくらい変わるのかすぐ見られます。一番小さい設定(extra smallまたはXS)にすると、以下のようになります。
「さらに大きな文字」無効のまま一番大きい設定(extra extra extra largeまたはXXXL)にすると以下のようになります。
アクセシビリティサイズはXXXLより大きく、「さらに大きな文字」を有効にすると選択できるようになる5つの設定です。その一番大きい設定(accessibility extra extra extra large、またはAX5)にすると以下のようになります。
因みにDynamic Type対応しているアプリでも、アクセシビリティサイズをXXXLと同じ扱いにしているアプリもあります。文字がとても大きいのでレイアウトが崩れやすいからでしょう。
設定の値
ユーザーに選択されたDynamic Type設定はUIContentSizeCategory
という型で、UIApplication.shared.preferredContentSizeCategory
または(iOS 8以上では)traitCollection.preferredContentSizeCategory
で取得できます。現時点(iOS 12)で利用できるすべての値は以下のとおりです。
unspecified
(iOS 10以上)
extraSmall
(XS
, xSmall
)
small
(S
)
medium
(M
)
large
(L
) – 標準設定
extraLarge
(XL
, xLarge
)
extraExtraLarge
(XXL
, xxLarge
)
extraExtraExtraLarge
(XXXL
, xxxLarge
)
accessibilityMedium
(AccessibilityM
, AX1
)
accessibilityLarge
(AccessibilityL
, AX2
)
accessibilityExtraLarge
(AccessibilityXL
, AX3
)
accessibilityExtraExtraLarge
(AccessibilityXXL
, AX4
)
accessibilityExtraExtraExtraLarge
(AccessibilityXXXL
, AX5
)
設定画面で「さらに大きな文字」が有効になっているときだけに選択できるアクセシビリティサイズは名前がaccessibility
から始まります。iOS 11以上では、アクセシビリティサイズかどうか確認するためにUIContentSizeCategory
にisAccessibilityCategory
というメソッドがあります。また、同じくiOS 11以上でUIContentSizeCategory
を<
, <=
, >=
, >
で比較できるようになるのでtraitCollection.preferredContentSizeCategory > .extraExtraExtraLarge
も使えます。
content size categoryの値によって、レイアウトを変えたりできます。例えばDynamic Typeの設定画面で大きいアクセシビリティサイズを選ぶ時、「さらに大きな文字」スイッチがラベルの右からその下に移ります。Dynamic Type設定によってUIStackView
の設定が変わるのでしょう。
自分のアプリ内、Dynamic Typeの設定に合わせてするレイアウト変更は作成時以外、どういうタイミングでやれば良いのでしょうか。
変更に反応
アプリがバックグラウンドにある間に、ユーザーがDynamic Typeの設定を変えるとアプリが終了されるわけではありません。ユーザーがアプリに戻ったら、アプリが変更を知らされる仕組みが2つあります:
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
}
}
注意: ビューコントローラーが表示されていない場合(例えば別のタブが表示されている間)、traitCollectionDidChange
は呼ばれませんが、UIContentSizeCategory.didChangeNotification
は届きます。
Text Style (書式)
設定によってレイアウトを変えても、Dynamic Typeの「Type」は文字、フォントのことなので、肝心のフォントサイズはどうすれば良いのでしょうか。アプリに使われているすべてのスタイルをcontent size categoryごとに用意するのは手間が掛かりすぎます。
そのため、iOS 7からシステムにいくつかのText Styleが用意されています。ワードプロセッサーでいうと「書式」と同じ概念です(ただし自分の好みに合わせて変えることができません)。Human Interface Guidelines(HIG)のTypographyページにスタイルのリストと各content size categoryでのフォントサイズが見られます。アクセシビリティサイズも同じページの少し下にAX1, AX2, AX3, AX4, AX5として紹介されてあります。因みにフォント自体はシステムフォント(San Francisco)です。
そのスタイルはコード上ではUIFont.TextStyle
です。iOS 7ではスタイルが少なかった(body
, caption1
, caption2
, footnote
, headline
, subheadline
)のですが、iOS 9(callout
, title1
, title2
, title3
)やiOS 11(largeTitle
)で少し増えました。
スタイルによって変わるのはフォントサイズだけではなく、headline
スタイルの場合、フォントがボルドになります。
large
設定では、iPadで各スタイルを表示すると以下のようになります。
extraExtraExtraLarge
設定では、以下のようになります。
accessibilityExtraExtraExtraLarge
設定では、以下のようになります。この最大サイズでは各スタイルの差があまりないですね。
コードでは、UIFont.preferredFont(forTextStyle:)
がメインのAPIです。UIFont.preferredFont(forTextStyle: .body)
が今のDynamic Type設定に合っているbody
スタイルのフォントを返してくれます。
Interface Builderでは、スタイルをlabelやtext viewのFont設定にText Stylesの中で選べます。
自動フォント調整
上記のセクションの使い方だけでは、アプリがバックグラウンドにある間にユーザーがDynamic Typeの設定を変えてアプリに戻ったら、新しい設定が自動的に反映されるわけではありません。自分でcontent size categoryの変更に反応して、labelやtext viewのフォントを指定し直す必要があります。テキストスタイルをコードで指定していればまだ良いのですが、Interface Builderで指定した場合、コードで再指定することになってしまいます。
その問題を避けるため、iOS 10以上ではUIContentSizeCategoryAdjusting
プロトコルに準拠しているクラス(UILabel
, UITextField
, UITextView
)にadjustsFontForContentSizeCategory
というプロパティがあります。Interface Builder上の「Automatically Adjusts Font」と同じです。
以下その機能を「自動フォント調整」と呼ぶことにします。
自動フォント調整が上記のプロパティで有効になっているビューは、Dynamic Type設定変更後、バックグラウンドにあったアプリに戻るとフォントサイズが自動的に更新されます。ただし、フォントサイズが更新されるのは上記に紹介したtext style、またはあとで紹介するfont metrics、が使われている場合のみに有効です。
有効にされたら自動フォント調整が効く |
有効にされても自動フォント調整が効かない |
label.font = UIFont.preferredFont(forTextStyle: .body) |
label.font = UIFont.systemFont(ofSize: 12) |
Interface BuilderでフォントにText Stylesのどれかを選んだ |
Interface Builderでカスタムやシステムフォントを選んだ |
実際Interface Builderでテキストスタイルでないフォントを指定して、「Automatically Adjusts Font」にチェックを入れた場合、ビルド時に警告が出ます。コードでそのビューのフォントに自動フォント調整が効くフォントを指定したら、ちゃんと動きますがビルド時の警告が残ります。なので、コードでフォントを指定する場合、自動フォント調整を有効にするのもコードでやる(view.adjustsFontForContentSizeCategory = true
)のが良いでしょう。
カスタム
テキストスタイルAPIはシステムフォントしか使えませんし、システムフォントを使うとしても、スタイルの種類が多くありません。
テキストスタイルAPIが用意されたスタイルを使う前提で作られたとしても、コードで自分の定義したサイズやフォントを使う方法がなかったわけではありません。少し考えるだけで以下の2つの方法がすぐ思いつくのでしょう。
switch preferredContentSizeCategory { ... }
– すべてのcontent size categoryの分のフォントとサイズを用意します。かなり手間が掛かりますし、スタイルを変えたいときも大変です。
((UIFont.preferredFont(forTextStyle: .body).pointSize / 17.0) * myFontSize).round()
(17.0がbody
スタイルの標準のポイントサイズです)で自分のフォントのサイズをcontent size categoryに合わせて調整します。
実は、現時点でクックパッドiOSアプリがDynamic Typeに対応している唯一の画面、レシピ詳細では長い間後者を使っています。
もちろんこの2つの方法では、自動フォント調整が使えません。Dynamic Typeの設定に変更があったときにフォントサイズを調整したければ、「変更に反応」セクションで紹介した方法で変更に反応して各ビューのフォントを再指定します。
Deployment TargetがiOS 10以前のアプリはカスタムなスタイルを使いたかったら、上記の方法しかありませんが、iOS 11以上だと、もう少し楽なAPIが提供されています。
UIFontMetrics
iOS 11から誕生したUIFontMetrics
クラスを使うと、好きなフォントやフォントサイズを選んで、そのカスタムなスタイルのサイズをDynamic Typeの設定に合わせられるだけではなく、自動フォント調整も使えます。
使い方は以下のようです。
let customFont = UIFont(name: "AmericanTypewriter", size: 17)!
let scaledFont = UIFontMetrics.default.scaledFont(for: customFont)
label.font = scaledFont
label.adjustsImageSizeForAccessibilityContentSizeCategory = true
もしフォントサイズが大きすぎるとレイアウトが崩れてしまう場合、最大フォントサイズを指定できます。
let scaledFont = UIFontMetrics.default.scaledFont(
for: UIFont.systemFont(ofSize: customFont),
maximumPointSize: 50.0
)
UIFontMetrics
にはフォントだけではなく、画像のサイズを文字の大きさに合わせるためのメソッドもあります。
let scaledSize = UIFontMetrics.default.scaledValue(for: sizeToScale)
シンプルですね。懸念点が1つあります:Interface Builderでその機能が使えません。ビューをInterface Builderで配置しても良いのですが、フォントはコードで指定する必要があります。
メトリックスはテキストスタイル次第
上記のコードでUIFontMetrics
のインスタンスはUIFontMetrics.default
を使っていましたが、UIFontMetrics
のドキュメントを見ると、init(forTextStyle textStyle: UIFont.TextStyle)
もあります。実はUIFontMetrics.default
はUIFontMetrics(forTextStyle: .body)
に初期化されたものです。UIFontMetrics(forTextStyle: .title1)
やUIFontMetrics(forTextStyle: .caption2)
なども使えます。
どうしてテキストスタイルを指定できるのかと言いますと、Dynamic Type設定によってフォントのサイズがどう変わるのかはテキストスタイルごとに少し違うためです。以下の図を見ればもう少し分かるかと思います。図に使われた数値をどう計算したのかあとで説明します。
カスタムスタイルごとにサイズの推移をどのシステムスタイルに合わせたいのか決めるのが大変だと思うので、分からない時はUIFontMetrics.default
を使って良い気がします。
UIFontMetrics
の計算
上記は説明せずに図を出しましたが、実際サイズがどう計算されるのでしょうか?UIFontMetrics.scaledFont(for:)
の返しているフォントの自動フォント調整対応は自分で実装できないかもしれませんが、計算くらいはできるのではないでしょうか。
実はUIFontMetrics.scaledFont(for:)
に使われる計算が上記の「カスタム」セクションで紹介したすぐ思いつきそうな((UIFont.preferredFont(forTextStyle: .body).pointSize / 17.0) * myFontSize).round()
に近いです。ただし、割合はpoint sizeからではなく、leadingから計算されています。
数字は既に紹介しましたHIGのTypographyページのDynamic Type Sizesにある「Leading」にあります。図に使った数字は対象のcontent size categoryのleadingを標準(large)のleadingで割っただけです。
因みにフォントに関して「Leading」は「リーディング」ではなく、「レディング」と読みます。
TypographyページにあるLeadingというのは日本語で「行送り」のことです。1行のベースラインから次の行のベースラインまでの距離です。
UIFont
にあるleading
は違っていて、こっちは「行間」のことです。TypographyページにあるLeadingがUIFont
でいうとleading + lineHeight
です。
いろんな説明よりコードの方が分かりやすいかもしれません。下記のコードに使われているUIFont.preferredFont(forTextStyle:compatibleWith:)
がiOS 10以上でしか使えませんが、もっと古いiOSバージョンではleadingの値のテーブルをコードに埋め込めば簡単に実装できるしょう。
因みに下記のコードに使われているUITraitCollectionが表示に影響ありそうな環境の状態(サイズクラス、画面スケール)や設定(Dynamic Type)をまとめるものです。UIFontMetrics
の場合、サイズがどう変わるのか見たいとき、Dynamic Type設定をいちいち変えるのが大変なので、UITraitCollection
を渡すのが良いのですが、ビューのフォントを指定する場合は基本的にUITraitCollection
を渡さず、ユーザーの設定に合うものを求めます。
import UIKit
private extension UIFont {
var bodyLeading: CGFloat {
return lineHeight + leading
}
}
class SimpleFontMetrics {
private var textStyle: UIFont.TextStyle
init(forTextStyle textStyle: UIFont.TextStyle) {
self.textStyle = textStyle
}
static let `default`: SimpleFontMetrics = .init(forTextStyle: .body)
private static let defaultContentSizeCategoryTraitCollection = UITraitCollection(preferredContentSizeCategory: .large)
private func unroundedScaledValue(for value: CGFloat, compatibleWith traitCollection: UITraitCollection?) -> CGFloat {
let defaultFont = UIFont.preferredFont(forTextStyle: textStyle, compatibleWith: SimpleFontMetrics.defaultContentSizeCategoryTraitCollection)
let currentFont = UIFont.preferredFont(forTextStyle: textStyle, compatibleWith: traitCollection)
return (value * currentFont.bodyLeading) / defaultFont.bodyLeading
}
func scaledValue(for value: CGFloat, compatibleWith traitCollection: UITraitCollection? = nil) -> CGFloat {
let displayScale: CGFloat
if let traitCollection = traitCollection, traitCollection.displayScale != 0 {
displayScale = traitCollection.displayScale
} else {
displayScale = UIScreen.main.scale
}
return (unroundedScaledValue(for: value, compatibleWith: traitCollection) * displayScale).rounded() / displayScale
}
func scaledFontPointSize(for pointSize: CGFloat, maximumPointSize: CGFloat = 0, compatibleWith traitCollection: UITraitCollection? = nil) -> CGFloat {
assert(pointSize >= 0, "You cannot create a font of negative size.")
let scaledPointSize = unroundedScaledValue(for: pointSize, compatibleWith: traitCollection).rounded()
if maximumPointSize == 0 {
return scaledPointSize
} else {
return min(scaledPointSize, maximumPointSize)
}
}
}
UIFontMetrics
の少し不自然なところ
上記にUIFontMetrics
がサイズ計算に使っている比率がフォントのサイズ(pointSize
)のではなく、行送り(leading + lineHeight
)のだと書きましたが、フォントサイズの計算にその比率が使われるから少し不自然な結果になることがあります。
let largeTraitCollection = UITraitCollection(preferredContentSizeCategory: .large)
let axxxlTraitCollection = UITraitCollection(preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge)
let baseFont = UIFont.preferredFont(forTextStyle: .body, compatibleWith: largeTraitCollection)
UIFont.preferredFont(forTextStyle: .body, compatibleWith: axxxlTraitCollection).pointSize
UIFontMetrics.default.scaledFont(for: baseFont, compatibleWith: axxxlTraitCollection).pointSize
標準文字サイズ(large)のbody
スタイルのフォントをUIFontMetrics
で一番大きいDynamic Type設定に拡大されたフォントと、一番大きいDynamic Type設定でのbody
のフォントはサイズが少し異なります。そのため、UIFont.preferredFont
とUIFontMetrics.scaledFont
を同じ画面で混在させない方が良いかもしれません。
因みに、今回はその方が分かりやすかったのですが、普段はUIFont.preferredFont
の戻り値をUIFontMetrics.scaledFont
を渡さないでおきましょう。一応動くのですが、UIFontMetrics.scaledFont
の戻り値をまたUIFontMetrics.scaledFont
に渡すとObjective-Cの例外が発生します。
画像
テキストの横に画像があると、その画像をある程度テキストのサイズに合わせたいことがあるかもしれません。手動でUIFontMetrics.scaledValue(for:)
を使ってもできますが、iOS 11からUIButton
やUIImageView
が準拠しているUIAccessibilityContentSizeCategoryImageAdjusting
プロトコルにadjustsImageSizeForAccessibilityContentSizeCategory
というプロパティが登場しました。Interface Builderにある「Adjust Image Size」と同じです。
そのプロパティがtrue
の場合、Dynamic Type設定によってビューの画像が拡大されますが、アクセシビリティサイズが選ばれている場合のみです。他の設定ではサイズが変わりません。
サイズ調整で画像が大きくなると荒くなってしまう可能性があります。もとの画像がPDFでしたら、Asset Catalogの設定で「Preserve Vector Data」にチェックを入れたら綺麗に拡大されます。
その他
まとめ
Dynamic Type設定でユーザーが自分の視力や好みに合わせてフォントのサイズを選べますが、アプリ側での対応が必要です。
iOS 7でその機能が導入されてから、対応がやりやすくなる機能が少しずつ増えて、iOS 11以上ではだいぶ楽になったのではないでしょうか。
もっと多くのユーザーの使いやすさのために対応しているアプリを増やしておきましょう。