Flutter x Atomic Design で堅牢なデザインパターンを実現しよう

commits21 min read読了の目安(約18900字

はじめに

Flutter は宣言的 UI フレームワークを採用しており、階層的に UI を実装することが可能となっています。

Flutter を始めとして SwiftUI や React Native などの台頭により、近年では宣言的 UI でのネイティブアプリ開発がトレンドとなっていますが、その原型と言えるものは HTML の言語仕様として Web フロント開発では既に定着していました。

その Web フロント開発において注目されているコンポーネント設計手法があります。
それが Atomic Design です。

この記事では、Flutter による宣言的 UI 実装と Atomic Design によるコンポーネント設計を組み合わせ、堅牢かつ再利用性の高い Widget 及び画面を実装することを目指します。


目次

- Atomic Design とは?
- Flutter での Atomic Design の適用

Atomic Designとは?

基本的な理念

Atomic Design は、小さい UI コンポーネントを組み合わせてより大きなコンポーネントを作っていくための設計フレームワークです。
コンポーネントを化学要素(原子と物体)に見立て、それらを組み合わせることで画面を構成します。

コンポーネントの粒度

Atomic Design は、最小単位の原子が組み合わさって分子を構成し、その分子が集まって有機体を形成する自然界のモデルを UI のコンポーネント設計に適用しています。

そのため、Atomic Design では以下のコンポーネント粒度を定義しています。

  • Atoms(原子)
    • あらゆる UI コンポーネントの最小単位
    • 責務:デザインの統一性
  • Molecules(分子)
    • 機能を組み合わせてユーザの具体的な動機に応える
    • 責務:行動を阻害しない操作性
  • Organisms(有機体)
    • コンポーネントで完結するコンテンツを提供
    • 責務:ユーザの行動を促すコンテンツ
  • Templates(テンプレート)
    • ページの雛形
    • コンポーネントがページ上で正しくレイアウトされるかを確認する
    • 責務:画面全体のレイアウト
  • Pages(ページ)
    • Template 層のコンポーネントに実際のコンテンツを流し込んだもの
    • 責務:コンテンツそのもの

コンポーネントの依存関係

Atomic Design では小さいコンポーネントを組み合わせて大きいコンポーネントを作るため、 小さいコンポーネントが大きなコンポーネントを含まない という依存の方向性が存在します。
そのため、Atomic Design は「UI デザインを階層化アーキテクチャの概念を取り入れて設計するもの」として考えることが出来ます。


Flutter での Atomic Design の適用

ここからは、Flutter で提供されている Widget を用いて、実際に Flutter での Atomic Design の実装パターンを考えていきます。

Atoms

Atoms はあらゆるUIコンポーネントの最小単位で、Atoms 以外の4つの粒度のコンポーネントは、全て Atoms 層に分解できる ように設計します。

Atoms 層でコンポーネント化したほうが良いものは、

  • プラットフォームのデフォルトUI
  • プラットフォームのデファクト・スタンダードなUI
  • レイアウト・パターン
  • セマンティックな(意味を付加するような)デザイン要素

の4つのカテゴリーです。

例)ボタン

button.dart
import 'package:flutter/material.dart';

class ButtonStyle {
  final EdgeInsetsGeometry padding;
  final EdgeInsetsGeometry margin;
  final ButtonTextTheme textTheme;
  final double minWidth;
  final double height;
  final ShapeBorder shape;
  final Color buttonColor;
  final Color disabledColor;
  final Color focusColor;
  final Color hoverColor;
  final Color highlightColor;
  final Color splashColor;

  const ButtonStyle({
    this.padding = const EdgeInsets.all(0.0),
    this.margin = const EdgeInsets.all(0.0),
    this.textTheme = ButtonTextTheme.normal,
    this.minWidth = 88.0,
    this.height = 36.0,
    this.shape,
    this.buttonColor,
    this.disabledColor,
    this.focusColor,
    this.hoverColor,
    this.highlightColor,
    this.splashColor,
  });
}

class StylableRaisedButton extends StatelessWidget {
  final ButtonStyle style;
  final VoidCallback onPressed;
  final Widget child;

