エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Flutterアプリでのデザインマネジメント

エンジニアリンググループ 新規プロダクト支援チーム所属の荒谷(@_a_akira)です。

あまり知られていないかもしれませんが弊社では、2019年末から既に5つの新規アプリをFlutterで実装しリリースしています。 先日リリースされたデジカルスマート診療(以降デジスマアプリ)という医療機関向けに予約やキャッシュレス決済を導入・利用できるアプリもFlutterで作成しています。

digikar-smart.jp

このサービスの立ち上げからリリースまでの開発期間は約3ヶ月で開発側の人数もPdM1人、アシスタントPdM1人、デザイナー1人、バックエンド2人、WEB フロント(クリニック向け管理画面)1人、アプリ(患者向け, Flutter)1人の構成で開発しています。 このあたりの開発体制については先日記事が上がっているので興味のある方はそちらを見てみてください。

www.m3tech.blog

今日はこの少ない人数と短い開発期間でリリースするために行ったデザイン管理方法をデザイン側、アプリ開発側の両視点から解説したいと思います。

デザイン側の管理方法

デザインツール

以前は弊社ではAdobeXDでデザインしていることが多かったのですが、1年半ほど前からはFigmaを利用するようになっていて、このプロダクトもFigmaを使ってデザインされています。 導入自体はコロナ前なのですが、リアルタイムに同時編集可能なのでコロナ禍のリモート環境でもスムーズにやり取りできてとても進めやすいです。 仕様書の部分もFigma上で作成していて、画面ごとの対比が見やすくなっているため開発スピードアップに大きく貢献しました。 同時編集自体はXDでもできるのですが、Figmaの利点はコンポーネントのステート管理のしやすさにある気がします。

コンポーネント化することの一番のメリットは、画面間のデザインが統一できる点にあると思います。 デザインパーツがコンポーネント化されていれば画面追加の際にも迷わずデザインできますし、大きく既存デザインから外れることがなくなるので、エンジニアだけでも先行して開発を進められます。

コンポーネント管理

今回はFlutterで開発するので、大枠のデザインはマテリアルデザインに沿って作ってあります。FlutterはGoogleが開発しているクロスプラットフォーム向けのフレームワークで、近年ではAndroid, iOSアプリが1つのソースコードで素早く簡単に作れる点等が評価されて広く使われるようになってきました。
ご存知の通りマテリアルデザインもGoogleが開発していて、Flutterはそれよりも後に開発されているため、基本設計がマテリアルデザインの思想で作られており、デザイン思想を合わせると開発スピードが大幅に向上します。そのため、後から思想を合わせているAndroidよりも簡単に開発できる印象です。

色の管理

デジスマアプリはダークテーマに対応していて、端末のテーマ設定によって画面が切り替わるようになっています。

そのためアプリ側で定義している色定義もテーマに依らない共通のもの、ライトテーマ、ダークテーマの3カテゴリに分けています。 さらに、その中でマテリアルデザインデフォルトの色として使われているMaterial Design(この定義はデジスマアプリに依らない)、テキストカラーとして使われているText、デジスマアプリの色として使われているAppの3カテゴリに分けて管理されています。

figma-color-definition
Figma:色定義

意外と調べるのが大変だったのはマテリアルデザインのデフォルトで使われている色定義で、アプリのテーマ設定で色を指定しない限りはその色が使われます。その色をFigmaで定義するためにFlutterのソースコードからカラーコードを読み取りました。(マテリアルデザイン, Flutterの公式サイトにまとまってはいませんでした)

個人のブログですが、Flutterで使われているデフォルトの色をまとめておいたので興味があれば見てください。

aakira.app

命名規則

現状Figma自体には作成した画面をダークテーマに切り替える機能はありません。そのため、こちらのプラグインを使ってダークテーマとライトテーマを切り替えています。

www.figma.com

このプラグインではFigmaのColor Stylesに定義されている色の名前にあるLightとDarkに反応してテーマを自動で切り替えてくれます。

