Dynamic Type

モバイル基盤部のヴァンサン(@vincentisambart)です。

使っているアプリのフォントサイズを変えたいと思ったことありますか?目があまり良くないから文字を大きくしたい。逆にもっと多くの情報を一目で見られるために文字を少し小さくしたい。

フォントサイズをアプリ内で調整できるアプリもありますが、iOSではシステム全体のフォントサイズを調整できる「Dynamic Type」と呼ばれる機能があります。iOS全体の設定に一般→アクセシビリティ→さらに大きな文字(英語だとSettingsにGeneral→Accessibility→Larger Text)で変えられます。対応しているアプリではフォントサイズがその設定に合わせられます。

標準より少し小さくできるとはいえ、「アクセシビリティ」設定に入っているのもあって文字を大きくする方がメインのユースケースの気がします。

Dynamic TypeはiOS 7から使えるようになりましたが、関連している様々な便利機能がその後のiOSバージョンに追加されました。

一応Dynamic Typeを使うにはAuto Layoutは必須ではないのですが、手動レイアウトと一緒に使うのはおすすめできません。

システム設定変更

設定を変える前に、簡単に戻せるようにまずは標準設定を覚えておきましょう。標準設定は以下のスクリーンショットのように「さらに大きな文字」が無効で、下部のスライダーがど真ん中です。

Dynamic Type標準設定

スライダーを動かすとフォントサイズがどれくらい変わるのかすぐ見られます。一番小さい設定(extra smallまたはXS)にすると、以下のようになります。

f:id:vincentisambart:20190104132028p:plain:w320

「さらに大きな文字」無効のまま一番大きい設定(extra extra extra largeまたはXXXL)にすると以下のようになります。

f:id:vincentisambart:20190104132100p:plain:w320

アクセシビリティサイズはXXXLより大きく、「さらに大きな文字」を有効にすると選択できるようになる5つの設定です。その一番大きい設定(accessibility extra extra extra large、またはAX5)にすると以下のようになります。

f:id:vincentisambart:20190104131951p:plain:w320

因みに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以上では、アクセシビリティサイズかどうか確認するためにUIContentSizeCategoryisAccessibilityCategoryというメソッドがあります。また、同じくiOS 11以上でUIContentSizeCategory<, <=, >=, >で比較できるようになるのでtraitCollection.preferredContentSizeCategory > .extraExtraExtraLargeも使えます。

content size categoryの値によって、レイアウトを変えたりできます。例えばDynamic Typeの設定画面で大きいアクセシビリティサイズを選ぶ時、「さらに大きな文字」スイッチがラベルの右からその下に移ります。Dynamic Type設定によってUIStackViewの設定が変わるのでしょう。

f:id:vincentisambart:20190104131951p:plain:w320

自分のアプリ内、Dynamic Typeの設定に合わせてするレイアウト変更は作成時以外、どういうタイミングでやれば良いのでしょうか。

変更に反応

アプリがバックグラウンドにある間に、ユーザーがDynamic Typeの設定を変えるとアプリが終了されるわけではありません。ユーザーがアプリに戻ったら、アプリが変更を知らされる仕組みが2つあります:

  • UIContentSizeCategory.didChangeNotificationというnotificationがアプリに送られます。
  • iOS 8以上ではtraitCollectionDidChangeメソッドが呼ばれます。UITraitEnvironmentプロトコルのメソッドですが、UIViewUIViewControllerが実装しているので、自分のビューやビューコントローラに以下のコードでDynamic Typeの設定変更に反応できます。
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
        // 設定が変わったときにやりたいこと
    }
}

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で各スタイルを表示すると以下のようになります。

f:id:vincentisambart:20190104132124p:plain:w500

extraExtraExtraLarge設定では、以下のようになります。

f:id:vincentisambart:20190104132150p:plain:w500

accessibilityExtraExtraExtraLarge設定では、以下のようになります。この最大サイズでは各スタイルの差があまりないですね。

f:id:vincentisambart:20190104132207p:plain:w500

コードでは、UIFont.preferredFont(forTextStyle:)がメインのAPIです。UIFont.preferredFont(forTextStyle: .body)が今のDynamic Type設定に合っているbodyスタイルのフォントを返してくれます。

Interface Builderでは、スタイルをlabelやtext viewのFont設定にText Stylesの中で選べます。

f:id:vincentisambart:20190104131624p:plain:w390

自動フォント調整

上記のセクションの使い方だけでは、アプリがバックグラウンドにある間にユーザーがDynamic Typeの設定を変えてアプリに戻ったら、新しい設定が自動的に反映されるわけではありません。自分でcontent size categoryの変更に反応して、labelやtext viewのフォントを指定し直す必要があります。テキストスタイルをコードで指定していればまだ良いのですが、Interface Builderで指定した場合、コードで再指定することになってしまいます。

その問題を避けるため、iOS 10以上ではUIContentSizeCategoryAdjustingプロトコルに準拠しているクラス(UILabel, UITextField, UITextView)にadjustsFontForContentSizeCategoryというプロパティがあります。Interface Builder上の「Automatically Adjusts Font」と同じです。

f:id:vincentisambart:20190104131820p:plain:w325

以下その機能を「自動フォント調整」と呼ぶことにします。

自動フォント調整が上記のプロパティで有効になっているビューは、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)! // 好きなサイズのシステムフォント`UIFont.systemFont(ofSize: myCustomSize)`でも大丈夫です
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.defaultUIFontMetrics(forTextStyle: .body)に初期化されたものです。UIFontMetrics(forTextStyle: .title1)UIFontMetrics(forTextStyle: .caption2)なども使えます。