  const StylableRaisedButton({
    this.style = const ButtonStyle(),
     this.onPressed,
     this.child,
  });

  
  Widget build(BuildContext _) {
    return Container(
      margin: this.style.margin,
      child: ButtonTheme(
        textTheme: this.style.textTheme,
        minWidth: this.style.minWidth,
        height: this.style.height,
        padding: this.style.padding,
        shape: this.style.shape,
        buttonColor: this.style.buttonColor,
        disabledColor: this.style.disabledColor,
        focusColor: this.style.focusColor,
        hoverColor: this.style.hoverColor,
        highlightColor: this.style.highlightColor,
        splashColor: this.style.splashColor,
        child: RaisedButton(
          onPressed: this.onPressed,
          child: this.child,
        ),
      ),
    );
  }
}

Button コンポーネントがUIとして関心を持つべきは ボタンを表示する ということのみで、どんなラベルを表示するかに関心を持つべきではありません。
したがって、ボタン上に表示するラベルは任意のものを表示できるように、child で任意の Widget を指定できるようにします。

また、Button コンポーネントに大きさや表示座標に関する責務まで持たせてしまうのは過剰です。
なぜなら、大きさの決定や配置はコンポーネントを使う側の関心であるためです。
そこで、ButtonStyle を実装することで、上位層のコンポーネントが Button の大きさや座標を自由に決定することが出来ます。

そして、Button コンポーネントの機能には、どんな処理をするかまでは含めません。
具体的にどんな処理をするかまでを機能に含めると、「それ以上機能的に分解できない最小単位」ではなくなってしまうためです。

例)テキストフォームフィールド

text_form_field.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class TextFormFieldStyle {
  final EdgeInsetsGeometry margin;
  final bool isOutline;
  final TextStyle labelStyle;
  final TextStyle helperStyle;
  final int helperMaxLines;
  final TextStyle hintStyle;
  final int hintMaxLines;
  final TextStyle errorStyle;
  final int errorMaxLines;
  final FloatingLabelBehavior floatingLabelBehavior;
  final EdgeInsetsGeometry contentPadding;
  final BoxConstraints prefixIconConstraints;
  final TextStyle prefixStyle;
  final TextStyle suffixStyle;
  final BoxConstraints suffixIconConstraints;
  final TextStyle counterStyle;
  final bool filled;
  final Color fillColor;
  final Color focusColor;
  final Color hoverColor;
  final TextAlign textAlign;
  final int maxLines;
  final int minLines;
  final bool expands;
  final int maxLength;

  const TextFormFieldStyle({
    this.margin = const EdgeInsets.all(0.0),
    this.isOutline = false,
    this.labelStyle,
    this.helperStyle,
    this.helperMaxLines,
    this.hintStyle,
    this.hintMaxLines,
    this.errorStyle,
    this.errorMaxLines,
    this.floatingLabelBehavior = FloatingLabelBehavior.auto,
    this.contentPadding,
    this.prefixIconConstraints,
    this.prefixStyle,
    this.suffixStyle,
    this.suffixIconConstraints,
    this.counterStyle,
    this.filled,
    this.fillColor,
    this.focusColor,
    this.hoverColor,
    this.textAlign = TextAlign.start,
    this.maxLines = 1,
    this.minLines,
    this.expands = false,
    this.maxLength,
  });
}

class StylableTextFormField extends StatelessWidget {
  final TextFormFieldStyle style;
  final Widget icon;
  final String labelText;
  final String helperText;
  final String hintText;
  final String errorText;
  final bool isDense;
  final Widget prefixIcon;
  final Widget prefix;
  final String prefixText;
  final Widget suffixIcon;
  final Widget suffix;
  final String suffixText;
  final Widget counter;
  final String counterText;
  final String semanticCounterText;
  final bool alignLabelWithHint;
  final TextInputType keyboardType;
  final TextInputAction textInputAction;
  final TextDirection textDirection;
  final bool autofocus;
  final bool readOnly;
  final bool obscureText;
  final bool autocorrent;
  final bool autovalidate;
  final bool maxLengthEnforced;
  final ValueChanged<String> onChanged;
  final FormFieldValidator<String> validator;
  final List<TextInputFormatter> inputFormatters;
  final bool enabled;

