🦩

MaterialAppにおけるTypographyの役割を理解する

2022/12/17に公開

本記事は Flutterのカレンダー | Advent Calendar 2022 - Qiita の17日目の記事です。

背景

私のチームでは、これまでデザインとエンジニアリングを Flutter で行うスクラップビルドな開発をしてきましたが、最近は体制の拡大やメンバーへの教育的な観点で、Figma のデザインをマスタとして開発するスタンダードな方法を取り始めました。
そのために、すでに Flutter で開発した画面をベースに、Figma でトレースしながらコンポネントなどを作るという通常とは逆のフローの作業をしているわけですが、とくに iOS のフォントについては完璧なトレースができず苦戦していました。考えてみれば、開発している Flutter アプリは MaterialApp(Material Design)で構成されているため、iOS の Human Interface Guideline に倣ってトレースしようとすると、微妙に差異がでてくるのは当然だと気づきました。

プラットフォームごとに描画しているフォントが異なるのは理解できると思いますが、それが Flutter でどのように実現されているのか、Material Design をどのようにプラットフォームごとに適用しているのか、その辺りの構造を Typography・TextTheme を中心に紐解いていこうと思います。

本記事で取り扱うこと

  • 各プラットフォームにおけるシステムフォントの違い
  • MaterialApp における Typography・TextTheme の役割
  • プラットフォーム別にデフォルトで利用される TextTheme の分岐

システムフォント

まずはじめに、各プラットフォームのシステムフォントを知る必要があります。システムフォントとは、フォントを明示的に指定しない場合にデフォルトで利用される OS に搭載されたフォントで、プラットフォームごとに異なります。モバイルアプリ開発に携わっている方にとっては基礎知識でもありますので、不要でしたら読み飛ばしてください。iOS/Android のシステムフォントをまとめると以下の表となります。

プラットフォーム 英数字 日本語
iOS San Francisco[1] Hiragino Sans
Android Roboto Noto Sans CJK

iOS や Android のモバイル製品の場合、英数字と日本語が混在するテキストでは英数字用のフォントと日本語用のフォントを組み合わせて表示します。また、異なる種類のフォントを単に組み合わせて横並びにするだけでは、バウンディングボックス や基準の高さが異なるためアンバランスになってしまいます。これを解消するために、OS 側でフォントサイズやカーニング等の微調整がなされています。
普段の日常の中でスマートフォンを触っているだけではあまり気づかないかもしれませんが、こういった細かい配慮が OS 側でなされているおかげで、私たちユーザーは快適に文字を読むことができているわけですね。

iOS & iPad OS & macOS etc…

iOS に限らず Apple プラットフォーム上のフォントには、San Francisco フォントが使われます。San Francisco フォントの中にも iOS や iPad OS で利用される SF Pro や、watchOS などの狭い領域でも視認性高く表示できる SF Compact、等幅な SF Mono などがあり、これらのフォントは Apple のサイトからダウンロードできます。ちなみに、Apple の Web サイトや資料で使われている日本語フォントの SF Pro JP も存在しますが、フォントファイルとして公開はされてませんので私たちが使うことはできません。
https://developer.apple.com/fonts/

San Francisco フォントの詳細については、usagimaru さんの以下の記事がオススメです。
https://qiita.com/usagimaru/items/da88c0a8793f23633c28

Android

Android は Material Design で構成され、デフォルトでは Roboto フォントが利用されます。Roboto フォントは世界中の言語を表現するために、3,300以上ものグリフが存在するようです。Roboto で表現できない言語(日本語など)については、Roboto と互換性がある Noto Sans ファミリーを代替フォントとして利用する仕組みとなっており、日本語では Noto Sans CJK(日本語用の Noto Sans)が使われているというわけですね。
https://m3.material.io/styles/typography/fonts

Windows

Windows の場合は前述の OS のように日本語と英数字でフォントを組みあわせて表示することはせず、OS の言語設定に連動してフォントが切り替わる仕組みとなっています。言語設定が英語の場合は Segoe UI が、日本語の場合は Yu Gothic UI が使用されます。

MaterialAppにおけるTypographyの役割

Typography クラスは Material Design で利用する TextTheme です。
https://api.flutter.dev/flutter/material/Typography-class.html

主に次の2つの役割(TextTheme)に分類されます:

[1]ファミリーやカラーの決定

  • プラットフォームに応じてフォントファミリーを決める役割
  • ライトテーマ・ダークテーマに応じてテキストカラーを決める役割
  • blackMountainView / whiteCupertino etc..