どうしてテキストスタイルを指定できるのかと言いますと、Dynamic Type設定によってフォントのサイズがどう変わるのかはテキストスタイルごとに少し違うためです。以下の図を見ればもう少し分かるかと思います。図に使われた数値をどう計算したのかあとで説明します。

f:id:vincentisambart:20190104131516p:plain

カスタムスタイルごとにサイズの推移をどのシステムスタイルに合わせたいのか決めるのが大変だと思うので、分からない時は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 {
    // https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/typography/#dynamic-type-sizes にある"leading"に相当します
    // 実はUIFontには`_bodyLeading`というメソッドがありますが、プライベートなので再実装が必要です
    var bodyLeading: CGFloat {
        return lineHeight + leading
    }
}

// `UIFontMetrics`の機能の一部を再実装するクラス
// 注意:自動フォント調整対応のフォントを作るには本物の`UIFontMetrics`を使うしかありません
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
    }

    // `UIFontMetrics.scaledValue(for:compatibleWith:)`と同じです
    func scaledValue(for value: CGFloat, compatibleWith traitCollection: UITraitCollection? = nil) -> CGFloat {
        // 表示に使われている画面のスケール(ポイントごとのピクセル数)
        let displayScale: CGFloat
        // `traitCollection.displayScale`が0だったら未定だということなので`traitCollection`が指定されていないと同じ扱いです
        if let traitCollection = traitCollection, traitCollection.displayScale != 0 {
            displayScale = traitCollection.displayScale
        } else {
            displayScale = UIScreen.main.scale
        }
        // ピクセル単位で四捨五入
        return (unroundedScaledValue(for: value, compatibleWith: traitCollection) * displayScale).rounded() / displayScale
    }

    // `UIFontMetrics.scaledFont(for:maximumPointSize:compatibleWith:).pointSize`に相当します
    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 // => 53
UIFontMetrics.default.scaledFont(for: baseFont, compatibleWith: axxxlTraitCollection).pointSize // => 48

標準文字サイズ(large)のbodyスタイルのフォントをUIFontMetricsで一番大きいDynamic Type設定に拡大されたフォントと、一番大きいDynamic Type設定でのbodyのフォントはサイズが少し異なります。そのため、UIFont.preferredFontUIFontMetrics.scaledFontを同じ画面で混在させない方が良いかもしれません。

因みに、今回はその方が分かりやすかったのですが、普段はUIFont.preferredFontの戻り値をUIFontMetrics.scaledFontを渡さないでおきましょう。一応動くのですが、UIFontMetrics.scaledFontの戻り値をまたUIFontMetrics.scaledFontに渡すとObjective-Cの例外が発生します。

画像

テキストの横に画像があると、その画像をある程度テキストのサイズに合わせたいことがあるかもしれません。手動でUIFontMetrics.scaledValue(for:)を使ってもできますが、iOS 11からUIButtonUIImageViewが準拠しているUIAccessibilityContentSizeCategoryImageAdjustingプロトコルにadjustsImageSizeForAccessibilityContentSizeCategoryというプロパティが登場しました。Interface Builderにある「Adjust Image Size」と同じです。

f:id:vincentisambart:20190104132302p:plain:w325

そのプロパティがtrueの場合、Dynamic Type設定によってビューの画像が拡大されますが、アクセシビリティサイズが選ばれている場合のみです。他の設定ではサイズが変わりません。

サイズ調整で画像が大きくなると荒くなってしまう可能性があります。もとの画像がPDFでしたら、Asset Catalogの設定で「Preserve Vector Data」にチェックを入れたら綺麗に拡大されます。

f:id:vincentisambart:20190104132325p:plain:w325

その他

  • 広い画面でもコンテンツが広がりすぎないためにある読みやすい幅機能はDynamic Typeと一緒に使われるように設計されているので、ぜひ紹介記事をご覧ください。
  • 既存のアプリは全画面を一気に対応するのは難しいでしょう。1つの画面で対応しているビューとしていないビューが混在すると不自然な表示になりますので、画面単位で対応した方が良い気がします。
  • テーブルのセルの高さはできるだけシステムが自動計算するようにしましょう:estimatedRowHeightの指定を忘れず(指定されないと自動計算が動かないので)、高さ(rowHeight)をUITableView.automaticDimensionにします。rowHeightestimatedRowHeightUITableViewのプロパティで指定しても、UITableViewDelegateのメソッドで返しても、どっちでも大丈夫です。
  • Auto Layoutを使ってビューを配置するとき、ベースラインアンカー(firstBaselineAnchorまたはlastBaselineAnchor)に対する縦制約はconstraint(equalToSystemSpacingBelow:multiplier:)(またはlessThanバージョンgreaterThanバージョン)を使うと、制約の高さがフォントのサイズに合わせて変わります。Interface Builder上では「Constant」の値に「Use Standard Value」を選ぶのと同じです。

まとめ

Dynamic Type設定でユーザーが自分の視力や好みに合わせてフォントのサイズを選べますが、アプリ側での対応が必要です。

iOS 7でその機能が導入されてから、対応がやりやすくなる機能が少しずつ増えて、iOS 11以上ではだいぶ楽になったのではないでしょうか。

もっと多くのユーザーの使いやすさのために対応しているアプリを増やしておきましょう。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/