🖋️

SwiftUIでTypographyのカスタムトークンをシームレスに呼び出す

2024/07/31に公開

最近は Figma でビジュアルデザインを管理することが増えてきましたが、Typography(テキスト)のデザイントークン (Design Token) も他のプラットフォームとも仕様を統一できるので導入しているところも多いかと思います。
そんなトークンを iOS、さらには SwiftUI に対応させる際、色々と考慮することは多いのですが、非常にシンプルな実装でシームレスに使えるような例を紹介してみます。

Apple Typography

Human Interface Guideline にも Typography のセクションが用意されておりますので、一度読んでみると良いでしょう。
https://developer.apple.com/design/human-interface-guidelines/typography

基本的には、システムフォント(SF Pro や ヒラギノ角ゴシック)が非常に美しいので、このまま使うことが多いと思いますが、Dynamic Type という端末のフォント拡大/縮小の設定値に応じて動的に変化するという仕組みをサポートすることが推奨されています。

ちなみに、Apple もデフォルトとしての Typography のデザイントークンを用意してくれています。
https://developer.apple.com/design/human-interface-guidelines/typography#Specifications

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension Font {
    /// A dynamic text style to use for fonts.
    public enum TextStyle : CaseIterable, Sendable {

        /// The font style for large titles.
        case largeTitle

        /// The font used for first level hierarchical headings.
        case title

        /// The font used for second level hierarchical headings.
        @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
        case title2

        /// The font used for third level hierarchical headings.
        @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
        case title3

        /// The font used for headings.
        case headline

        /// The font used for subheadings.
        case subheadline

        /// The font used for body text.
        case body

        /// The font used for callouts.
        case callout

        /// The font used in footnotes.
        case footnote

        /// The font used for standard captions.
        case caption

        /// The font used for alternate captions.
        @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
        case caption2

ミニマムには、こちらが最適化されたものであるため、そのまま使うのがベターかなと思いますが、先述した通り他のプラットフォームの仕様とも統一するためなどでカスタムなデザイントークンを用いたいケースがあると思います。
そこに対応する方法を示したいと思います。

実装

カスタムのデザイントークンの定義

まずは、Typography という enum 型を用意して case を列挙します。case の定義はそれぞれのプロダクトのデザイントークンをベースに定義してください。
そして、Dynamic Type のデフォルトとなる Large variant におけるフォントサイズ値を size というプロパティに CGFloat 型で定義します。

enum Typography: String, CaseIterable {
    case displayLarge
    case displayMedium
    case displaySmall
    case headlineLarge
    case headlineMedium
    case headlineSmall
    case titleLarge
    case titleMedium
    case titleSmall
    case bodyLarge
    case bodyMedium
    case bodySmall
    case labelLarge
    case labelMedium
    case labelSmall

    fileprivate var size: CGFloat {
        switch self {
        case .displayLarge: return 57
        case .displayMedium: return 45
        case .displaySmall: return 36
        case .headlineLarge: return 32
        case .headlineMedium: return 28
        case .headlineSmall: return 24
        case .titleLarge: return 22
        case .titleMedium: return 16
        case .titleSmall: return 14
        case .bodyLarge: return 16
        case .bodyMedium: return 14
        case .bodySmall: return 12
        case .labelLarge: return 14
        case .labelMedium: return 12
        case .labelSmall: return 11
        }
    }
}

Dynamic Type の比率への対応

つぎに、カスタムのデザイントークンが Dynamic Type による拡大/縮小の設定に応じてフォントサイズが変化するように比率を定義します。この際、一つひとつを定義していると凄まじい組み合わせになるため、Apple のデフォルトのトークンのフォントサイズに最も近くなるようにその対応を先ほどの HIG の Specifications を参考に relative というプロパティを定義し実装します。
一度この対応を作っておくと、カスタムのデザイントークンを増やした時にも勝手に対応してくれるようになります。

extension Typography {
    /// Dynamic Type の拡大縮小の比率の基準となるデフォルトの TextStyle とのマッピング
    /// https://developer.apple.com/design/human-interface-guidelines/typography#Specifications
    fileprivate var relative: Font.TextStyle {
        switch Int(size) {
        case 34...: return .largeTitle
        case 28..<34: return .title
        case 22..<28: return .title2
        case 20..<22: return .title3
        case 17..<20: return .body
        case 16..<17: return .callout
        case 15..<16: return .subheadline
        case 13..<15: return .footnote
        case 12..<13: return .caption
        default: return .caption2
        }
    }
}

これで、カスタムの Typography デザイントークンの定義と Dynamic Type の比率を定義することができました 🎉

font モディファイアから使えるようにする

さて、Apple の標準トークンは、View から使える font(_ font:) モディファイアによって、非常にシームレスに使えるようになっています。

Text("Hello world!!")
    .font(.body)

これをカスタムのデザイントークンでも同じように使えるようにしていきます。
すでにある font モディファイアをそのまま使えるようにするには、Font 型の static property などを定義する必要がありますが、そうすると先ほど定義した Typography 以外にも定義を増やさないといけないため、多少面倒です。(Apple のトークンは Font.TextStyle だけでなくご丁寧に Font 型のアクセサも用意してくれているので使えるというカラクリです)

そのため、Typography 型が使える新しい font モディファイアを定義して、それを使えるようにしていきたいと思います。

extension View {
    func font(_ typography: Typography) -> some View {
        modifier(TypographyModifier(typography))
    }
}

private struct TypographyModifier: ViewModifier {
    @ScaledMetric var scale: CGFloat

    init(_ typography: Typography) {
        _scale = .init(wrappedValue: typography.size, relativeTo: typography.relative)
    }

    func body(content: Content) -> some View {
        content
            .font(.system(size: scale))
    }
}

font(_ typography:) モディファイアを新たに定義し、任意の Typography 型を渡せるようにします。
実ロジックとなる TypographyModifier 構造体では、@ScaledMetric という Property Wrapper を用いた scale プロパティを定義し、端末の設定値に応じて動的にスケールするような実装をしています。
ScaledMetric の初期化には先ほど定義した基準となる size の値と relative の対応を渡せる Initializer がサポートされてるので、それらをそのまま展開して渡すことで scale 値をよしなに算出してくれます。非常に便利ですね!
あとは、body の実装として、既存の font モディファイアを使ってシステムフォントかつ scale された size を指定してあげるだけです。

実装としてはこれで全部です。

呼び出し方

呼び出し方は、Apple 標準のトークンを用いるときと全く同じように、font モディファイアを使って Typography 型のトークンを指定するだけです。予備知識いらずで使えるので非常にシームレスです。

せっかくなので、SwiftUI Preview にて描画してみましょう。
しれっと Typography を CaseIterable に準拠させておいたので、ForEach を使って全てのトークンの実装を一気に閲覧できるプレビューを作ってみます。

#Preview {
    VStack {
        ForEach(Typography.allCases, id: \.self) { typography in
            Text(typography.rawValue)
                .font(typography)
        }
    }
}

Preview を表示してみると、このような形でトークンごとのフォントサイズに応じた描画がうまくできていることが確認できると思います。

さらに、Preview の Dynamic Type Variants モードで表示すれば全ての端末設定値に応じた表示も一覧で可視化できます(便利!)。ちゃんと設定値に応じてサイズが可変しており、Dynamic Type をサポートできていることもわかるかと思います。

さいごに

何個かに分けて実装を紹介したので、最後に 1 ファイルとしての全てのコードを載せておきます(プレビューを含めても 100 行未満のコードで非常にエッセンシャルです ✨)。

Typography.swift
import SwiftUI

enum Typography: String, CaseIterable {
    case displayLarge
    case displayMedium
    case displaySmall
    case headlineLarge
    case headlineMedium
    case headlineSmall
    case titleLarge
    case titleMedium
    case titleSmall
    case bodyLarge
    case bodyMedium
    case bodySmall
    case labelLarge
    case labelMedium
    case labelSmall

    fileprivate var size: CGFloat {
        switch self {
        case .displayLarge: return 57
        case .displayMedium: return 45
        case .displaySmall: return 36
        case .headlineLarge: return 32
        case .headlineMedium: return 28
        case .headlineSmall: return 24
        case .titleLarge: return 22
        case .titleMedium: return 16
        case .titleSmall: return 14
        case .bodyLarge: return 16
        case .bodyMedium: return 14
        case .bodySmall: return 12
        case .labelLarge: return 14
        case .labelMedium: return 12
        case .labelSmall: return 11
        }
    }

    /// Dynamic Type の拡大縮小の比率の基準となるデフォルトの TextStyle とのマッピング
    /// https://developer.apple.com/design/human-interface-guidelines/typography#Specifications
    fileprivate var relative: Font.TextStyle {
        switch Int(size) {
        case 34...: return .largeTitle
        case 28..<34: return .title
        case 22..<28: return .title2
        case 20..<22: return .title3
        case 17..<20: return .body
        case 16..<17: return .callout
        case 15..<16: return .subheadline
        case 13..<15: return .footnote
        case 12..<13: return .caption
        default: return .caption2
        }
    }
}

extension View {
    func font(_ typography: Typography) -> some View {
        modifier(TypographyModifier(typography))
    }
}

private struct TypographyModifier: ViewModifier {
    @ScaledMetric var scale: CGFloat

    init(_ typography: Typography) {
        _scale = .init(wrappedValue: typography.size, relativeTo: typography.relative)
    }

    func body(content: Content) -> some View {
        content
            .font(.system(size: scale))
    }
}

#Preview {
    VStack {
        ForEach(Typography.allCases, id: \.self) { typography in
            Text(typography.rawValue)
                .font(typography)
        }
    }
}

かなりミニマムな実装で Typography の独自のデザイントークンの定義に対応することができました 🎉
カスタムフォントを使ったり、weight の定義なども細かく設定できるようにしたい場合は本実装を拡張すればいいですし、Figma との連携を用いて case を自動生成したりするとさらに効果的かなと思います。以上です!

Discussion