  const StylableTextFormField({
    this.style = const TextFormFieldStyle(),
    this.icon,
    this.labelText,
    this.helperText,
    this.hintText,
    this.errorText,
    this.isDense = false,
    this.prefixIcon,
    this.prefix,
    this.prefixText,
    this.suffixIcon,
    this.suffix,
    this.suffixText,
    this.counter,
    this.counterText,
    this.semanticCounterText,
    this.alignLabelWithHint = false,
    this.keyboardType,
    this.textInputAction,
    this.textDirection,
    this.autofocus = false,
    this.readOnly = false,
    this.obscureText = false,
    this.autocorrent = true,
    this.autovalidate = false,
    this.maxLengthEnforced = true,
     this.onChanged,
    this.inputFormatters,
     this.validator,
    this.enabled = true,
  });

  
  Widget build(BuildContext _) {
    return Container(
      margin: this.style.margin,
      child: TextFormField(
        decoration: InputDecoration(
          icon: this.icon,
          labelText: this.labelText,
          labelStyle: this.style.labelStyle,
          helperText: this.helperText,
          helperStyle: this.style.helperStyle,
          helperMaxLines: this.style.helperMaxLines,
          hintText: this.hintText,
          hintStyle: this.style.hintStyle,
          hintMaxLines: this.style.hintMaxLines,
          errorText: this.errorText,
          errorStyle: this.style.errorStyle,
          errorMaxLines: this.style.errorMaxLines,
          floatingLabelBehavior: this.style.floatingLabelBehavior,
          isDense: this.isDense,
          contentPadding: this.style.contentPadding,
          prefixIcon: this.prefixIcon,
          prefixIconConstraints: this.style.prefixIconConstraints,
          prefix: this.prefix,
          prefixText: this.prefixText,
          prefixStyle: this.style.prefixStyle,
          suffixIcon: this.suffixIcon,
          suffix: this.suffix,
          suffixText: this.suffixText,
          suffixStyle: this.style.suffixStyle,
          suffixIconConstraints: this.style.suffixIconConstraints,
          counter: this.counter,
          counterText: this.counterText,
          counterStyle: this.style.counterStyle,
          filled: this.style.filled,
          fillColor: this.style.fillColor,
          focusColor: this.style.focusColor,
          hoverColor: this.style.hoverColor,
          border: this.style.isOutline ? const OutlineInputBorder() : const UnderlineInputBorder(),
          enabled: this.enabled,
          semanticCounterText: this.semanticCounterText,
          alignLabelWithHint: this.alignLabelWithHint,
        ),
        keyboardType: this.keyboardType,
        textInputAction: this.textInputAction,
        textDirection: this.textDirection,
        textAlign: this.style.textAlign,
        autofocus: this.autofocus,
        readOnly: this.readOnly,
        obscureText: this.obscureText,
        autocorrect: this.autocorrent,
        autovalidate: this.autovalidate,
        maxLengthEnforced: this.maxLengthEnforced,
        maxLines: this.style.maxLines,
        minLines: this.style.minLines,
        expands: this.style.expands,
        maxLength: this.style.maxLength,
        onChanged: this.onChanged,
        validator: this.validator,
        inputFormatters: this.inputFormatters,
        enabled: this.enabled,
      ),
    );
  }
}

TextFormField コンポーネントも Button コンポーネントと同様に、コンポーネントは キーボード入力を受け付ける という一点にのみ関心を持つようにし、TextFormFieldStyle を上位層のコンポーネントが利用して、TextFormField コンポーネントの大きさや座標を決定します。

Molecules

Molecules 層のコンポーネントは、2つ以上の Atoms を組み合わせて実装します。

Molecules 層が担っているのは、 ユーザが意識してやりたいと思っていることに対して機能を提供する ことです。
ユーザの動機に対する責務をコンポーネント化することで、ユーザのタスク完遂に対する効率性を最大化します。

ユーザが Molecules 層を通してやりたいことを簡単に行うには、その 手段自体が分かりやすいこと が重要です。
「手段が分かりやすい」ということには、

  • 以前に使ったことがある、または、直感的に使い方がわかる形をしている
  • 似た形をしたものは常に同じ挙動をする

