【SwiftUI】プレビューでカスタムテキストスタイルの Dynamic Type に対応する

# はじめに

Dynamic Type とはフォントサイズをユーザが調整できるようにする OS の機能です。

アプリ内設定ではなく、設定アプリから端末全体に対して設定するやつですね。

公式が用意している [`Font.TextStyle`](https://developer.apple.com/documentation/swiftui/font/textstyle) を使用することで簡単に対応できます。

ただし、`Font.TextStyle` はフォントサイズがガチガチに決められているためデザイン指示がある場合などは対応しきれないことが多いでしょう。

というわけで、この記事では**カスタムのテキストスタイルの Dynamic Type 対応方法**について紹介します。


# 実装方法2パターン

上記に対応する場合、パターンとしては以下の二つがあります。

1.  [`UIFontMetrics`](https://developer.apple.com/documentation/uikit/uifontmetrics) を使用するパターン
2.  [`ScaledMetric`](https://developer.apple.com/documentation/swiftui/scaledmetric) を使用するパターン

`UIFontMetrics` のパターンで対応している方が多い印象です。（自分の周りだけかもしれませんが）

ただ、使い始めると気付くと思うのですが `UIFontMetrics` はその仕組み上、 **SwiftUI プレビューの Dynamic Type 設定を変更しても反映されません**。

ちなみにプレビューの Dynamic Type 設定場所はこちらです。

<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617831/c996cefd-0aad-df3e-76fc-85531b1e2860.png">

せっかくプレビューがあるのに Dynamic Type の細かなレイアウト調整のために Run が必要になるのはもったいないですよね。

そのため **SwiftUI プレビューの Dynamic Type 変更に追従できる `ScaledMetric` を使用したパターン** がおすすめです。

というわけで、この記事では主に `ScaledMetric` を使用した実装例を紹介します。

`UIFontMetrics` の方も気になるという方はぜひ下記の記事を読んでください。
めちゃくちゃ勉強になります。

[Dynamic Type](https://techlife.cookpad.com/entry/2019/01/08/090000)


# ソースコード

完成系のコードは下記に格納しています。
[GitHub](https://github.com/ain-gr3/ScaledMetricSample) 

# ScaledMetric を使用した実装方法

## 1. カスタムのテキストスタイルを定義

まずはカスタムのテキストスタイルを定義します。

```swift
enum MyTextStyle {

    case title
    case body
    case subheadline
    case caption
}
```

## 2. テキストスタイルのデフォルトサイズを定義

`MyTextStyle` に対してデフォルトのサイズを定義します。

```swift
extension MyTextStyle {

    var defaultSize: Double {
        switch self {
        case .title:
            return 21
        case .body:
            return 17
        case .subheadline:
            return 15
        case .caption:
            return 12
        }
    }
}
```

ちなみにデフォルトサイズとは [DynamicTypeSize.large](https://developer.apple.com/documentation/swiftui/dynamictypesize/large) で表示されるサイズになります。

.large が DynamicTypeSize のデフォルト値なので、設定アプリから文字サイズを一度も変更していない場合は .large が設定されているはずです。


## 3. 文字の拡大率を設定

`Font.TextStyle` には「Dynamic Type 変化時にどれぐらい文字サイズが変わるのか」のパラメータがそれぞれ設定されています。

例えば `Font.TextStyle.caption` は元々文字サイズが小さいため　`DynamicTypeSize.medium` 以下は文字サイズが変わらないように設定されていたりします。

全てのテキストスタイルの拡大率を同じにするのも良いですが、
元々小さい文字が小さくなりすぎないようにするなどのちょっとした配慮がアプリの品質を高めてくれます。

そのため、カスタムテキストスタイルにもそれぞれの用途に合った拡大率を設定してあげるのが良いでしょう。

設定方法としては標準の `Font.TextStyle` を使用します。

どの `Font.TextStyle` をあてるべきか迷う場合もありますが、基本的にはデフォルトサイズが近いものを選ぶのが良いと思います。


```swift
extension MyTextStyle {

    var relativeStyle: Font.TextStyle {
        switch self {
        case .title:
            return .title
        case .body:
            return .body
        case .subheadline:
            return .subheadline
        case .caption:
            return .caption
        }
    }
}
```

## 4. ViewModifier を定義する

`MyTextStyle` で定義したパラメータを実際にフォント設定として `View` に適用するための `ViewModifier` を用意します。

```swift
struct ScaledFontModifier: ViewModifier {

    @ScaledMetric var scale: Double

    init(_ style: MyTextStyle) {
        self._scale = .init(
            wrappedValue: style.defaultSize,
            relativeTo: style.relativeStyle
        )
    }

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

ここで本命の `ScaledMetric` が登場します。

[`ScaledMetric.init(wrappedValue:relativeTo:)`](https://developer.apple.com/documentation/swiftui/scaledmetric/init(wrappedvalue:relativeto:)) の引数は

- wrappedValue ... デフォルトのフォントサイズ
- relativeTo ... 文字の拡大率をどの Font.TextStyle に合わせるか

となっています。

---

### `ScaledMetric` について補足

#### 1. `ViewModifier` の実装解説

`ScaledMetric` は property wrapper なので `ScaledMetric` の `wrappedValue` の型（今回は Double）の stored property として定義します。

初期化時に `self._scale` としてアクセスしていますがこれは property wrapper の仕様の一つで、アンダースコアをつけると本体の `struct ScaledMetric` にアクセスすることができます。
逆にアンダースコアをつけない場合は `wrappedValue` に直接アクセスすることができます。

今回は `ScaledMetric.init(wrappedValue:relativeTo:)` が肝なので `self._scale` としてアクセスしています。


#### 2. `@ScaledMetric` の再描画の仕組み

内部はブラックボックス化されているのであくまでも推測ですが、
`@ScaledMetric` は `@Environment` と同じ仕組みで
`EnvironmentValues` の `DynamicTypeSize`（or `SizeCategory`）を参照しています。
そして `DynamicTypeSize` 変更時に自身が定義されている `View` や `ViewModifier` を再描画させています。

はじめに `UIFontMetrics` はプレビューの Dynamic Type 設定が反映されないと書きましたが、上記の仕組みが関係しています。

おそらくプレビューは　`EnvironmentValues` として Dynamic Type を設定しており、端末の設定には一切関与していません。

逆に `UIFontMetrics` は端末の設定を参照し`EnvironmentValues` を参照していないため、プレビューのパラメータ変更が反映されないのでしょう。


---

呼び出し用のメソッドを `View extension` に定義します。

```swift
extension View {

    func scaledFont(_ style: MyTextStyle) -> some View {
        modifier(ScaledFontModifier(style))
    }
}
```

```swift
// 使用時
Text("hogehoge")
    .scaledFont(.title)
```

「Dynamic Type に対応したカスタムテキストスタイルの実装」という点ではこの状態で十分なのですが、実際に使用する際は一緒に Weight も指定したくなります。

以降で指定方法を紹介します。

ただしあくまでも一例であり、それぞれ好みの使用感を追求するのが良いと思います。


## 5. Weight を指定できるようにする

`MyTextStyle` の `associatedValue` として `Weight` を持たせるのがおすすめです。

デフォルト値を設定できるという点と、`.scaledFont(.body(.bold))` のように直感的に使用することができるという点がポイントです。


```swift
enum MyTextStyle {

    // associatedValue に Font.Weight を追加
    // デフォルト値も設定する
    case title(Font.Weight = .bold)
    case body(Font.Weight = .regular)
    case subheadline(Font.Weight = .regular)
    case caption(Font.Weight = .regular)
}

extension MyTextStyle {

    // Font.Weight を MyTextStyle から取得する処理を追加
    var weight: Font.Weight {
        switch self {
        case .title(let weight),
            .body(let weight),
            .subheadline(let weight),
            .caption(let weight):
            return weight
        }
    }
}
```

```swift
struct ScaledFontModifier: ViewModifier {

    @ScaledMetric var scale: Double
    // weight をプロパティとして追加
    var weight: Font.Weight

    init(_ style: MyTextStyle) {
        self._scale = .init(
            wrappedValue: style.defaultSize,
            relativeTo: style.relativeStyle
        )
        // style から weight を取得して self.weight に設定する
        self.weight = style.weight
    }

    func body(content: Content) -> some View {
        content
            // 引数の weight に値を設定
            .font(.system(size: scale, weight: weight))
    }
}

```

```swift
// 使用時
// style: body, weight: regular
Text("hogehoge")
    .scaledFont(.body())

// style: body, weight: bold
Text("hogehoge")
    .scaledFont(.body(.bold))
```


# まとめ

ここまでのコードの全貌はこんな感じです↓
```swift
enum MyTextStyle {

    case title(Font.Weight = .bold)
    case body(Font.Weight = .regular)
    case subheadline(Font.Weight = .regular)
    case caption(Font.Weight = .regular)
}

private extension MyTextStyle {

    var defaultSize: Double {
        switch self {
        case .title:
            return 21
        case .body:
            return 17
        case .subheadline:
            return 15
        case .caption:
            return 12
        }
    }

    var relativeStyle: Font.TextStyle {
        switch self {
        case .title:
            return .title
        case .body:
            return .body
        case .subheadline:
            return .subheadline
        case .caption:
            return .caption
        }
    }

    var weight: Font.Weight {
        switch self {
        case .title(let weight),
            .body(let weight),
            .subheadline(let weight),
            .caption(let weight):
            return weight
        }
    }
}

private struct ScaledFontModifier: ViewModifier {

    @ScaledMetric var scale: Double
    var weight: Font.Weight

    init(_ style: MyTextStyle) {
        self._scale = .init(
            wrappedValue: style.defaultSize,
            relativeTo: style.relativeStyle
        )
        self.weight = style.weight
    }

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

extension View {

    func scaledFont(_ style: MyTextStyle) -> some View {
        modifier(ScaledFontModifier(style))
    }
}
```

プレビューで見てみましょう。

<img width=600 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/617831/cec35dad-54a4-9480-b884-1b05c2d857b1.gif">

いい感じにプレビューでも反映できていますね。

AX3 あたりから title より body の方が大きくなるのが面白いですね。
この倍率にした理由が気になる :thinking: 


# おまけ

`ScaledMetric` はカスタムのフォントファミリーの Dynamic Type 対応にも使用できます。

ここでは説明は省きますが、カスタムフォントファミリー付きの実装例も [GitHub](https://github.com/ain-gr3/ScaledMetricSample) にアップロードしています。

ぜひ参考にしてください。