inSmartBank

B/43を運営する株式会社スマートバンクのメンバーによるブログです

SwiftUIにおけるDynamic Typeに対応したTypographyの実装

こんにちは、スマートバンクでアプリエンジニアをしているロクネムです。

弊社ではデザインシステムを導入しており、ColorやTypographyなどのデザイントークンやコンポーネントライブラリをiOSアプリの実装でも利用しています。 導入してから約1年ほど経ちますが、 B/43という家計簿プリカアプリ の開発を通じて、SwiftUIで1画面を実装する上でかかる時間がかなり短くなったように感じます。

本記事ではそんなデザインシステムを活用した開発の中でも Typography にフォーカスし、B/43のiOSアプリにおいてどのようにTypographyを実装しているのかについてご紹介します。

Typographyのデザイントークン

デザイントークンとは、色、タイポグラフィ、サイズ、不透明度、影などのデザインをするための最小要素のことです。

B/43のデザインシステムにおいてもこのデザイントークンは定義しており、その設計の詳細については下記の putchom san のブログにて語られています。

blog.smartbank.co.jp

B/43のデザイントークンの定義において、Typographyの型はW3CのDesign Tokens Format Moduleに従って定義しており、以下のpropertyを持ちます。

  • fontFamily: Typographyのフォント。
  • fontSize: Typographyのサイズ。
  • fontWeight: Typographyの太さ。
  • letterSpacing: Typographyの水平方向の文字間隔。
  • lineHeight: Typographyの垂直方向の行間隔。
// 💭 B/43におけるデザイントークンの定義イメージ
{
    "typography": {
        "body": {
            "small": {
                "fontSize": "{fontSize.extraSmall}",
                "fontWeight": "{fontWeight.normal}",
                "lineHeight": "{lineHeight.normal.extraSmall}",
            },
            "medium": {...},
            "large": {...}
        },
        "display": {...},
        ...
    }
}

B/43のiOSアプリの実装においては、 fontFamilyletterSpacing についてはシステムのデフォルトを使用することとしています。

Typographyの実装

ここからは、Typographyの fontSize , fontWeight そして lineHeight をどう定義していくかというところを考えていきます。

fontSize , fontWeight の実装

各デザイントークンの fontSize , fontWeight の実装は以下のようになります。

public enum B43Typography {
    // ✅ デザインシステムで規定されたデザイントークンを定義
    case bodySmall, bodyMedium, bodyLarge
    case displaySmall, displayMedium, displayLarge
    ...

    // ✅ 各デザイントークンに対応するsizeを返す
    public var fontSize: CGFloat {
        switch self {
        case .bodySmall:
            B43ReferenceFontSizeToken.extraSmall
        case .bodyMedium:
            B43ReferenceFontSizeToken.medium
        case .bodyLarge:
            B43ReferenceFontSizeToken.extraLarge
        case .displaySmall:
            B43ReferenceFontSizeToken.twoExtraLarge
        ...
        }
    }

    // ✅ 各デザイントークンに対応するweightを返す
    public var fontWeight: UIFont.Weight {
        switch self {
        case .bodySmall, .bodyMedium, .bodyLarge:
            .regular
        case .displaySmall, .displayMedium, .displayLarge:
            .bold
        ...
        }
    }
}

public extension B43Typography {
    // ✅ 各Typographyに対応するsize, weightを持つFontを返す 
    var font: UIFont {
        // 📝 Font FamilyにはSystem Fontを指定
        .systemFont(ofSize: fontSize, weight: fontWeight)
    }
}
💡 B/43では、デザイントークンのビルドシステムに Style Dictionary を採用しています。 fontSize等の数値についてはStyle Dictionaryにて生成されたコードを参照するようにしています。

この B43Typography をSwiftUIのTextに適用できるようにModifierを定義します。

public struct TypographyModifier: ViewModifier {
    public let typography: B43Typography

    public func body(content: Content) -> some View {
        content
            .font(Font(typography.font))
    }
}

public extension View {
    func typography(_ typography: B43Typography) -> some View {
        modifier(TypographyModifier(typography: typography))
    }
}

lineHeight の実装

続いて、上記実装にて lineHeight を考慮できるようにしていきます。

