Flutter の primarySwatch は奥が深い

21 min read読了の目安(約19400字

こんにちはこんばんわ、すぎっと٩( ᐛ )وです
今日のテーマは primarySwatch です。

Flutter のアプリを flutter create my_app で作ると、まず目にすると思います。

以下の実装のように、 ThemeData に primarySwatch: Colors.blue が指定されていると思います。

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

今回はこれについて深掘りしてみようと思いました。
ちなみに、初期状態でアプリを実行するとこんな見た目です。
(この世で最も使用された Flutterアプリは、実はこのカウンターアプリなんですね! 知らんけど)

flutter_default

どうして primarySwatch を深掘りしようと思ったの?

初期のアプリで primarySwatch: Colors.blue と指定されていることに対して、 なぜ? と思ったからです。

と、いうのも実はこの部分の設定はなくても見た目は変わりません。

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        // primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

これでも、

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      // theme: ThemeData(
      //   primarySwatch: Colors.blue,
      // ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

これでも、

同じです。変わりません。

あってもなくても変わらない設定を Flutter チームが全 Flutter エンジニアのスタートラインとも言えるアプリに何の意図もなく実装するでしょうか?

何か意図があるだろう・・・

これがきっかけです。

ちなみに、 flutter create my_app で作られるアプリのテンプレートはこちらに実装されています。

https://github.com/flutter/flutter/blob/master/packages/flutter_tools/templates/app/lib/main.dart.tmpl

これを読むのも、それはそれで面白そうですね! 興味のある方はぜひ。

ThemeData の実装をみてみよう

こちらが ThemeData の定義です (どんっ)

はい、もう見る気なくなりました。

これに関しては API 仕様書をみましょう。

https://api.flutter.dev/flutter/material/ThemeData-class.html

と、いいつつ、以降は Flutter の実装も合わせてチェックしながら進めます。

OSS ですからね。せっかくなんでスーパーエンジニアたちの実装を見せてもらいましょう。

なお、ここからは一旦、 primarySwatch 以外は見えないと自己催眠の上、読み進めましょう。

まず、一部の切り出しですが、 ThemeData の factory (コンストラクタ) を見てみましょう。

factory コンストラクタについては dart のドキュメント をご参照ください。
ここではその観点で深掘りはしません。

  factory ThemeData({
    Brightness? brightness,
    VisualDensity? visualDensity,
    MaterialColor? primarySwatch,
    Color? primaryColor,
    Brightness? primaryColorBrightness,
    Color? primaryColorLight,
    Color? primaryColorDark,
    Color? accentColor,
    Brightness? accentColorBrightness,
    // つづく・・・

primarySwatch は MaterialColor というクラスで定義されていますね。

では、まずは MaterialColor から見ていきます。

MaterialColor をちゃんと理解しよう

MaterialColor について、調べたことはありますか?

https://api.flutter.dev/flutter/material/MaterialColor-class.html

MaterialColor クラスは単純な Color クラスとは異なり、 Material Design の Color System に準じて定義された カラーシステム です。

https://material.io/design/color/the-color-system.html

material_design_color_system
Material Design の ページより拝借

このカラーシステムでは、ベースとなる色と、明暗の異なる10色をセットにして取り扱います。 MaterialColor クラスでも同様の仕組みが以下のように実装されています。

class MaterialColor extends ColorSwatch<int> {
  /// Creates a color swatch with a variety of shades.
  ///
  /// The `primary` argument should be the 32 bit ARGB value of one of the
  /// values in the swatch, as would be passed to the [new Color] constructor
  /// for that same color, and as is exposed by [value]. (This is distinct from
  /// the specific index of the color in the swatch.)
  const MaterialColor(int primary, Map<int, Color> swatch) : super(primary, swatch);

  /// The lightest shade.
  Color get shade50 => this[50]!;

  /// The second lightest shade.
  Color get shade100 => this[100]!;

  /// The third lightest shade.
  Color get shade200 => this[200]!;

  /// The fourth lightest shade.
  Color get shade300 => this[300]!;

  /// The fifth lightest shade.
  Color get shade400 => this[400]!;

  /// The default shade.
  Color get shade500 => this[500]!;

  /// The fourth darkest shade.
  Color get shade600 => this[600]!;

  /// The third darkest shade.
  Color get shade700 => this[700]!;

  /// The second darkest shade.
  Color get shade800 => this[800]!;

  /// The darkest shade.
  Color get shade900 => this[900]!;
}

ということは、 primarySwatch: Colors.blue の blue ってただの青色ではないということですね。これはご存知の方も多いと思いますがせっかくなので細かく実装を見てみましょう。

まず、 Colors クラスについて見てみましょう。Color クラスではなく、 Color s クラスです。

実はこの Colors クラス、 ただの入れ物 です。便利な色の const がたくさん定義されているだけです。ポイントなのが、そこで定義されている色には Color クラスのものがあったり、 MaterialColor クラスのものがあったりします。

https://api.flutter.dev/flutter/material/Colors-class.html

これが案外初心者泣かせで、MaterialColor を指定するところを見様見真似で Color クラスから引用して、エラーが出て困る、というものです。
こういうエラーですね。そのままです。

flutter: type 'Color' is not a subtype of type 'MaterialColor'

さて、Colors.blue を見てみましょう。

  static const MaterialColor blue = MaterialColor(
    _bluePrimaryValue,
    <int, Color>{
       50: Color(0xFFE3F2FD),
      100: Color(0xFFBBDEFB),
      200: Color(0xFF90CAF9),
      300: Color(0xFF64B5F6),
      400: Color(0xFF42A5F5),
      500: Color(_bluePrimaryValue),
      600: Color(0xFF1E88E5),
      700: Color(0xFF1976D2),
      800: Color(0xFF1565C0),
      900: Color(0xFF0D47A1),
    },
  );
  static const int _bluePrimaryValue = 0xFF2196F3;

これが Colors.blue の実装です。先程の MaterialColor クラスの定義通りに実装されていますね。明暗の違いがしっかりと異なるカラーコードで実装されています。このカラーコードについては先程の Material Design のページに定義があります。

https://material.io/design/color/the-color-system.html#tools-for-picking-colors

さて、 MaterialColor に話を戻そうと思います。改めて MaterialColor クラスの定義を見てみましょう。(簡略版)

class MaterialColor extends ColorSwatch<int> {
  const MaterialColor(int primary, Map<int, Color> swatch)
   : super(primary, swatch);
   
  // ほげほげ・・・

extends ColorSwatch<int> ということは、次は ColorSwatch クラスについてみていくのが良さそうですね。初心者泣かせシリーズ第二弾、継承です。(話題にするとかなり分かっている人でも軽く火傷するアレです。玄人たちは「継承と委譲」大好きですからね。おっと火傷しちゃう・・・)

Swatch ね!
知ってるよ!!
スイスの腕時計でしょ!!!

٩( ᐛ )و

Color Swatch は色見本って意味です。

こういうやつです by 楽天市場

ColorSwatch クラスがこちら!!


class ColorSwatch<T> extends Color {
  const ColorSwatch(int primary, this._swatch) : super(primary);

  
  final Map<T, Color> _swatch;

  Color? operator [](T index) => _swatch[index];

  
  bool operator ==(Object other) {
    if (identical(this, other))
      return true;
    if (other.runtimeType != runtimeType)
      return false;
    return super == other
        && other is ColorSwatch<T>
        && mapEquals<T, Color>(other._swatch, _swatch);
  }

  
  int get hashCode => hashValues(runtimeType, value, _swatch);

  
  String toString() => '${objectRuntimeType(this, 'ColorSwatch')}(primary value: ${super.toString()})';
}

はい〜、いろいろありますねぇ。
ここだけみてください。


final Map<T, Color> _swatch;

ジェネレーターですね。型 T をキーに Color をもった Map オブジェクトです。
確かに色見本の概念をそのまま実装していますね。

MaterialColor ではこの型 T が int で、明暗を表すものでした。

ここまでで分かったことを整理すると、

  • primarySwatch には MaterialColor を指定する
  • MaterialColor は 色見本という概念を実装した ColorSwatch を MaterialDesign のガイドラインに準じて解釈したもの
    • MaterialColor は Colors クラスに const で定義されているものから選ぶと便利
    • 自作する場合は 10色以上のカラーセットを定義しないといけない

こんなところでしょうか。

primarySwatch はどこに影響するの?

続いて ThemeData まで話を戻します。
初期状態のアプリの ThemeData では primarySwatch: Colors.blue が指定されていましたね。
では、この色を変更すると、どういう効果があるのかを見ていきましょう。

まず重要(?)なポイントとして、 primarySwatch は ThemeData のプロパティではない 、ということが挙げられます。どういうことでしょうか。

クラスとプロパティについて細かく説明するつもりはありませんが、プロパティであるということは、そのクラスの実態であるインスタンスに含まれるものであり、実態が生成されたのちにアクセス可能(アクセス修飾子は一旦無視)な値といったところでしょうか。

つまり、 primarySwatch には ThemeData の初期化後にアクセスすることができません。
実際、Theme.of(context).xxx でアクセスしようとしてもプロパティが見つからないことがわかります。

theme_properties

お・・・俺が定義した色に・・・アクセスできないだと・・・ 😇

primarySwatch はコンストラクタにはありますが、プロパティにはないのです。ここには Flutter チームの意図 があるのではないかと思っています。

primarySwatch がコンストラクタのみにあるということは、 ThemeData を作るときに primarySwatch を元にして何かのプロパティが構成される ということです。

これはつまり、

ばらばらとした値を設定するのって、実装者にとって辛いよね! だからこっちでやってあげるよ!

ということです。

いや、真意はもしかすると

テキトーな値を好き放題設定されたら Flutter の良さとか Material Design の素晴らしさがちゃんと伝わらないよな〜。せや、便利な簡単設定を作ってやろう。そしたら俺らの手のひらの上やで!

かもしれません。

Hey Siri, テンションが上がる曲をかけてよ!
気分を上げたいときに聞くプレイリストを、再生します

このやり取りに近いものがあります。

さて、本題の primarySwatch の影響範囲を見ていきましょう。
以降は、 ThemeData の実装から抜粋しながら見ていきます。

ThemeDataの実装は以下のGitHubで実物を見ることができます(↓)。 ありがたい。

https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/material/theme_data.dart

まず primarySwatch 自身です。

primarySwatch ??= Colors.blue;

ここで最初に出会った謎が解けましたね。

コンストラクタで指定がなければ primarySwatch は自動的に Colors.blue になるんですね。

これを見るとますます以下のテンプレートから Flutter チームの手のひらの上で踊らされている (いや、躍らせていただいている)感が増しますね!

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

では、もう少し広げて見てみましょう。

final Brightness _brightness = brightness ?? colorScheme?.brightness ?? Brightness.light;
final bool isDark = _brightness == Brightness.dark;

primarySwatch ??= Colors.blue;

primaryColor ??= isDark ? Colors.grey[900]! : primarySwatch;
primaryColorLight ??= isDark ? Colors.grey[500]! : primarySwatch[100]!;
primaryColorDark ??= isDark ? Colors.black : primarySwatch[700]!;

primarySwatch が至る所に登場していますね。順に見てみましょう。

[case:1] primaryColor は ダークモードOFF で primarySwatch

primaryColor ??= isDark ? Colors.grey[900]! : primarySwatch;

isDark で分岐されています。が、 isDark == false で primarySwatch が設定されるようですね。 (primaryColor は MaterialColor ですね。 Color ではないところはポイントになりそうです。)

isDark については以下の通り、 brightness をコンストラクタで指定するか、colorScheme に brightness が指定されている場合はそれに従い、そうでなければ light です。

final Brightness _brightness = brightness ?? colorScheme?.brightness ?? Brightness.light;
final bool isDark = _brightness == Brightness.dark;

ちょっと余談として brightness についてみてみましょう。
Flutter でダークモードのテーマを組む場合、 MaterialApp の theme ではなく darkTheme を使うことが多いですね。

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

この theme と darkTheme ですが、 MaterialApp に対して指定される themeMode に従って切り替わるテーマです。 themeMode の初期値は ThemeMode.system なので、端末の設定にしたがってテーマを切り替えます。常にダークテーマが良ければ、 themeMode: ThemeMode.dark とすれば良いのです。要は、theme と darkTheme はこのフラグに従って読み込まれるテーマというだけです。これらに対して特に 強制的にdarkっぽくするといった実装はされていません。すべては実装者に委ねられています。

そこで、実装者が簡単に darkTheme を設定できるようにしてあげたほうがいいよね(?)ということで使うのが、 brightness です。

light テーマと dark テーマを簡単に作ってくれる便利な実装として以下のものは有名だと思います。

theme: ThemeData.light(),
darkTheme: ThemeData.dark(),

これらの実装はどうなっているかというと・・・

factory ThemeData.light() => ThemeData(brightness: Brightness.light);
factory ThemeData.dark() => ThemeData(brightness: Brightness.dark);

brightness を変えているだけ!!!!

つまりこれは、 primarySwatch さえちゃんと指定しておけば、ダークテーマは ThemeData クラスが勝手に作ってくれる ということです。

さあ、ダークテーマをゴリゴリ手動で実装していた方、正直に手をあげて自分の実装をチェックしてみてください。 brightness だけで済むかもしれませんよ。

さて、話を戻しますと、

primaryColor ??= isDark ? Colors.grey[900]! : primarySwatch;

primaryColor は brightness が light のときだけ primarySwatch の色になりますが、それ以外は grey[900]です。

「ダークモードの実装をやったときに全然色が変わらないじゃないか!!」 と憤った経験のある方、これが真実です。

先程からたびたび登場する XXな方! というのは過去の私です

primaryColor ですが、これは以下のような記載がある通り、アプリの主要なパーツで使われているテーマにおけるデフォルトのカラーです。もう少し色々説明されていますが、一旦おいておきましょう。

/// The background color for major parts of the app (toolbars, tab bars, etc)
///
/// The theme's [colorScheme] property contains [ColorScheme.primary], as
/// well as a color that contrasts well with the primary color called
/// [ColorScheme.onPrimary]. It might be simpler to just configure an app's
/// visuals in terms of the theme's [colorScheme].
final Color primaryColor;

同様に primaryColorLight と primaryColorDark についても簡単に触れておきます。

primaryColor ??= isDark ? Colors.grey[900]! : primarySwatch;
primaryColorLight ??= isDark ? Colors.grey[500]! : primarySwatch[100]!;
primaryColorDark ??= isDark ? Colors.black : primarySwatch[700]!;

primaryColorLight と primaryColorDark は primaryColor が適用されているアイテムの上に載っているアイコンやテキストのカラーです。 Light と Dark は primaryColorBrightness という、primaryColor をベースに計算される brightness に従って切り替わります。つまり、下地に合わせてその上に乗るテキストのカラーを自動で選んでくれるということです。この brightness の自動決定について気になる方は、 theme_data.dart の estimateBrightnessForColor(Color color) の実装を見てみてください。

ただ、 primaryColorLight や primaryColorDark がどの部品に効いてくるのかを適切に把握して実装するのはとてつもなく難しいです。API仕様書にもほとんど説明がありません。ですので、この部分は触らずそっとしておくのが得策です。

これで primaryColor についてはおしまいです。

なお、isDark の実装にあった colorScheme.brightness については一旦置いておいてください。

final Brightness _brightness = brightness ?? colorScheme?.brightness ?? Brightness.light;
final bool isDark = _brightness == Brightness.dark;

[case:2] accentColor もダークモードOFF で primarySwatch

説明はほとんど割愛します。先の内容と同じなので・・・。
以下の実装を見るとわかりますが、ダークモードの時のアクセントカラーは primarySwatch に関係なく、tealAccent[200] です。このあたりもハマりポイントなのでおさえておきましょう。

accentColor ??= isDark ? Colors.tealAccent[200]! : primarySwatch[500]!;

[case:misc.] ダークモード OFF で primarySwatch になるものがいっぱい!!

他のものも探してみました。

  • secondaryHeaderColor
    • ヘッダレベル2の色ですね
  • textSelectionColor
    • テキストハイライト色ですね
  • textSelectionHandleColor
    • テキスト選択時のアンカーハンドルの色ですね
  • backgroundColor
    • いろいろなものの背景色ですね
  • buttonColor
    • ボタンの色ですね
secondaryHeaderColor ??= isDark ? Colors.grey[700]! : primarySwatch[50]!;
textSelectionColor ??= isDark ? accentColor : primarySwatch[200]!;
textSelectionHandleColor ??= isDark ? Colors.tealAccent[400]! : primarySwatch[300]!;
backgroundColor ??= isDark ? Colors.grey[700]! : primarySwatch[200]!;
buttonColor ??= isDark ? primarySwatch[600]! : Colors.grey[300]!;

これではイメージが掴めないかもしれないので、具体例を示して確認しましょう。

みんな大好き AppBar を見てみます。

まず、 primarySwatch: Colors.green を指定してみます。

appbar_green

このとおり、 AppBar の背景色が緑色になりましたね。
何がどうなって、緑色になったか、分かりますか?

このときの AppBar の実装は初期のままなのでこの通りです。

appBar: AppBar(
  title: Text(widget.title),
),

この答えは AppBar の仕様に書かれています。

https://api.flutter.dev/flutter/material/AppBar-class.html

この AppBar の仕様にある backgroundColor プロパティの説明に答えがあります。

https://api.flutter.dev/flutter/material/AppBar/backgroundColor.html

The fill color to use for an app bar's Material.
If null, then the AppBarTheme.backgroundColor is used. If that value is also null, then AppBar uses the overall theme's ColorScheme.primary if the overall theme's brightness is Brightness.light, and ColorScheme.surface if the overall theme's brightness is Brightness.dark.

以下、概要です。

AppBar の色です。

  1. AppBar ウィジェットに backgroundColorは指定されていますか? (YES -> その色を使う / No -> 次へ)
  2. AppBarTheme は指定されていますか? (YES -> AppBarTheme.backgroundColor を使う / No -> 次へ)
  3. brightness が light なら ColorScheme.primary を使う。 brightness が dark なら ColorScheme.surface を使う。

ここまで長い説明を読んでくださった方ならおよそ何を言っているのかわかると思います。
今回の例は (1) にも (2) にも当てはまりません。 (3) です。

少し注意していただきたいのが、 ColorScheme.primary OR ColorScheme.surface というところ。 ColorScheme.backgroundColor ではないですよ!! 罠ですよね。

やっと出てきたぜ! ColorScheme!!

久しぶりに登場しましたね、ColorScheme です。色見本ですね。Flutter のテーマ設定の多くは何も設定がなければ基本に立ち返って色見本を参照します。

裏を返せば自分でテーマを組むというのは色見本との共存をうまく考えながら実施しなければならないということです。

新しい部品を追加したとたん、思った色にならず、テーマの見直しを余儀なくされ、その他の部品に影響が出るからと局所的な色設定をし・・・・  悪夢ですね。

さて、最後の話題です。 ThemeData における ColorScheme とはいったい何なのでしょうか。

ThemeDataの実装を再度チェックすると、こんな実装が見つかりました。

    // Create a ColorScheme that is backwards compatible as possible
    // with the existing default ThemeData color values.
    colorScheme ??= ColorScheme.fromSwatch(
      primarySwatch: primarySwatch,
      primaryColorDark: primaryColorDark,
      accentColor: accentColor,
      cardColor: cardColor,
      backgroundColor: backgroundColor,
      errorColor: errorColor,
      brightness: _brightness,
    );

また、 colorScheme プロパティの実装はこちら。

  /// A set of thirteen colors that can be used to configure the
  /// color properties of most components.
  ///
  /// This property was added much later than the theme's set of highly
  /// specific colors, like [cardColor], [buttonColor], [canvasColor] etc.
  /// New components can be defined exclusively in terms of [colorScheme].
  /// Existing components will gradually migrate to it, to the extent
  /// that is possible without significant backwards compatibility breaks.
  final ColorScheme colorScheme;

いろいろ繋がってきたでしょうか?

colorScheme = ColorScheme.fromSwatch(
  primarySwatch: primarySwatch,
  // ~~
);

この部分! よし、fromSwatch() を見るぞ!!

  factory ColorScheme.fromSwatch({
    MaterialColor primarySwatch = Colors.blue,
    Color? primaryColorDark,
    Color? accentColor,
    Color? cardColor,
    Color? backgroundColor,
    Color? errorColor,
    Brightness brightness = Brightness.light,
  }) {
    assert(primarySwatch != null);
    assert(brightness != null);

    final bool isDark = brightness == Brightness.dark;
    final bool primaryIsDark = _brightnessFor(primarySwatch) == Brightness.dark;
    final Color secondary = accentColor ?? (isDark ? Colors.tealAccent[200]! : primarySwatch);
    final bool secondaryIsDark = _brightnessFor(secondary) == Brightness.dark;

    return ColorScheme(
      primary: primarySwatch,
      primaryVariant: primaryColorDark ?? (isDark ? Colors.black : primarySwatch[700]!),
      secondary: secondary,
      secondaryVariant: isDark ? Colors.tealAccent[700]! : primarySwatch[700]!,
      surface: cardColor ?? (isDark ? Colors.grey[800]! : Colors.white),
      background: backgroundColor ?? (isDark ? Colors.grey[700]! : primarySwatch[200]!),
      error: errorColor ?? Colors.red[700]!,
      onPrimary: primaryIsDark ? Colors.white : Colors.black,
      onSecondary: secondaryIsDark ? Colors.white : Colors.black,
      onSurface: isDark ? Colors.white : Colors.black,
      onBackground: primaryIsDark ? Colors.white : Colors.black,
      onError: isDark ? Colors.black : Colors.white,
      brightness: brightness,
    );
  }

今回注目するのはここ!!

    return ColorScheme(
      primary: primarySwatch,

primary を primarySwatch、つまり Colors.green にしています!
AppBar が緑になっている理由が分かりましたね!!

case 1, 2, misc. のタイトルに "ダークモードOFFで" と書きましたが、正確には brightness 次第です。しかし、ダークモード = brightness.dark とするほうが何かと便利ということと、言葉として分かりやすいので、こういうタイトルにしています。悪しからず。

結論: 手のひらの上で踊ろうぜ

  • なるべく primarySwatch でテーマを組んでおいたほうがいい
  • 間違えて primaryColor って書いていないか確認したほうがいい

primarySwatch のつもりが primaryColor にしちゃってた!! テヘッ
っというノリでこういうコードを書いたことがある方、もしかすると危険ですよ。

    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primaryColor: Colors.green, // <- primaryColor を指定
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );

なぜか??

こうなるからです!!

incorrect_example

FAB (FloatingActionButton) が残念なことになっていますね!!

以上、ちょっとマニアックな内容をお届けしてしまいましたが、 Flutter の Theme ってイマイチ分かってないんだよね〜という方の一助になれば幸いです。

すぎっと٩( ᐛ )و