f:id:AAkira:20210914135937g:plain
Figma Theme Switcher Plugin

Color Stylesは /[カテゴリ]/[Light, Dark]/[Widget]/[State] の順に定義していて、このプラグインではLight, Darkの階層(順番)は任意なため、デジスマアプリではトップレベルにコンポーネントの名前をつけて、2つ目の階層でLight, Darkを定義しています。

e.g.

  • Text/Light/Primary
  • Text/Dark/Primary

figma-color-styles
Figma: Color Styles

テキスト以外も同様の命名規則で定義していて、このようになっています。

e.g.

  • MaterialDesign/Light/AppBar/Background
  • MaterialDesign/Light/Switch/Thumb/Default
  • MaterialDesign/Light/Switch/Thumb/Disabled
  • MaterialDesign/Light/Switch/Track/Default
  • MaterialDesign/Light/Switch/Track/Selected
  • MaterialDesign/Light/Switch/Track/Disabled
  • MaterialDesign/Dark/Switch/Thumb/Default

Light, Darkをトップレベルにして、カテゴリを2階層目にするのかはチームで相談して決めると良いでしょう。

テキストの管理

マテリアルデザインのサイズを踏襲するために、テキストもマテリアルデザインに定義されているサイズからそのまま指定しています。

material-design-text-theme
Material Design TextTheme

FigmaのText Stylesはこのように定義してあります。

※ 上記リンク先のドキュメントにも書いてあるようにMaterial Design 2018では body1,body2がbodyText1, bodyText2にリネームされています。

figma-text-styles
Figma: Text Styles

文字サイズとFont Weightだけでなく、Letter spacingも忘れずに定義しましょう。

Widgetの管理

ここでは、アプリのそれぞれを構成するパーツをWidgetと呼びます。コンポーネント化されているWidgetはたくさんありますが、わかりやすいのでボタンだけ紹介します。

色やテキストとは異なりWidgetはStyleではなく、Master Componentとして定義します。

figma-button-components
Figma: Button Components

また、Variantsを設定して、プロパティの切り替えでボタンの状態を切り替えられるようになっています。

figma-button-variants
Figma: Button Variants

Flutter側の管理方法

Flutterでは MaterialApptheme に対して ThemeData を設定すると任意のテーマを設定できます。
同様に darkTheme にも ThemeData を設定できて、端末のテーマ設定に応じてFlutterが自動で切り替えてくれます。
ただし、アプリ内にテーマ切り替えの設定を持つ場合は darkThemeThemeData を渡してしまうと端末の設定に依存してしまうので、 自分でテーマの値を切り替える必要があります。(この記事では考慮しません)

MaterialApp(
  title: 'Example App',
  theme: lightTheme,
  darkTheme: darkTheme,
  home: const HomePage(),
);

テーマ定義

  • ライトテーマ

ThemeData.light()がFlutter標準のテーマになります。 これをベースにアプリのデザインに応じて色を設定します。 独自で定義している AppColors, _lightTextTheme については後述します。

ThemeData get lightTheme {
  return ThemeData.light().copyWith(
    primaryColor: AppColors.primary,
    primaryColorBrightness: Brightness.light,
    textTheme: _lightTextTheme,
    appBarTheme: const AppBarTheme(
      color: AppColors.white,
      iconTheme: IconThemeData(color: AppColors.black),
      titleTextStyle: TextStyle(
        color: AppColors.textLightPrimary,
      ),
    ),
    backgroundColor: AppColors.lightBackground,
  );
}
  • ダークテーマ

ダークテーマも同様にFlutterのデフォルトテーマをベースに変更します。

ThemeData get darkTheme {
  return ThemeData.dark().copyWith(
    primaryColor: AppColors.primary,
    primaryColorBrightness: Brightness.dark,
    textTheme: _darkTextTheme,
    appBarTheme: const AppBarTheme(
      color: Colors.black,
      iconTheme: IconThemeData(color: AppColors.white),
      titleTextStyle: TextStyle(
        color: AppColors.textDarkPrimary,
      ),
    ),
    backgroundColor: AppColors.darkBackground,
  );
}