まずは B43TypographylineHeight のpropertyを追加します。

public enum B43Typography {
    ...
    // ✅ 各デザイントークンに対応するline heightを返す
    public var lineHeight: CGFloat {
        switch self {
        case .bodySmall:
            B43ReferenceLineHeightToken.normalExtraSmall
        case .bodyMedium:
            B43ReferenceLineHeightToken.normalMedium
        case .bodyLarge:
            B43ReferenceLineHeightToken.normalExtraLarge
        case .displaySmall:
            B43ReferenceLineHeightToken.denseTwoExtraLarge
        ...
        }
    }
}

次に、この lineHeight をSwiftUIのTextに反映できるようにしていきます。

SwiftUIでは lineSpacing Modifierを用いて行間隔を指定することが可能です。

B43Typography にてこの lineSpacing を返すpropertyを定義します。

public extension B43Typography {
    ...
    // ✅ 各Typographyに対応するline heightを元に行間を返す
    var lineSpacing: CGFloat {
        // 📝 Typographyの期待するlineHeightからUIFont自身の持つlineHeightを引いた値を返す
        lineHeight - font.lineHeight
    }
}

そして、 TypographyModifier から参照させます。

 public struct TypographyModifier: ViewModifier {
     public let typography: B43Typography

     public func body(content: Content) -> some View {
         content
             .font(Font(typography.font))
+            .lineSpacing(typography.lineSpacing)
    }
}

これにより行間がTypgraphyごとに指定されるようになりました。

しかし、Textの上部と下部の余白を指定できていないため、Line Heightを完全に反映できていません。

足りない上下の余白分は TypographyModifier にて垂直方向にpaddingを付与してあげましょう。

 public struct TypographyModifier: ViewModifier {
     public let typography: B43Typography

     public func body(content: Content) -> some View {
         content
             .font(Font(typography.font))
             .lineSpacing(typography.lineSpacing)
+            .padding(.vertical, typography.lineSpacing / 2.0)
    }
}

ここまでで、エンジニアはFigmaを見て指定されたTypographyと同じものを実装で指定すれば、同様の見た目を再現できるようになりました。

struct HomeScreen: View {
    ...
    var body: some View {
        ...
        Text(...)
            .typography(.bodySmall)
        ...
    }
}
💡 iOSのSystem fontは欧文と和文でフォントが異なるため、例えばSystem fontのサイズに17ptを指定すると、欧文では17ptで表示されるものの和文では16ptと、1ptほど小さく表示されてしまいます。
よって、厳密にFigmaと見た目を揃える場合は UIデザイナーに必要なiOSのTypographyの知識 の記事にあるように1ptの小ささを考慮したテキストスタイルを定義するなど、運用の工夫が必要となります。

Dynamic Type

続いて、このTypographyの実装をDynamic Typeに対応させていきます。

Dynamic Typeとは

Dynamic Typeとは、OSの設定からテキストサイズを変更するとアプリ上のテキストもそれに合わせてサイズが調整されるようにする機能です。

Default A11y Three Extra Large

iOSでは、System FontのWeight, Size, Leadingの属性のセットを表すText Styleがいくつか定義されており、このText Styleを用いて実装するとDynamic Typeに対応したテキストが表示されます。

Style Weight Size (points) Leading (points)
Large Title Regular 34 41
Title 1 Regular 28 34
Title 2 Regular 22 28
Title 3 Regular 20 25
Headline Semibold 17 22
Body Regular 17 22
Callout Regular 16 21
Subhead Regular 15 20
Footnote Regular 13 18
Caption 1 Regular 12 16
Caption 2 Regular 11 13
Text("Body Text Style")
    .font(.body)

Text("Title 1 Text Style")
    .font(.title1)

テキストサイズを変更するとText Styleの適用されたTextは拡大縮小されます。

実はこの拡大縮小率はText Styleによって異なります。

例えば、もともとサイズの大きいTitle 1は拡大縮小率は低く、それに比べてBodyは拡大縮小率が高く設定されています。

https://uxdesign.cc/designing-for-scalable-dynamic-type-in-ios-5d3e2ae554eb より

また、Caption 2のような既に非常に小さいText Styleの場合は縮小率がかなり低く設定されています。