[2]スクリプトカテゴリの決定

  • 言語に応じてフォントサイズやウェイトを調整する役割
  • englishLike / tall / dense

以降、順に見ていきます。

1.ファミリーやカラーの決定

前述の システムフォント の通り、プラットフォームによって標準で搭載されているフォント(システムフォント)は異なります。ご存じの通り、Flutter は モバイルに限らないマルチプラットフォーム であり、実行環境が多様です。多様なプラットフォーム上で、適切なテキスト表示を実現するための指定が1つ目の TextTheme の役割です。

TextTheme

以下2つの役割をどう表現しているかをコードで見ていきます。

プラットフォームに応じてフォントファミリーを決める役割

以下は、blackMountainView という TextTheme の定義です。中身を見ると bodyMedium などの Material Design におけるトークンが列挙されており、fontFamily や color が指定されていることがわかります。blackMountainView は Android や Fuchsia OS のデフォルトフォントとして利用されるため(後述)、Roboto フォントが指定されています。同様に iOS で利用される blackCupertino などが存在します。

ライトテーマ・ダークテーマに応じてテキストカラーを決める役割

また、blackMountainView とほぼ同じで、color だけが異なる whiteMountainView も存在し、これらによってライトテーマ・ダークテーマのカラーを差し替えられるようになっています。

typography.dart
// ref. https://github.com/flutter/flutter/blob/19c5fc50d966b4e2ad71818bec6f5435f44db844/packages/flutter/lib/src/material/typography.dart#L369-L385
static const TextTheme blackMountainView = TextTheme(
  displayLarge: TextStyle(debugLabel: 'blackMountainView displayLarge', fontFamily: 'Roboto', color: Colors.black54, decoration: TextDecoration.none),
  displayMedium: TextStyle(debugLabel: 'blackMountainView displayMedium', fontFamily: 'Roboto', color: Colors.black54, decoration: TextDecoration.none),
  displaySmall: TextStyle(debugLabel: 'blackMountainView displaySmall', fontFamily: 'Roboto', color: Colors.black54, decoration: TextDecoration.none),
  headlineLarge: TextStyle(debugLabel: 'blackMountainView headlineLarge', fontFamily: 'Roboto', color: Colors.black54, decoration: TextDecoration.none),
  headlineMedium: TextStyle(debugLabel: 'blackMountainView headlineMedium', fontFamily: 'Roboto', color: Colors.black54, decoration: TextDecoration.none),
  headlineSmall: TextStyle(debugLabel: 'blackMountainView headlineSmall', fontFamily: 'Roboto', color: Colors.black87, decoration: TextDecoration.none),
  titleLarge: TextStyle(debugLabel: 'blackMountainView titleLarge', fontFamily: 'Roboto', color: Colors.black87, decoration: TextDecoration.none),
  titleMedium: TextStyle(debugLabel: 'blackMountainView titleMedium', fontFamily: 'Roboto', color: Colors.black87, decoration: TextDecoration.none),
  titleSmall: TextStyle(debugLabel: 'blackMountainView titleSmall', fontFamily: 'Roboto', color: Colors.black, decoration: TextDecoration.none),
  bodyLarge: TextStyle(debugLabel: 'blackMountainView bodyLarge', fontFamily: 'Roboto', color: Colors.black87, decoration: TextDecoration.none),
  bodyMedium: TextStyle(debugLabel: 'blackMountainView bodyMedium', fontFamily: 'Roboto', color: Colors.black87, decoration: TextDecoration.none),
  bodySmall: TextStyle(debugLabel: 'blackMountainView bodySmall', fontFamily: 'Roboto', color: Colors.black54, decoration: TextDecoration.none),
  labelLarge: TextStyle(debugLabel: 'blackMountainView labelLarge', fontFamily: 'Roboto', color: Colors.black87, decoration: TextDecoration.none),
  labelMedium: TextStyle(debugLabel: 'blackMountainView labelMedium', fontFamily: 'Roboto', color: Colors.black, decoration: TextDecoration.none),
  labelSmall: TextStyle(debugLabel: 'blackMountainView labelSmall', fontFamily: 'Roboto', color: Colors.black, decoration: TextDecoration.none),
);

命名を簡単にまとめると以下です。

  • どのフォントファミリーを利用するか
    • xxxMountainView, xxxCupertino etc..
  • ライトテーマダークテーマどちらのカラーを利用するか
    • blackXxx, whiteXxx

Typographyのプラットフォームごとの指定