の2点を満たす必要があります。

例)サインアップ用のフォーム

sign_up_form.dart
import 'package:flutter/material.dart';
import 'package:your_package/components/atoms/stylable_button.dart';
import 'package:your_package/components/atoms/stylable_label.dart';
import 'package:your_package/components/atoms/stylable_text_form_field.dart';

class SignUpForm extends StatelessWidget {
  final EdgeInsetsGeometry padding;
  final EdgeInsetsGeometry margin;

  const SignUpForm({
    this.padding = const EdgeInsets.all(0.0),
    this.margin = const EdgeInsets.all(0.0),
  });

  
  Widget build(BuildContext context) {
    return Container(
      padding: this.padding,
      margin: this.margin,
      child: LayoutBuilder(
        builder: (BuildContext _, BoxConstraints viewportConstrains) {
          return Stack(
            children: [
              SingleChildScrollView(
                child: ConstrainedBox(
                  constraints:
                      BoxConstraints(minHeight: viewportConstrains.maxHeight),
                  child: Column(
                    children: <Widget>[
                      StylableTextFormField(
                        style: const TextFormFieldStyle(
                          margin: const EdgeInsets.only(
                            top: 4.0,
                            bottom: 8.0,
                          ),
                          isOutline: true,
                        ),
                        labelText: "メールアドレス *",
                        keyboardType: TextInputType.emailAddress,
                        textInputAction: TextInputAction.next,
                        autofocus: true,
                        autovalidate: true,
                        onChanged: (String text) {
                          // TODO: Set state of email address.
                        },
                        validator: (String text) {
                          // TODO: Validate if it is an email address.
                        },
                        inputFormatters: [
                          BlacklistingTextInputFormatter(RegExp(r"\s")),
                        ],
                      ),
                      StylableTextFormField(
                        style: const TextFormFieldStyle(
                          margin: const EdgeInsets.only(bottom: 8.0),
                          isOutline: true,
                        ),
                        labelText: "パスワード *",
                        keyboardType: TextInputType.visiblePassword,
                        textInputAction: TextInputAction.next,
                        obscureText: true,
                        autovalidate: true,
                        onChanged: (String text) {
                          // TODO: Set state of password.
                        },
                        validator: (String text) {
                          // TODO: Validate if it is an email address.
                        },
                        inputFormatters: [
                          BlacklistingTextInputFormatter(RegExp(r"\s")),
                        ],
                      ),
                      StylableTextFormField(
                        style: const TextFormFieldStyle(
                          isOutline: true,
                        ),
                        labelText: "パスワードの確認 *",
                        keyboardType: TextInputType.visiblePassword,
                        textInputAction: TextInputAction.done,
                        obscureText: true,
                        autovalidate: true,
                        onChanged: (String text) {
                          // TODO: Set state of string which confirms the password.
                        },
                        validator: (String text) {
                          // TODO: Validate if password is confirmed.
                        },
                      ),
                    ],
                  ),
                ),
              ),
              Align(
                alignment: Alignment.bottomCenter,
                child: StylableRaisedButton(
                  style: const ButtonStyle(
                    minWidth: double.infinity,
                  ),
                  onPressed: () {
                    // TODO: Send a request for sign up.
                  },
                  child: const StylableLabel(
                    "SIGN UP",
                    style: const LabelStyle(
                      color: Colors.white,
                    ),
                  ),
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

SignUpForm コンポーネントは Atoms である ButtonTextFormField を組み合わせ、「サービスにサインアップしたい」というユーザの動機に応えるものです。

SignUpForm の責務として、

  • メールアドレス、パスワードの有効性の確認
  • パスワードの再入力による確認
  • サインアップのリクエスト

の役割を持たせます。

コードは見ての通り、先程実装した StyledButtonStyledTextFormField を組み合わせ、 ContainerStackAlign などの Widget を利用して Atoms 層のコンポーネントの表示位置を決定しています。

そして、「ボタンのタイトル」や「テキストフィールドのプレースホルダー」などは Molecules である SignUpForm で指定します。

Organisms

Orgamisms 層は Molecules や Atoms で構成されるコンポーネント群です。
Molecules や Atoms だけではなく、Organisms 層のコンポーネント自体も別の Organisms 層のコンポーネントを構成する要素になることがあります。

Molecules 層ではユーザの関心事に対して機能を提供しましたが、Orgamism 層は コンポーネントで完結するコンテンツ を提供します。
Organisms 層のコンポーネントは独立してコンテンツを提供できるため、コンテンツ単位での画面配置が可能 です。

例)コンテンツが並ぶリスト

feed.dart
class Feed extends StatelessWidget {
  const Feed();

  
  Widget build(BuildContext _) {
    return ListView.separated(
      padding: const EdgeInsets.all(8),
      itemCount: 20,
      itemBuilder: (BuildContext _, int __) {
        return Content();
      },
      separatorBuilder: (BuildContext _, int __) => const Divider(),
    );
  }
}

class Content extends StatelessWidget {
  const Content();

  
  Widget build(BuildContext _) {
    return Card(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          const ListTile(
            leading: Icon(
              Icons.favorite,
              color: Colors.pink,
              size: 24.0,
            ),
            title: Text('The Enchanted Nightingale'),
            subtitle: Text('Music by Julie Gable. Lyrics by Sidney Stein.'),
          ),
        ],
      ),
    );
  }
}

Content コンポーネントは Atoms である IconText を組み合わせ、「何かの情報をまとめて得る事ができる」独立したコンテンツです。

また、Feed は Organisms である Content を一覧表示する独立したコンテンツです。

※Molecules と Organisms の分け方

Atomic Design にて UI を設計している際に、Molecules か Organisms のどちらの粒度であるのが適切なのか分からないコンポーネントが出てくることがあります。
なぜなら、どちらも「複数のコンポーネントを組み合わせて実装する」という点では同じものだからです。

Molecules と Organisms 、どちらの粒度に含めるか迷った際には、以下の基準を参考にしてみてください。

Molecules:
  独立して存在できず、他のコンポーネントの機能を補助する役割が強いコンポーネント
Organisms:
  独立して存在することのできるコンポーネント

Templates

Templates 層はその名の通りページの雛形なので具体的なコンテンツを持ちませんが、Organisms 層や Molecules 層、Atoms 層などのコンポーネントを 実際のサービスのページと同様に配置する ことを目的としているものです。

例)サインアップ画面

sign_up_template.dart
import 'package:your_package/components/molecules/sign_up_form.dart';
import 'package:flutter/material.dart';

class SignUpTemplate extends StatelessWidget {
  const SignUpTemplate();

  
  Widget build(BuildContext _) {
    return Scaffold(
      appBar: AppBar(title: const Text("Sign up")),
      body: const SafeArea(
        child: const Padding(
          padding: const EdgeInsets.all(8.0),
          child: const SignUpForm(),
        ),
      ),
    );
  }
}

SignUpTemplate では、Atomsである AppBar 、Molecules である SignUpForm を実際の画面で表示したい大きさで座標に配置します。

Pages

Pages はユーザがプロダクト上で実際に触れるものであり、役割としては Template 層を介してコンテンツやルーティングをコンポーネントに接続することです。
Templates と Pages レイアウトとコンテンツを分割することができ、コンテンツに依存することなくレイアウトのテストを行うことが出来ます。

SignUpPage

sign_up_page.dart
import 'package:your_package/components/templates/sign_up_template.dart';
import 'package:flutter/material.dart';

class SignUpPage extends StatelessWidget {
  const SignUpPage();

  
  Widget build(BuildContext _) {
    return ChangeNotifierProvider<SignUpNotifier>(
      create: (BuildContext _) => SignUpNotifier(),
      child: const SignUpTemplate(),
    );
  }
}

SignUpPage では、Provider という package を利用して、ビジネスロジックとそれを利用するコンポーネントの繋ぎこみ(実データの流し込みなど)を行います。


まとめ

この記事では Atomic Design の「コンポーネントの再利用性」に着目し、Flutter と Atomic Design を組み合わせた堅牢な Widget 実装を考察しました。

みなさんも、Atomic Design による汎用性の高い堅牢な View 設計を学びましょう!