色定義

アプリの色はシングルトンのクラスで定義しています。 ただ色の定義を参照するだけならクラスを定義せずconstだけで良いのですが、 ダークテーマ時に色を切り替えたい場合があるのでクラスにisDarkThemeというプロパティを持っています。 現在がダークテーマかどうかは WidgetsBinding.instance?.window.platformBrightness == Brightness.dark で判定できます。

class AppColors {
  factory AppColors() {
    return _;
  }

  AppColors._internal();

  static final AppColors _ = AppColors._internal();

  static const Color primary = Color(0xFF0079D1);
  ...
  static const Color lightGrey = Color(0xFFE4E7E7);
  static const Color brightGrey = Color(0xFF9AA7A6);
  ...
  
  static const Color textLightPrimary = Color(0xFF1A1C1E);
  static const Color textLightSecondary = Color(0xFF404A49);
  static const Color textLightDisabled = Color(0xFFA7AEB4);
  static const Color textDarkPrimary = Color(0xFFFEFEFE);
  static const Color textDarkSecondary = Color(0xFFE4E7E7);
  static const Color textDarkDisabled = Color(0x61FEFEFE);
  ...

  bool get isDarkTheme =>
      WidgetsBinding.instance?.window.platformBrightness ==
                                         Brightness.dark;

  Color get grey {
    return isDarkTheme ? lightGrey : brightGrey;
  }

  Color get primaryText {
    return isDarkTheme ? textDarkPrimary : textLightPrimary;
  }

  ...
}

Primary Colorは単純に AppColors.primary; と呼び出せばアクセスできます。
LightとDarkで色が切り替わる場合は getter経由で AppColors().grey; で呼び出して使っています。

テキストテーマ

個人的に一番定義して管理したいのはテキストのサイズとカラーです。 デザイン管理の章でも述べたとおり、Flutterではマテリアルデザインのサイトに定義されているテキストテーマがそのまま同名で定義されています。
使用する場所によって異なる場合もありますが、アプリ独自のテキストスタイルを予め定義することで各Viewからの呼び出しは決められたテーマを使うようにルールで統一しやすくなります。

const _lightTextTheme = TextTheme(
  headline6: TextStyle(color: AppColors.textLightPrimary),
  subtitle1: TextStyle(color: AppColors.textLightPrimary),
  subtitle2: TextStyle(color: AppColors.textLightPrimary),
  bodyText1: TextStyle(color: AppColors.textLightPrimary),
  caption: TextStyle(color: AppColors.textLightSecondary),
  overline: TextStyle(color: AppColors.textLightSecondary),
  bodyText2: TextStyle(color: AppColors.textLightSecondary),
  headline5: TextStyle(color: AppColors.textLightSecondary),
  headline4: TextStyle(color: AppColors.textLightSecondary),
  headline3: TextStyle(color: AppColors.textLightSecondary),
  headline2: TextStyle(color: AppColors.textLightSecondary),
  headline1: TextStyle(color: AppColors.textLightSecondary),
  button: TextStyle(color: AppColors.textLightPrimary),
);

(このテーマを ThemeData に設定しています)

各Viewからはこのように呼び出しています。

Text(
    'Hello, World!',
    style: Theme.of(context).textTheme.bodyText2,
);

拡張スタイル

例えば、サイズはSubtitle1で定義されているスタイルを用いたいが、Font WeightをRegularではなくBoldで使いたいとします。 通常であればこのように使う側で merge してスタイルを上書きしますが、

Text(
    'Hello, World!',
    style: Theme.of(context)
          .textTheme
          .subtitle1
          ?.merge(const TextStyle(fontWeight: FontWeight.bold)),
);

DartのExtensionを使えばこのように定義でき、

extension TextThemeExtension on TextTheme {
  TextStyle? get subtitle1Bold => subtitle1?.merge(
        const TextStyle(fontWeight: FontWeight.bold),
      );
}

