Flutterで独自のカラー(Dynamic Color以外)を扱うときに便利なパッケージ作った!!

こんにちは、Rerurate_514と申します。

今回は独自でカラー定義するときに使えるパッケージを作ってみたのでそれを共有します。

名前はtheme_palette_generatorです。

使い方

install

インストールには、

flutter pub get theme_palette_generator

と入力します。

依存関係に、

  • build_runner
    があるので、そちらの導入もお願いします。

カラースキーマのプロパティ定義

まず、このパッケージを使用するには独自のカラー定義を集めたクラスを作る必要があります。書き口としてはfreezedとほぼ同じです。違う点としては、ThemeExtensionを継承している点と、freezedの各種アノテーションは存在しない点ですね。

import 'package:flutter/material.dart';
import 'package:theme_palette_generator/theme_palette_generator.dart';
 
part 'ファイル名.theme.g.dart';
 
sealed class クラス名 extends ThemeExtension<クラス名> with _$クラス名{
  const factory AppColorScheme({
    required Color 色名,
  }) = _クラス名;
}

プロパティの型はimport 'package:flutter/material.dart';Colorです。
partディレクティブの拡張子はtheme.g.dartです!

実際に定義してみるとこんな感じ。

import 'package:flutter/material.dart';
import 'package:theme_palette_generator/theme_palette_generator.dart';
 
part 'app_color_scheme.theme.g.dart';
 
@ThemePalette()
sealed class AppColorScheme 
	extends ThemeExtension<AppColorScheme>
    with _$AppColorScheme {
  const factory AppColorScheme({
    required Color surfaceSuccess,
    required Color surfaceWarning,
    required Color brandBlue,
    required Color secondaryBrandBlue,
 
    required Color statusPending,
    required Color statusProcessing,
    required Color statusCompleted,
    required Color statusFailed,
  }) = _AppColorScheme;
}

今回は例として、この8つのフィールドを定義してみました。
これを使用していきます。

そして、build_runnerを走らせます。

dart run build_runner build

ちなみに自分はlib/core/theme/においてます。
下に続くコードもここにおいてます。

色の定義

上記のクラスを定義したら次は色を定義していきます。

import 'package:flutter/material.dart';
import 'package:rerurate_flutter_template/core/theme/app_color_scheme.dart';
 
const lightCustomColors = AppColorScheme(
  surfaceSuccess: Color(0xFFE8F5E9),
  surfaceWarning: Color(0xFFFFF3E0),
  brandBlue: Color(0xFF0D47A1),
  secondaryBrandBlue: Color(0xFFD3E4FF),
 
  statusPending: Color(0xFF9E9E9E),
  statusProcessing: Color(0xFF1976D2),
  statusCompleted: Color(0xFF388E3C),
  statusFailed: Color(0xFFD32F2F)
);
 
const darkCustomColors = AppColorScheme(
  surfaceSuccess: Color(0xFF1B5E20),
  surfaceWarning: Color(0xFFE65100),
  brandBlue: Color(0xFF2196F3),
  secondaryBrandBlue: Color(0xFFD3E4FF),
 
  statusPending: Color(0xFF757575),
  statusProcessing: Color(0xFF2196F3),
  statusCompleted: Color(0xFF4CAF50),
  statusFailed: Color(0xFFF44336),
);

大体こんな感じでしょうか。

同じファイルにMaterialAppに渡すためのThemeDataも定義します。

class AppTheme {
  static ThemeData get light => lightCustomColors.buildTheme(
    ThemeData.light(useMaterial3: true),
  );
 
  static ThemeData get dark => darkCustomColors.buildTheme(
    ThemeData.dark(useMaterial3: true),
  );
}

ここでbuildTheme()を使用して、テーマを生成します。引数はThemeDataです。

使うとき

