🌗

Flutter ダークモードで「色が消える」バグを根絶する — Material 3 ColorScheme 完全移行ガイド

に公開

Flutter ダークモードで色が消える原因は、ColorScheme を経由しない静的カラー参照です。本記事では Material 3 の ColorScheme へ完全移行し、ライト/ダーク両テーマで破綻しない色設計を実現する方法を解説します。

ダークモードで色が消えた日

あるアプリの開発中、ダークモード対応を始めたときのことです。システム設定をダークに切り替えた瞬間、画面のテキストやアイコンが背景に溶け込んで「消えた」ように見えました。

原因を調べると、アプリ全体で AppColors.primaryAppColors.textColor といった静的な色定数を直接参照していました。これらの値はライトモード前提のハードコードされた色。ダークモードに切り替えても値は変わらず、濃い背景に濃いテキストが重なり、視認不能になっていたのです。

具体的に発生した不具合は 4 種類ありました。

  1. テーマ追従不全: システム設定をダークモードに切り替えても、ライトモードの色がそのまま残る
  2. コントラスト不足: 濃灰色の背景にハードコードされた黒テキストが重なり、読めない
  3. プレースホルダーの違和感: 画像読み込み中の明るい背景色がダーク UI 上で浮いて見える
  4. 未定義のセマンティックカラー: バッジや価格表示の色がダークモードで未定義のまま

この問題の根本原因と解決策を、順を追って説明します。

なぜ AppColors.primary は危険なのか?

静的カラー参照とテーマ参照の違いを理解することが、ダークモード対応の第一歩です。

次の図は、Flutter のテーマシステムにおける色の流れを示しています。正しいパス(緑)と、静的参照による危険なパス(赤)を対比しています。

Theme.of(context).colorScheme を経由する正しいパスでは、OS のテーマ設定がウィジェットまで伝搬します。一方、AppColors の静的参照はテーマシステムを完全にバイパスするため、ダークモードに切り替えても色が変わりません。

静的参照(テーマに追従しない)

// NG: どのテーマでも常に同じ色が返る
class AppColors {
  static const Color primary = Color(0xFFE91E63); // ピンク
  static const Color textColor = Color(0xFF212121); // ほぼ黒
}

Text(
  'サンプル再生',
  style: TextStyle(color: AppColors.primary), // 常にライトモードの色
)

この書き方では、AppColors.primaryconst で固定されたピンク色です。ダークモードに切り替えても値は 0xFFE91E63 のまま変わりません。

テーマ参照(テーマに追従する)

// OK: 現在のテーマに応じた色が返る
final colorScheme = Theme.of(context).colorScheme;

Text(
  'サンプル再生',
  style: TextStyle(color: colorScheme.primary), // テーマに応じて自動で切り替わる
)

Theme.of(context).colorScheme を経由すれば、Flutter のテーマシステムがライト/ダークを自動で判定し、適切な色を返します。

ColorScheme 設計:Material 3 のスロットを正しく使う

Material 3 の ColorScheme には 40 以上のカラースロットが定義されています。ここでは実務で特に重要なものを整理します。

基本スロット

スロット 用途 ライト例 ダーク例
primary 主要なアクション・強調要素 深いピンク #B0004E 淡いピンク #F48FB1
onPrimary primary の上に載るテキスト・アイコン #FFFFFF 濃いピンク #650033
surface カード・シートなどの背景 ほぼ白 #FFFBFF 深いグレー #201A1B
onSurface surface の上に載るテキスト ほぼ黒 #201A1B 明るいグレー #ECE0E0
error エラー状態の色 #BA1A1A 明るい赤 #FFB4AB
onError error の上に載るテキスト #FFFFFF 深い赤 #690005

surfaceContainer 系(Material 3 の階層表現)

Flutter 3.19.0(2024年2月)で追加された surfaceContainer 系スロットは、Material 3 で「影」の代わりに「色の明るさ」で UI の階層を表現するための仕組みです。