前述の、TextTheme でプラットフォーム用のフォントファミリーやカラーを指定できることがわかりました。以下は、どの世代の Material Design を利用するかを決定した後に呼ばれるプラットフォーム分岐のコードです。見やすいように関連箇所のみに抜粋しています。iOS と macOS では異なる TextTheme が指定され、逆に Android と Fuchsia では同じ TextTheme が指定されていることがわかります。

プラットフォーム Textheme Font Family
iOS Cupertino SF UI
android,fuchsia MountainView Roboto
Windows Redmond Segoe UI
macOS RedwoodCity AppleSystemUIFont(SF Pro)
linux Helsinki Roboto[2]
typography.dart
  factory Typography._withPlatform(
    TargetPlatform? platform,
    TextTheme? black,
    TextTheme? white,
  ) {
    switch (platform) {
      case TargetPlatform.iOS:
        black ??= blackCupertino;
        white ??= whiteCupertino;
        break;
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
        black ??= blackMountainView;
        white ??= whiteMountainView;
        break;
      case TargetPlatform.windows:
        black ??= blackRedmond;
        white ??= whiteRedmond;
        break;
      case TargetPlatform.macOS:
        black ??= blackRedwoodCity;
        white ??= whiteRedwoodCity;
        break;
      case TargetPlatform.linux:
        black ??= blackHelsinki;
        white ??= whiteHelsinki;
        break;
      case null:
        break;
    }
  }

このようにして、各プラットフォームごとに適切なフォントファミリー・カラーを持つ TextTheme を分岐していることがわかります。

Material DesignのiOSフォントはSF UI

TargetPlatform.iOS で指定されている blackCupertino/whiteCupertino の定義を覗いていただくとわかりますが、fontFamily の指定が、SF UI フォントになっています。SF UI フォントは SF Pro フォントの1つ前の古いフォントという位置づけです。具体的にどの辺りが変わったのかは把握できておりませんが、基本的には SF Pro を利用することが推奨されているはずです。

San Francisco 書体は細かなアップデートを重ねていて、2017年6月の WWDC 2017 (macOS High Sierra / iOS 11) を境に SF UI が SF Pro に置き換わったようです。なので現在では SF UI は旧版という扱い
https://qiita.com/usagimaru/items/da88c0a8793f23633c28#sf-pro-vs-sf-ui

つまり、Apple 製品は本来 SF Pro が利用されるはずですが、Flutter の Material Design における指定は古いままとなっているようです。
コメントは適宜ついていますが、2年前に作成されたイシューは依然開かれたままです。
https://github.com/flutter/flutter/issues/63507#issuecomment-1295089631

あまり重要視されていなかったり、iOS の描画結果全体に影響を与える変更となるため身長になっているのかもしれません。今後、気がついたときには SF Pro に書き換わっている可能性もありますが、執筆時点では SF UI となっています。

ちなみに、CupertinoTheme class - cupertino library - Dart API ではしっかり SF Pro 指定されているので、厳密に Apple 製品と同様な UI を再現したい場合は Cupertino を使うのが良いです。とはいえ、この目的のためだけに fontFamily をラッピングするメソッド用意したり、CupertinoApp を選択する(=MaterialApp を使わない)ことは思うので、この辺りが1つの妥協点なのかなとも思います。
https://github.com/flutter/flutter/blob/19c5fc50d966b4e2ad71818bec6f5435f44db844/packages/flutter/lib/src/cupertino/text_theme.dart#L17

2.スクリプトカテゴリの決定

スクリプトカテゴリとは、多言語の文章でも複数の書体を使って自然に表示ができるような仕組みです。以下の3つに分類され、それぞれ対象とする言語が決まっています。

English-like: アメリカ、ヨーロッパ、およびアフリカなどのラテンアルファベット。
Tall: アラビア語、ヒンディー語、ベトナム語などの南アジア、東南アジアおよび中東の比較的背の高い言語。
Dense: 中国語、日本語、韓国語など、密集度の高い言語(漢字がイメージしやすいですね)。

これだけ並べられても普段、日本語・英語辺りしか利用しない私たちにとってはイメージしづらいですね。
Flutter では englishLike がデフォルトで使われており、普段の開発で意識することは多くありません。一方で多言語対応されたアプリケーションでは、英数字と日本語で English-like と Dense が併用して使われることで、1文の中で高さが崩れず自然に読めるよう調整されているようです。

Generally speaking, font sizes for ScriptCategory.tall and ScriptCategory.dense scripts - for text styles that are smaller than the title style - are one unit larger than they are for ScriptCategory.englishLike scripts.
https://api.flutter.dev/flutter/material/MaterialLocalizations/scriptCategory.html

