見た目と文言を一箇所に集める
色や文字サイズ、そして画面に出る文言は、書いた場所に散らばると直せなくなる。ThemeData と ColorScheme、ThemeExtension による独自トークン、そして gen-l10n による多言語化まで、アプリ全体の一貫性を保つ仕組みを整理する。
ここまでは、一つの画面をどう作り、画面と画面をどうつなぐかを見てきました。今回は視点をもう一段 上げて、アプリ全体を横断するものを扱います。具体的には見た目と文言です。
この二つは、油断すると同じ壊れ方をします。Color(0xFF2A6DF4) や '保存' を書いた場所に置いた
まま画面が増えていき、ブランド色を変えたい・英語対応したいとなった時点で、すべての画面を
探して回ることになる。どちらも解き方は同じで、値を画面から追い出して一箇所に集め、
BuildContext 経由で受け取る——連載 I で見た Theme.of(context) の構造そのものです。
§ 01THEME見た目を一箇所に集める
Flutter では、アプリ全体の見た目を ThemeData として MaterialApp に渡します。
MaterialApp.router(theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF2A6DF4)),textTheme: const TextTheme(titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),bodyMedium: TextStyle(fontSize: 14, height: 1.7),),),routerConfig: router,)
ここで MaterialApp.router を使っているのは、ルーティングを go_router に委ねているからです
(前回参照)。テーマや文言の渡し方は、home: を渡す従来型の MaterialApp でも変わりません。
各画面はこれを Theme.of(context) で受け取ります。
final theme = Theme.of(context);Text('在庫', style: theme.textTheme.titleMedium);Container(color: theme.colorScheme.surface);
Theme.of(context) が context の位置から祖先をたどって最も近い Theme を探すことは、連載 I の
LOG-002 で見たとおりです。つまり部分的に上書きすることもできます——ある画面だけ暗い配色に
したければ、その部分を Theme(data: ...) で包めば、その下だけ別のテーマになります。
§ 02COLORSCHEME色を「役割」で選ぶ
ColorScheme は、色を役割の名前で持ちます。ここが発想の切り替えどころです。「青」ではなく
「primary」、「薄いグレー」ではなく「surface」と呼ぶ。
主な役割は次のとおりです。
| 役割 | 使いどころ |
|---|---|
primary | 主要なボタン、強調したい要素 |
secondary | 補助的な強調 |
surface | カードやシートなど、面の背景 |
error | エラー表示 |
onPrimary / onSurface | その色の上に載せる文字やアイコンの色 |
onXxx という命名がわかりにくいのですが、「primary の上に置くもの(on primary)の色」という
意味です。背景に primary を敷いたら文字は onPrimary——このペアで使えば、配色を変えても文字が
読めなくなりません。
ColorScheme.fromSeed() は、一色(seed)から調和のとれた配色一式を自動生成します1ColorScheme.fromSeed は Material 3 の配色アルゴリズムで、seed 色から明度・彩度の異なる一式を導出する。ブランド色をそのまま primary に使うとは限らない点に注意。。
すべての役割を手で決める必要はなく、ブランド色を一つ渡すところから始められます。
役割で考える利点は、ダークテーマがほぼ自動で成立することです。「この文字は #333」と書いて
あると暗い背景で読めなくなりますが、「この文字は onSurface」と書いてあれば、テーマ側が適切な
値を返します。
§ 03DARK2 つのテーマを用意する
ダークテーマは、MaterialApp に 2 つ目の ThemeData を渡すだけです。
MaterialApp.router(theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: brandSeed),),darkTheme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: brandSeed,brightness: Brightness.dark,),),themeMode: ThemeMode.system, // 端末の設定に従うrouterConfig: router,)
themeMode は system / light / dark の三択です。既定の system は端末の設定に追従します。
アプリ内で切り替えたいなら、この値を状態として持てばよく——連載 I で見た Provider の出番です。
final themeModeProvider =NotifierProvider<ThemeModeNotifier, ThemeMode>(ThemeModeNotifier.new);class ThemeModeNotifier extends Notifier<ThemeMode> {@overrideThemeMode build() => ThemeMode.system;void select(ThemeMode mode) => state = mode;}
注意したいのは、ダーク対応は「色を反転すること」ではない点です。暗い面では影が見えないので
面の明度差で階層を表す、彩度の高い色はまぶしいので少し落とす——といった調整が要ります。
fromSeed に brightness を渡すのは、その調整を任せる指定です。
§ 04TOKENS標準テーマにない値を持つ
アプリを作っていると、ThemeData に用意されていない値が必ず出てきます。カードの角丸、画面の
左右余白、独自のアクセント色。これらを各画面に直書きすると、最初の問題に逆戻りです。
Flutter には ThemeExtension という拡張の口があります。自前のトークン群をテーマに載せられます。
class AppTokens extends ThemeExtension<AppTokens> {const AppTokens({required this.gutter, required this.cardRadius});final double gutter;final double cardRadius;@overrideAppTokens copyWith({double? gutter, double? cardRadius}) => AppTokens(gutter: gutter ?? this.gutter,cardRadius: cardRadius ?? this.cardRadius,);@overrideAppTokens lerp(AppTokens? other, double t) {if (other == null) return this;return AppTokens(gutter: lerpDouble(gutter, other.gutter, t)!,cardRadius: lerpDouble(cardRadius, other.cardRadius, t)!,);}}
ThemeData に登録し、Theme.of(context).extension<AppTokens>() で取り出します。
ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: brandSeed),extensions: const [AppTokens(gutter: 16, cardRadius: 12)],)
copyWith と lerp の実装が要求されるのは、テーマ切り替え時のアニメーションのためです
2lerp は 2 つのテーマ間を補間するための実装。テーマを切り替えたとき、色や寸法が滑らかに変化するのはこの補間による。。単なる定数置き場でよければ static const を並べたクラスでも構いませんが、ライト/ダークで
値を変えたいなら ThemeExtension を選びます。
§ 05ASSETSフォントと画像を登録する
見た目を決めるもう一方の材料が、フォントと画像です。どちらも pubspec.yaml への登録が要ります。
flutter:assets:- assets/images/fonts:- family: NotoSansJPfonts:- asset: assets/fonts/NotoSansJP-Regular.ttf- asset: assets/fonts/NotoSansJP-Bold.ttfweight: 700
登録したフォントは ThemeData(fontFamily: 'NotoSansJP') でアプリ全体に適用できます。ここでも
各 Text に指定して回らないのが要点です。
画像は端末の画素密度に応じた出し分けがあり、assets/images/2.0x/logo.png のようにサブディレクトリ
を置いておけば自動的に選ばれます3assets/images/logo.png を宣言しておけば、同階層の 2.0x/ 3.0x/ サブディレクトリにある同名ファイルが端末の画素密度に応じて選択される。。なお SVG は Flutter 標準では扱えないため、
flutter_svg のようなパッケージが必要です。
§ 06L10N文言をコードから追い出す
ここから文言です。仕組みは色とまったく同じ——書いた場所から追い出して一箇所に集め、
context から受け取る。Flutter では gen-l10n がこれを担い、翻訳ファイルから Dart コードを
生成します。
まず設定ファイルを置きます。
arb-dir: lib/l10ntemplate-arb-file: app_ja.arboutput-localization-file: app_localizations.dart
翻訳は言語ごとの ARB ファイル(JSON 形式)に書きます。
{"@@locale": "ja","save": "保存","stockCount": "{count} 件の在庫","@stockCount": {"placeholders": {"count": { "type": "int" }}}}
flutter gen-l10n を実行すると AppLocalizations が生成され、MaterialApp に登録して使います。
MaterialApp.router(localizationsDelegates: AppLocalizations.localizationsDelegates,supportedLocales: AppLocalizations.supportedLocales,routerConfig: router,)
final l10n = AppLocalizations.of(context)!;Text(l10n.save);Text(l10n.stockCount(items.length));
AppLocalizations.of(context) が null を返しうるのは、localizationsDelegates の登録漏れという
設定ミスを型で表しているためです4生成される of が nullable なのは nullable-getter オプションが既定 true のため。l10n.yaml で nullable-getter: false にすると非 null で受け取れ、呼び出し側の ! が不要になる。。
文字列を自前で連結しない、というのがここでの鉄則です。'${count} 件の在庫' と書いてしまうと、
語順の違う言語で破綻します。数によって形が変わる言語のために、ARB には複数形の記法もあります。
{"stockCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}"}
日本語には複数形がないので不要ですが、英語では 1 item と 3 items を書き分ける必要がある——
言語ごとに必要な情報が違うので、その判断は翻訳ファイル側に置きます。
§ 07LOCALEどの言語で表示するか
表示言語は、既定では端末の言語設定を supportedLocales に照合して決まります。対応がなければ
リストの先頭が使われます。
アプリ内で切り替えるなら、MaterialApp の locale に値を渡します。テーマと同じく、その値を
Provider で持てば設定画面から変更できます。
MaterialApp.router(locale: ref.watch(localeProvider), // null なら端末設定に従うlocalizationsDelegates: AppLocalizations.localizationsDelegates,supportedLocales: AppLocalizations.supportedLocales,routerConfig: router,)
一点、言語コードだけでは足りない言語があります。中国語がその代表で、簡体字と繁体字は
言語コードが同じ zh です。区別するには script code を使います。
const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'); // 簡体字const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); // 繁体字
ARB ファイルもこれに合わせて app_zh_Hans.arb / app_zh_Hant.arb と分けます。
なお、日付や数値の書式は文言とは別の仕組みです。「2026/08/28」と「August 28, 2026」の違いは
翻訳ではなく書式化なので、intl パッケージの DateFormat / NumberFormat に任せます5DateFormat.yMMMd(locale) のように locale を渡すと、その言語の慣習に沿った書式で出力される。文言の翻訳とは別レイヤーの話。。
§ 08SUMMARY一貫性を保つ地図
- 色も文言も、書いた場所に散らばると直せなくなる。集めて
contextから受け取る - 色は役割で選ぶ。
primary/surfaceと、その上に載るonPrimary/onSurface ColorScheme.fromSeedは一色から配色一式を生成。brightnessでダーク版も作れる- 標準テーマにない値は
ThemeExtension。copyWithとlerpは切り替えアニメーション用 - フォントと画像は
pubspec.yamlに登録。密度別アセットは自動で選ばれる - 文言は ARB →
gen-l10n→AppLocalizations。文字列を自前で連結しない - 表示言語は端末設定が既定。切り替えるなら
MaterialApp.localeを状態として持つ
見た目と文言がそろい、アプリの表側は一通り完成しました。次回からは裏側——データに入ります。
まずは、そのデータを表現するモデルの作り方から。Freezed が何を生成しているのかを見ていきます。
- [1]
ColorScheme.fromSeedは Material 3 の配色アルゴリズムで、seed 色から明度・彩度の異なる一式を導出する。ブランド色をそのままprimaryに使うとは限らない点に注意。 ↩ - [2]
lerpは 2 つのテーマ間を補間するための実装。テーマを切り替えたとき、色や寸法が滑らかに変化するのはこの補間による。 ↩ - [3]
assets/images/logo.pngを宣言しておけば、同階層の2.0x/3.0x/サブディレクトリにある同名ファイルが端末の画素密度に応じて選択される。 ↩ - [4] 生成される
ofが nullable なのはnullable-getterオプションが既定trueのため。l10n.yamlでnullable-getter: falseにすると非 null で受け取れ、呼び出し側の!が不要になる。 ↩ - [5]
DateFormat.yMMMd(locale)のように locale を渡すと、その言語の慣習に沿った書式で出力される。文言の翻訳とは別レイヤーの話。 ↩