スロット 階層 主な用途
surfaceContainerLowest 最も低い 最背面のコンテンツ
surfaceContainerLow 低い カードのデフォルト背景
surfaceContainer 中間 一般的なコンテナ
surfaceContainerHigh 高い 検索バー・ダイアログ
surfaceContainerHighest 最も高い テキストフィールド・プレースホルダー

階層が高いほど色が濃く(ライトモード)または明るく(ダークモード)なり、ユーザーに「手前にある」という感覚を与えます。

AppColors と AppTheme の実装パターン

実際のプロジェクトで使える実装パターンを示します。

ColorScheme ファクトリの定義

lib/theme/app_colors.dart
class AppColors {
  AppColors._();

  // ブランドカラー(シードカラーとして使用)
  static const Color brandPink = Color(0xFFE91E63);

  // ダークモード用に彩度を落としたカラー
  static const Color brandPinkDark = Color(0xFFF48FB1);

  // ライトモード用 ColorScheme
  static ColorScheme lightColorScheme = ColorScheme.fromSeed(
    seedColor: brandPink,
    brightness: Brightness.light,
  );

  // ダークモード用 ColorScheme
  static ColorScheme darkColorScheme = ColorScheme.fromSeed(
    seedColor: brandPink,
    brightness: Brightness.dark,
  ).copyWith(
    primary: brandPinkDark, // ダークモードでは彩度を落とす
  );
}

ColorScheme.fromSeed を使うと、シードカラーから Material 3 のアルゴリズムで調和の取れたパレット全体が自動生成されます。ダークモードでは brightness: Brightness.darkfromSeed に直接渡してください。

ThemeData の組み立て

lib/theme/app_theme.dart
class AppTheme {
  AppTheme._();

  static ThemeData light = ThemeData(
    useMaterial3: true,
    colorScheme: AppColors.lightColorScheme,
    // colorScheme を設定すれば、AppBar や Card などの
    // マテリアルウィジェットは自動的にスロットの色を使う
  );

  static ThemeData dark = ThemeData(
    useMaterial3: true,
    colorScheme: AppColors.darkColorScheme,
  );
}

MaterialApp での適用

lib/main.dart
MaterialApp(
  theme: AppTheme.light,
  darkTheme: AppTheme.dark,
  themeMode: ThemeMode.system, // システム設定に追従
  // ...
)

これだけで、システムのダークモード設定に応じてテーマが自動で切り替わります。

デザインシステムとの統合について

テーマ設計をアプリ全体のデザインシステムとして体系化する方法については、Flutter クリーンアーキテクチャにおけるデザインシステム設計ガイドで詳しく解説しています。ColorScheme を基盤としたトークン管理やコンポーネント設計と組み合わせることで、より堅牢な色管理が実現できます。

特殊ケース:画像プレースホルダーとカードへの対応

ColorScheme の基本を押さえたら、実務で見落としやすい特殊ケースに対応しましょう。

画像プレースホルダー

画像の読み込み中に表示するプレースホルダーは、ハードコードされた色が残りやすいポイントです。

// NG: ライトモードの色がダーク UI 上で浮いて見える
placeholder: (context, url) => Container(
  color: Colors.grey[200],
),

// OK: テーマに応じた適切な色
placeholder: (context, url) => Container(
  color: Theme.of(context).colorScheme.surfaceContainerHighest,
),

surfaceContainerHighest は階層が最も高いスロットで、プレースホルダーのように「一時的にコンテンツの代わりに表示される領域」に適しています。

カードの階層表現

Material 3 では、カードの重なりを影(elevation)ではなく色で表現します。

// 通常のカード(デフォルトで surfaceContainerLow が適用される)
Card(
  child: ListTile(title: Text('通常のカード')),
)

// 強調したいカード(より高い階層の色を明示的に指定)
Card(
  color: Theme.of(context).colorScheme.surfaceContainerHigh,
  child: ListTile(title: Text('強調カード')),
)