各所でmergeせずに統一したテキストスタイルを使い回せます。

Text(
    'Hello, World!',
    style: Theme.of(context).textTheme.subtitle1Bold,
);

これはかなり便利で各Viewで定義し直していたところが一括管理できるようになったのは良かったです。
マテリアルデザインのテキストテーマの名前(Subtitle, BodyText等)がわかりづらいので、サービスごとに適した名前を付けるやり方でも良いと思います。

Widget(Button)

少し長いですが、Figmaで定義したボタンのFlutter側のコードはこのようになっています。 ポイントは AppButtonStyle というEnumを定義して、PrimaryとSecondaryを切り替えています。また、このWidgetはグローバルのコンポーネントとして定義されているので、各Viewのコードにボタンを追加する際は必ずこのWidgetを使うことでアプリ全体のコード(デザイン)を統一でき管理しやすくなります。

extension AppButtonStyleExtension on AppButtonStyle {
  _AppButtonStyle get value {
    switch (this) {
      case AppButtonStyle.primary:
        return _AppButtonStyle(
          text: AppColors.white,
          style: ElevatedButton.styleFrom(
            primary: AppColors.primary,
            onPrimary: AppColors.white,
            onSurface: AppColors().grey,
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(10),
            ),
          ),
        );
      case AppButtonStyle.secondary:
         ...
   }
}

class _AppButtonStyle {
  const _AppButtonStyle({
    required this.text,
    required this.style,
  });

  final Color text;
  final ButtonStyle style;
}

class AppButton extends StatelessWidget {
  const AppButton({
    Key? key,
    required this.text,
    this.onPressed,
    this.isThin = false,
    this.style = AppButtonStyle.primary,
  }) : super(key: key);

  final String text;
  final VoidCallback? onPressed;
  final bool isThin;
  final AppButtonStyle style;

  @override
  Widget build(BuildContext context) {
    final textTheme = isThin
        ? Theme.of(context).textTheme.subtitle2
        : Theme.of(context).textTheme.headline6;
    return ConstrainedBox(
      constraints: const BoxConstraints(minWidth: 240),
      child: ElevatedButton(
        child: Padding(
          padding: const EdgeInsets.symmetric(vertical: 12),
          child: Text(
            text,
            style: textTheme?.merge(
              TextStyle(
                color: onPressed == null
                    ? AppColors.textLightDisablePrimary
                    : style.value.text,
              ),
            ),
          ),
        ),
        style: style.value.style,
        onPressed: onPressed, 
      ),
    );
  }
}

Viewからの呼び出しはこれだけです。(引数のstyleはデフォルトで定義されているので省略可能です。)

AppButton(
    text: 'Button',
    onPressed: () {
        print('Button pressed');
    },
    style: AppButtonStyle.primary,
);

まとめ

デザイン側のコンポーネント管理方法からFlutter側の参照までの一連の流れを説明しました。
Flutterはマテリアルデザインのコンポーネントに則るとかなり素早く開発できます。
今はネイティブとのブリッジ部分を自分で書くことがあるかもしれませんが、今後ライブラリが増えていけば徐々に減っていくと思います。(デジスマアプリには現状ありません)
これからFlutterでプロダクトを爆速開発していく際の参考になれば幸いです。

おまけ

まだリリース間もなく、これから導入クリニックや機能をどんどん増やしていくフェーズですが、利用クリニックが近くにあればとても便利なので、ぜひダウンロードして使ってみてください!! (良い感じのWalkthroughを見て欲しいだけ)

walkthrough
Walkthrough

  • iOS

M3デジカルスマート診療

M3デジカルスマート診療

  • Digikar, Inc
  • メディカル
  • 無料
apps.apple.com

  • Android

play.google.com

We are hiring!

エムスリーでは、ソフトウェアエンジニア、デザイナーを募集しています。興味を持たれた方は下記よりお問い合わせください。

jobs.m3.com