まず、MaterialAppに渡すテーマを選択。実際はRiverpodとか使ってテーマを選択できるようにするでしょうが、今回は省略します。

  return MaterialApp(
      theme: AppTheme.light,
      // darkTheme: AppTheme.dark,

そして、ウィジェットで実際に色を使います。
色を使うときは、BuildContextを介して行います。

@override
Widget build(BuildContext context) {
  final theme = context.themePalette;
 
  return ElevatedButton(
    onPressed: onPressed,
    style: ElevatedButton.styleFrom(
      backgroundColor: theme.brandBlue, //themeを介してアクセス
    ),
    child: Text("Tap"),
  );
}

これだけ!!!

用意したメソッドについて

copyWith

一部の色だけ変えたインスタンスを作成できます。多分使うことはないかも。。。

final modified = lightCustomColors.copyWith(brandBlue: Colors.red);

lerp

アニメーション中にテーマを補間します。Flutterエンジンが呼ぶことを想定しているので、基本的に直接呼ぶことはありません。

toMap

ただ、Mapにしてくれるだけです。

final map = lightCustomColors.toMap();
print(map['brandBlue']); // Color(0xFF0D47A1)

== / hashCode

同じ色の組み合わせなら同一と判定します。

lightCustomColors == lightCustomColors.copyWith() // true

buildTheme

buildThemeはThemeDataにカスタムカラーを注入したThemeDataを生成するメソッドです。引数として既存のThemeDataを受け取り、extensionsにカスタムカラーを追加したものを返します。

class AppTheme {
  static ThemeData get light => lightCustomColors.buildTheme(
    ThemeData.light(useMaterial3: true),
  );
 
  static ThemeData get dark => darkCustomColors.buildTheme(
    ThemeData.dark(useMaterial3: true),
  );
}

技術

今回使用したのは、

この五つのパッケージです。
ビルド生成系パッケージを作るのにほぼ必須級のですね。

freezedで生成されたコードとにらめっこしたり、AIにcode_builderの使い方聞いたりしてようやくできました。

制作時間としては9時間ぐらいでしょうか?
mixinを使うという発想まで時間がかかりました。最初はextendsでやろうと思って実装していたのですが、そうするとユーザがクラスを作成するときに、どうしてもコンストラクタを排除できなかったので、ずっと考えてました。

import 'package:flutter/material.dart';
import 'package:theme_palette_generator/theme_palette_generator.dart';
 
part 'app_color_scheme.theme.g.dart';
 
@ThemePalette()
sealed class AppColorScheme extends _$AppColorScheme {
  const factory AppColorScheme({
    required Color surfaceSuccess,
    required Color surfaceWarning,
    required Color brandBlue,
    required Color secondaryBrandBlue,
 
    required Color statusPending,
    required Color statusProcessing,
    required Color statusCompleted,
    required Color statusFailed,
  }) = _AppColorScheme;
  
  const AppColorScheme._(); //この部分が排除できなかった
}

でなんとか迂回して、ファクトリだけで完結する実装になりました。
結果的には、コンストラクタを排除できたので満足。

.theme.g.dartの中身としてはこんな感じ。

classDiagram
    class ThemeExtension~T~ {
        <<Flutter SDK>>
    }

    class BuildContext {
        <<Flutter SDK>>
    }

    class AppColorScheme {
        <<interface / abstract>>
    }

    class _AppColorScheme {
    }

    class Mixin_AppColorScheme {
        <<mixin>>
        +copyWith() AppColorScheme
        +lerp() ThemeExtension~AppColorScheme~
        +toMap() Map~String, Color~
        +buildTheme() ThemeData
    }

    class AppColorSchemeBuildContextExtension {
        <<extension>>
        +themePalette AppColorScheme
    }

    ThemeExtension <|-- Mixin_AppColorScheme : extends
    ThemeExtension <|-- _AppColorScheme : extends
    Mixin_AppColorScheme <|-- _AppColorScheme : mixin
    AppColorScheme <|-- _AppColorScheme : implements

    BuildContext ..> AppColorSchemeBuildContextExtension : extends via BuildContext
    AppColorSchemeBuildContextExtension ..> AppColorScheme : fetches

これで不変性とconstの維持ができるってわけですね。

これ_クラス

@immutable
class _AppColorScheme extends ThemeExtension<AppColorScheme>
    with _$AppColorScheme
    implements AppColorScheme

extendswithimplements全部乗せでちょっとだけ面白いw
でもこうしないと理想が実現できなかった。

終わり

ブランドカラーの細かい管理や、Dynamic Colorでは対応しきれないデザインシステムを持つプロジェクトでは、こういった独自カラー定義の仕組みが役立つと思います。

ぜひ使ってみてください! それか、もっとよい方法があればぜひ教えてください!