ネストしたカードや、モーダル内のカードなど、階層が深くなる場面で surfaceContainer 系のスロットを使い分けると、ライト/ダーク両方で自然な奥行き感が表現できます。

自動テストでテーマ視認性を守る

コードレビューだけでは、テーマの問題を完全に防ぐことはできません。ウィジェットテストでライト/ダーク両方を検証する仕組みを導入しましょう。

ウィジェットテストでのダークモード検証

test/theme/dark_mode_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  for (final brightness in Brightness.values) {
    testWidgets('SampleCard は $brightness モードで正しく表示される', (tester) async {
      final colorScheme = ColorScheme.fromSeed(
        seedColor: const Color(0xFFE91E63),
        brightness: brightness,
      );

      await tester.pumpWidget(
        MaterialApp(
          theme: ThemeData(useMaterial3: true, colorScheme: colorScheme),
          home: const Scaffold(body: SampleCard()),
        ),
      );

      // テキストが存在することを確認
      expect(find.text('サンプル再生'), findsOneWidget);

      // テキストの色がテーマの onSurface であることを確認
      final textWidget = tester.widget<Text>(find.text('サンプル再生'));
      final textColor = textWidget.style?.color;
      expect(textColor, equals(colorScheme.onSurface));
    });
  }
}

このテストは Brightness.lightBrightness.dark の両方で自動実行されます。テーマ参照を忘れてハードコードした色を使っていれば、ダークモード側のアサーションで失敗します。

E2E テストでの補完

ウィジェットテストではピクセルレベルの視認性は検証できません。Maestro などの E2E テストツールを使えば、ダークモードのスクリーンショットを自動取得し、回帰テストとして活用できます。CI に組み込むことで、リリース前のビジュアル検証も自動化できます。

AI エージェント・Lint への制約追加

テスト以外にも、開発プロセスの上流でハードコードされた色の使用を防ぐ仕組みを導入できます。

CLAUDE.md / AI コーディングルールへの追加

AI コーディングエージェント(Claude Code や Gemini など)を使っている場合、プロジェクトのルールファイルに以下を追記しておくと効果的です。

## テーマルール
- 色の指定は必ず `Theme.of(context).colorScheme` 経由で行う
- `AppColors.xxx``Colors.xxx` の直接参照は禁止
- Material 3 のカラースロット名(`primary`, `surface`, `onSurface` 等)を使う
- `background``onBackground``surfaceVariant` は非推奨。代替スロットを使うこと

筆者の経験では、AI エージェントにこのルールを明記するだけで、新規コードでの静的カラー参照がほぼゼロになりました。再発防止策としては費用対効果が極めて高い方法です。

analysis_options.yaml でのカスタム Lint

avoid_hard_coded_colors のようなカスタム Lint ルールを custom_lint パッケージを使って自作すればColors.redColor(0xFF...) の直接使用を CI で検知できます。custom_lint 自体はルールを実装するためのフレームワークであり、インストールするだけでルールが有効になるわけではありません。

analysis_options.yaml
analyzer:
  plugins:
    - custom_lint

カスタムルールのパッケージを作成し、analysis_options.yaml で有効にすることで、チーム全体でのハードコードカラー使用を静的解析で防止できます。

まとめ

本記事で解説した内容を振り返ります。

冒頭で紹介した「色が消えた」問題も、ColorScheme への移行後はライト・ダーク両モードで意図通りの色が表示されるようになりました。テキストが背景に溶け込むことも、プレースホルダーが浮いて見えることもありません。

Material 3 の ColorScheme は一見スロットが多くて複雑に感じますが、基本の primary / surface / onSurfacesurfaceContainer 系を押さえれば、ほとんどのユースケースはカバーできます。まずは既存コードの AppColors.xxx を1つずつ colorScheme.xxx に置き換えるところから始めてみてください。

最後までお読みいただきありがとうございました。

Discussion