2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-11-19

はじめに

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

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

公式が用意している Font.TextStyle を使用することで簡単に対応できます。

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

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

実装方法2パターン

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

  1. UIFontMetrics を使用するパターン
  2. ScaledMetric を使用するパターン

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

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

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

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

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

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

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

Dynamic Type

ソースコード

完成系のコードは下記に格納しています。
GitHub

ScaledMetric を使用した実装方法

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

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

enum MyTextStyle {

    case title
    case body
    case subheadline
    case caption
}

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

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

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 で表示されるサイズになります。

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

3. 文字の拡大率を設定

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

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

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

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

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

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

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 を用意します。

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:) の引数は

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

となっています。


ScaledMetric について補足

1. ViewModifier の実装解説

ScaledMetric は property wrapper なので ScaledMetricwrappedValue の型(今回は Double)の stored property として定義します。

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

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

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

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

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

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

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


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

extension View {

    func scaledFont(_ style: MyTextStyle) -> some View {
        modifier(ScaledFontModifier(style))
    }
}
// 使用時
Text("hogehoge")
    .scaledFont(.title)

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

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

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

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

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

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

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
        }
    }
}
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))
    }
}

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

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

まとめ

ここまでのコードの全貌はこんな感じです↓

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))
    }
}

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

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

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

おまけ

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

ここでは説明は省きますが、カスタムフォントファミリー付きの実装例も GitHub にアップロードしています。

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

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?