Flutter、無指定のTextはenglishLikeのbody1でフォントサイズ14だけど、多言語対応して日本語表示だとdenseが採用されて1ポイント大きくなって15になる(英数字含めて)。
あと、textBaselineもalphabeticからideographicに変わる。
https://twitter.com/_mono/status/1222779804832825344

Flutter と Material Design のドキュメントはそれぞれ以下です。Flutter では ScriptCategory、Material Design では Launguage options という表記になっていましたが、ややこしいので本記事では Flutter 側に合わせました。また、Material Design 3のドキュメントとしては存在しなかったため、Material Design 2へのリンクとなっています。
https://api.flutter.dev/flutter/material/MaterialLocalizations/scriptCategory.html

https://m2.material.io/design/typography/language-support.html#language-considerations

TextTheme

スクリプトカテゴリの意義がわかったところで、実際のコードを見ていきましょう。ここでは冒頭で述べた「言語に応じてフォントサイズやウェイトを調整する役割」を担っています。

たとえば、useMaterial3 を true にした場合(M3[3]指定)に、デフォルトで指定される englishLike は、以下となっています(割愛しますが talldense も同様に定義されています)。

typography.dart
// ref. https://github.com/flutter/flutter/blob/b8f7f1f9869bb2d116aa6a70dbeac61000b52849/packages/flutter/lib/src/material/typography.dart#L744
static const TextTheme englishLike = TextTheme(
  displayLarge: TextStyle(debugLabel: 'englishLike displayLarge 2021', inherit: false, fontSize: 57.0, fontWeight: FontWeight.w400, letterSpacing: -0.25, height: 1.12, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  displayMedium: TextStyle(debugLabel: 'englishLike displayMedium 2021', inherit: false, fontSize: 45.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.16, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  displaySmall: TextStyle(debugLabel: 'englishLike displaySmall 2021', inherit: false, fontSize: 36.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.22, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  headlineLarge: TextStyle(debugLabel: 'englishLike headlineLarge 2021', inherit: false, fontSize: 32.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.25, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  headlineMedium: TextStyle(debugLabel: 'englishLike headlineMedium 2021', inherit: false, fontSize: 28.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.29, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  headlineSmall: TextStyle(debugLabel: 'englishLike headlineSmall 2021', inherit: false, fontSize: 24.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.33, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  titleLarge: TextStyle(debugLabel: 'englishLike titleLarge 2021', inherit: false, fontSize: 22.0, fontWeight: FontWeight.w400, letterSpacing: 0.0, height: 1.27, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  titleMedium: TextStyle(debugLabel: 'englishLike titleMedium 2021', inherit: false, fontSize: 16.0, fontWeight: FontWeight.w500, letterSpacing: 0.15, height: 1.50, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  titleSmall: TextStyle(debugLabel: 'englishLike titleSmall 2021', inherit: false, fontSize: 14.0, fontWeight: FontWeight.w500, letterSpacing: 0.1, height: 1.43, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  labelLarge: TextStyle(debugLabel: 'englishLike labelLarge 2021', inherit: false, fontSize: 14.0, fontWeight: FontWeight.w500, letterSpacing: 0.1, height: 1.43, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  labelMedium: TextStyle(debugLabel: 'englishLike labelMedium 2021', inherit: false, fontSize: 12.0, fontWeight: FontWeight.w500, letterSpacing: 0.5, height: 1.33, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  labelSmall: TextStyle(debugLabel: 'englishLike labelSmall 2021', inherit: false, fontSize: 11.0, fontWeight: FontWeight.w500, letterSpacing: 0.5, height: 1.45, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  bodyLarge: TextStyle(debugLabel: 'englishLike bodyLarge 2021', inherit: false, fontSize: 16.0, fontWeight: FontWeight.w400, letterSpacing: 0.5, height: 1.50, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  bodyMedium: TextStyle(debugLabel: 'englishLike bodyMedium 2021', inherit: false, fontSize: 14.0, fontWeight: FontWeight.w400, letterSpacing: 0.25, height: 1.43, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
  bodySmall: TextStyle(debugLabel: 'englishLike bodySmall 2021', inherit: false, fontSize: 12.0, fontWeight: FontWeight.w400, letterSpacing: 0.4, height: 1.33, textBaseline: TextBaseline.alphabetic, leadingDistribution: TextLeadingDistribution.even),
);

M3のドキュメントと見比べてみると、ドキュメント通りにウェイトやトラッキングが実装されていることがわかりますね。
https://m3.material.io/styles/typography/type-scale-tokens

また、前述のコードは M3に対応した englishLike となっているため、debugLabel2021 の表記があります。同様に Material Design の前世代である M1,M2も同様な形で englishLike2018,dense2014 などと定義されています。

初見だと、地味にわかりづらい 2014/2018/2021 の表記ですが、それぞれ M1/M2/M3に対応しており、それぞれの世代が登場した年で命名されています。Material Design の歴史については、以前 SpeakerDeck にあげているので興味がありましたらご覧ください。
https://speakerdeck.com/htsuruo/material-design-3-texue-huhasonarunatesain?slide=15

Display largeの実装が一部乖離していた話

Material Design 3のドキュメントと見比べてみると、ドキュメント通りにウェイトやトラッキングが実装されていることがわかりますね。

これは余談ですが、M3の englishLike における Display large の数値がドキュメントと乖離しています。実装側のミスかと思ったのですが、どうやらドキュメント側が間違っているらしく修正されるそうです(されない)。

https://github.com/flutter/flutter/issues/114886

Typography.materialで利用する

最後です。1.ファミリーやカラーの決定2.スクリプトカテゴリの決定 で内部構造を見ていきましたが、実際はあまり意識すること無くこれらをマージした TextTheme を扱うことができます。

Typography の factory コンストラクタとして、material2014/material2018/material2021 が用意されていますので、利用したい世代の Material Design を指定します。執筆時点の最新 Flutter バージョン 3.3.10 では、まだ Typography.material2014 がデフォルトで利用されます。useMaterial3 フラグを true にするとデフォルトは Typography.material2021 となります。

main.dart
MaterialApp(
  theme: ThemeData.from(
    useMaterial3: true,
    colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
  ),
);
theme_data.dart
// ref. https://github.com/flutter/flutter/blob/135454af32477f815a7525073027a3ff9eff1bfd/packages/flutter/lib/src/material/theme_data.dart#L521
typography ??= useMaterial3 ? Typography.material2021(platform: platform) : Typography.material2014

Typography.material2021 では englishLike2021/tall2021/dense2021 のいずれかが言語に応じて利用されます。

https://api.flutter.dev/flutter/material/Typography/Typography.material2021.html

TargetPlatform は実行環境によって変わり、black/white は Brightness によって切り替えります。

theme_data.dart
TextTheme defaultTextTheme = isDark ? typography.white : typography.black;

Theme.of(context) で参照する際も theme.typography.geometryThemeFor(category) によって、スクリプトカテゴリが指定された上でローカライズされます。

theme.dart
static ThemeData of(BuildContext context) {
    final _InheritedTheme? inheritedTheme = context.dependOnInheritedWidgetOfExactType<_InheritedTheme>();
    final MaterialLocalizations? localizations = Localizations.of<MaterialLocalizations>(context, MaterialLocalizations);
    final ScriptCategory category = localizations?.scriptCategory ?? ScriptCategory.englishLike;
    final ThemeData theme = inheritedTheme?.theme.data ?? _kFallbackTheme;
    return ThemeData.localize(theme, theme.typography.geometryThemeFor(category));
}

以上のように、実際は ThemeData を指定しているだけですが、内部では Typography・TextTheme が適宜切り替わっていることがわかりました。

まとめ

今回は、MaterialApp の Typography・TextTheme を中心に、Material Design のドキュメントと見比べながらその内部構造を見ていきました。各プラットフォームのシステムフォントを理解した上で、Flutter がどのようにプラットフォームを分岐しながら適切なテキスト描画を実現しているのか、その理解が少しでも深まったのかなと思います。それにしても、Material Design という完成されたデザインシステムはもちろん、これをあまり意識せずに開発者が簡単に享受できるような実装になっている Flutter Framework も改めてすごいですね。
Material Design のメリットを適切に享受するために、Typography・TextTheme の理解は外せないと思いますので、みなさまの何かのお役に立つ記事となれれば幸いです。

参考

脚注
  1. San Francisco フォントファミリーの中で、20pt を閾値に大きい文字は SF Display が、小さい文字には SF Text が使われています。 ↩︎

  2. フォールバックとして、'Ubuntu' / 'Cantarell' /'DejaVu Sans' / 'Liberation Sans' / 'Arial'が指定されています。 ↩︎

  3. Material Design の世代を意味する文脈では M1・M2・M3と表記しています。 ↩︎

Discussion