SwiftUIでTypographyのカスタムトークンをシームレスに呼び出す
最近は Figma でビジュアルデザインを管理することが増えてきましたが、Typography(テキスト)のデザイントークン (Design Token) も他のプラットフォームとも仕様を統一できるので導入しているところも多いかと思います。
そんなトークンを iOS、さらには SwiftUI に対応させる際、色々と考慮することは多いのですが、非常にシンプルな実装でシームレスに使えるような例を紹介してみます。
Apple Typography
Human Interface Guideline にも Typography のセクションが用意されておりますので、一度読んでみると良いでしょう。
基本的には、システムフォント(SF Pro や ヒラギノ角ゴシック)が非常に美しいので、このまま使うことが多いと思いますが、Dynamic Type という端末のフォント拡大/縮小の設定値に応じて動的に変化するという仕組みをサポートすることが推奨されています。
ちなみに、Apple もデフォルトとしての Typography のデザイントークンを用意してくれています。
@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