https://uxdesign.cc/designing-for-scalable-dynamic-type-in-ios-5d3e2ae554eb より

Dynamic Type対応

このDynamic TypeにデザインシステムのTypographyの実装を対応させるためには UIFontMetrics.scaledFont(for:) メソッドを使用します。

このメソッドに対してDynamic Typeに対応させたいUIFontを渡すことで、テキストサイズの変更に応じて拡大縮小されたUIFontを取得できます。

 public extension B43Typography {
     var font: UIFont {
-        .systemFont(ofSize: fontSize, weight: fontWeight)
+        UIFontMetrics.default
+            .scaledFont(for: .systemFont(ofSize: fontSize, weight: fontWeight))
     }
         ...
 }

UIFontMetrics.default は TextStyleのBodyに対応したFont Metricsを返します。

つまり、上記実装ではBodyと同じ倍率でテキストのサイズが拡大縮小されます。

本来的にはデザインシステムのTypographyの持つサイズに近いTextStyleと同じ倍率で拡大縮小されるように設定するのが正しいです。

よって、まずはTypographyの持つサイズに近いTextStyleを返すpropertyを追加します。

public enum B43Typography {
    ...
    // ✅ 各デザイントークンに対応するTextStyleを返す
    public var fontMetricsTextStyle: UIFont.TextStyle {
        switch self {
        case .bodySmall: .footnote
        case .bodyMedium: .callout
        case .bodyLarge: .title2
        case .displaySmall: .title1
        ...
        }
    }
}

そして、この値を UIFontMetrics の初期化時に指定します。

 public extension B43Typography {
     var font: UIFont {
-        UIFontMetrics.default
+        UIFontMetrics(forTextStyle: fontMetricsTextStyle)
+            .scaledFont(for: .systemFont(ofSize: fontSize, weight: fontWeight))
     }
     ...
 }

以上で、Typographyの持つサイズに近いTextStyleと同じ倍率でフォントが拡大縮小されるようになりました。

Line Heightについても同様にDynamic Typeが適用されるよう修正しておきます。

 public extension B43Typography {
     ...
     var lineSpacing: CGFloat {
-        lineHeight - font.lineHeight
+        let lineHeight = UIFontMetrics(forTextStyle: fontMetricsTextStyle)
+            .scaledValue(for: lineHeight)
+        return lineHeight - font.lineHeight
     }
     ...
 }

最後に、テキストサイズの変更を検知して動的に更新が反映されるように、 TypographyModifier にて @Environment(.dynamicTypeSize) を宣言します。

 public struct TypographyModifier: ViewModifier {
+    @Environment(\.dynamicTypeSize) private var dynamicTypeSize 
     
     public let typography: B43Typography
     public func body(content: Content) -> some View { ... }
 }
💡 @Environment(\.dynamicTypeSize) の値をViewのbodyから参照することはありませんが、宣言しておくことでテキストサイズの変更を検知して動的にフォントを拡大縮小してくれるようになります。この宣言がなければ、該当のViewが再描画されない限りはフォントサイズは更新されません。

まとめ

以上が、B/43のiOSアプリにおけるTypographyの実装方針でした。

B/43では、アプリ起動時に preferredContentSizeCategory を取得して計測しており、Dynamic Typeの設定サイズごとのユーザー数を以下のように可視化しています。

デフォルトのLargeを設定しているユーザーは全体の約70%、デフォルトより小さいサイズを指定している方は約25%、残りの約5%がデフォルトより大きいサイズを指定していました。

Dynamic Typeは、より大きなサイズのテキストの方が読みやすいというユーザーにとっての助けになることはもちろんのこと、テキストのサイズを通常より小さく表示することで、小さいサイズのテキストを読むことができるユーザーはより多くの情報を画面に表示することが可能とします。

本記事が、そんなDynamic Typeに対応したTypographyの実装を進める上での参考になれば幸いです。

最後に

スマートバンクでは一緒にB/43のアプリを開発していくメンバーを募集しています! カジュアル面談も受け付けていますので、ぜひお気軽にご応募ください💪

smartbank.co.jp

We create the new normal of easy budgeting, easy banking, and easy living.
In this blog, engineers, product managers, designers, business development, legal, CS, and other members will share their insights.