📦

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

2020/10/05に公開

はじめに

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つのカテゴリーです。

なので、基本的には Flutter から提供されている Widget を Atoms として利用し、プロダクトのデザインガイドに共通コンポーネントとして定義されているものがあれば、それを実装して利用すると良いでしょう。

Atoms は最小単位の粒度であり、汎用的な利用を想定することから、実装時に状態(State)は意識しません。

Molecules

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

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

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

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

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

上記を満たすことから、この粒度のコンポーネントを実装する際には状態(State)を持たせず、汎用的に利用できることを意識します。

例)メールアドレス入力フォーム

class EmailAddressForm extends StatelessWidget {
  const EmailAddressForm({
    Key? key,
    this.padding = const EdgeInsets.all(0.0),
    this.margin = const EdgeInsets.all(0.0),
    required this.onChanged,
  }) : super(key: key);

  final EdgeInsetsGeometry padding;
  final EdgeInsetsGeometry margin;
  final ValueChanged<String> onChanged;

  
  Widget build(BuildContext context) {
    return Padding(
      padding: padding.add(margin),
      child: TextFormField(
        key: const Key("EmailAddressForm"),
        decoration: InputDecoration(
          labelText: "メールアドレス *",
        ),
        keyboardType: TextInputType.emailAddress,
        textInputAction: TextInputAction.next,
        autofocus: true,
        enableSuggestions: false,
        onChanged: onChanged,
        validator: (String? value) {
          // TODO: メールアドレスの有効性を確認する
        },
        inputFormatters: <TextInputFormatter>[
          FilteringTextInputFormatter.deny(
            RegExp(r"\s"),
          ),
        ],
        autovalidateMode: AutovalidateMode.onUserInteraction,
      ),
    );
  }
}
EmailAddressForm の責務と関心

EmailAddressForm コンポーネントは Atoms である TextFormField を利用し、「メールアドレスを入力したい」というユーザの動機に応えるものです。

EmailAddressForm の責務として、

  • キーボード入力を受け付ける
  • 入力された文字列がメールアドレスの形式であるかを判定する
  • メールアドレスの形式でない場合、ユーザにメールアドレスの形式で入力するように伝える

を持たせます。

EmailAddressForm の実装

上述したとおり、EmailAddressForm の責務として 入力文字列のメールアドレス判定 があるので、その判定ロジックはこのコンポーネントで実装します。
それにより、EmailAddressForm を使い回すことで判定ロジックを意識せず再利用することができます。

そして、それ以上の責務や関心を持たせないようにするため、それ自体の大きさや表示位置に関しては上位層のコンポーネントが指定するようにします。

また、このコンポーネントから受け取ったメールアドレスの文字列をどのように扱うかは、ビジネスロジックを直接扱う上位層のコンポーネントの責務と関心であるため、ここでは 状態(State)に対する直接的な操作は行いません

例)パスワード設定フォーム

class PasswordConfirmationForm extends StatelessWidget {
  const PasswordConfirmationForm({
    Key? key,
    this.padding = const EdgeInsets.all(0.0),
    this.margin = const EdgeInsets.all(0.0),
    required this.onChanged,
    required this.onConfirmed,
  }) : super(key: key);

  final EdgeInsetsGeometry padding;
  final EdgeInsetsGeometry margin;
  final ValueChanged<String> onChanged;
  final ValueChanged<String> onConfirmed;

  
  Widget build(BuildContext context) {
    return Padding(
      padding: padding.add(margin),
      child: Column(
        children: <Widget>[
          TextFormField(
            key: const Key("PasswordForm"),
            decoration: InputDecoration(
              labelText: "パスワード *",
            ),
            keyboardType: TextInputType.visiblePassword,
            textInputAction: TextInputAction.next,
            obscureText: true,
            enableSuggestions: false,
            onChanged: onChanged,
            validator: (String? value) {
              // TODO: パスワードの有効性を確認する
            },
            inputFormatters: <TextInputFormatter>[
              FilteringTextInputFormatter.deny(
                RegExp(r"\s"),
              ),
            ],
            autovalidateMode: AutovalidateMode.onUserInteraction,
          ),
          SizedBox(height: 8),
          TextFormField(
            key: const Key("ConfirmPasswordForm"),
            decoration: InputDecoration(
              labelText: "パスワードの確認 *",
            ),
            keyboardType: TextInputType.visiblePassword,
            textInputAction: TextInputAction.done,
            obscureText: true,
            onChanged: onConfirmed,
            validator: (String? value) {
              // TODO: 設定するパスワードを確認する
            },
            autovalidateMode: AutovalidateMode.onUserInteraction,
          ),
        ],
      ),
    );
  }
}
PasswordConfirmationForm の責務と関心

PasswordConfirmationForm コンポーネントは Atoms である TextFormField を利用し、「メールアドレスを入力したい」というユーザの動機に応えるものです。

PasswordConfirmationForm の責務として、

  • キーボード入力を受け付ける
  • 設定したいパスワードが十分な強度であるかを判定する
  • 設定したいパスワードをもう一度確認させる
  • パスワードの強度が不十分であったり、パスワードの入力確認ができなかった場合、ユーザにその旨を伝える

を持たせます。

PasswordConfirmationForm の実装

上述したとおり、PasswordConfirmationForm の責務として 設定したいパスワードの強度判定設定したいパスワードの再確認 があるので、それらの判定ロジックはこのコンポーネントで実装します。
それにより、PasswordConfirmationForm を使い回すことで判定ロジックを意識せず再利用することができます。

そして、それ以上の責務や関心を持たせないため、それ自体の大きさや表示位置に関しては上位層のコンポーネントが指定するようにします。

また、このコンポーネントから受け取ったパスワードの文字列をどのように扱うかは、ビジネスロジックを直接扱う上位層のコンポーネントの責務と関心であるため、ここでは 状態(State)に対する直接的な操作は行いません

Organisms

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

前述の Molecules 層ではユーザの関心事に対して機能を提供しましたが、Orgamism 層は コンポーネントで完結するコンテンツ を提供します。
なので、Organisms 層では ビジネスロジックの取り扱い状態(State)の更新 を行うことも責務/関心となります。

Organisms 層のコンポーネントは独立してコンテンツを提供できるため、コンテンツ単位での画面配置 が可能です。

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

class SignUpForm extends StatelessWidget {
  const SignUpForm({
    Key? key,
    this.padding = const EdgeInsets.all(0.0),
    this.margin = const EdgeInsets.all(0.0),
  }) : super(key: key);

  final EdgeInsetsGeometry padding;
  final EdgeInsetsGeometry margin;

  
  Widget build(BuildContext context) {
    return Padding(
      padding: padding.add(margin),
      child: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints viewportConstrains) => Stack(
          children: <Widget>[
            SingleChildScrollView(
              child: ConstrainedBox(
                constraints: BoxConstraints(
                  minHeight: viewportConstrains.maxHeight,
                ),
                child: Column(
                  children: const <Widget>[
                    EmailAddressForm(
                      key: Key("SignUpForm_EmailAddress"),
                      margin: EdgeInsets.only(top: 4),
                      onChanged: (String email) {
                        // TODO: メールアドレスを扱う状態(State)の更新
                      },
                    ),
                    PasswordConfirmationForm(
                      key: Key("SignUpForm_PasswordConfirmation"),
                      margin: EdgeInsets.only(top: 8),
                      onChanged: (String password) {
                        // TODO: パスワードを扱う状態(State)の更新
                      },
                    ),
                  ],
                ),
              ),
            ),
            Align(
              alignment: Alignment.bottomCenter,
              child: SizedBox(
                width: double.infinity,
                child: ElevatedButton(
                  key: const Key("SignUpForm_ConfirmButton"),
                  onPressed: () {
                    // TODO: サインアップをリクエストする
                  },
                  child: const Text("SIGN UP"),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
SignUpForm の責務と関心

SignUpForm コンポーネントは Atoms である Button と Molecules である EmailAddressFormPasswordConfirmationForm を組み合わせ、「サービスへのサインアップ」という独立した機能を実装するものです。

SignUpForm の責務として、

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

の役割を持たせます。

SignUpForm の実装

コードを見ての通り、ElevatedButtonEmailAddressFormPasswordConfirmationForm を組み合わせ、 LayoutBuilderStackAlign などの Widget を利用してコンポーネントの表示位置を決定しています。

そして、「ボタンのタイトル」や「テキストフィールドのプレースホルダー」などは Orgamisms である SignUpForm で指定します。
また、それ自体の大きさや表示位置に関しては、後述の Templates 層で指定するようにします。

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

class Feed extends StatelessWidget {
  const Feed({
    Key key,
    this.padding = const EdgeInsets.all(0.0),
    this.margin = const EdgeInsets.all(0.0),
  }) : super(key: key);

  final EdgeInsetsGeometry padding;
  final EdgeInsetsGeometry margin;

  
  Widget build(BuildContext context) {
    return Padding(
      padding: padding.add(margin),
      child: 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({
    Key key,
    this.margin = const EdgeInsets.all(0.0),
  }) : super(key: key);

  final EdgeInsetsGeometry margin;

  
  Widget build(BuildContext context) {
    return Padding(
      padding: margin,
      child: 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 を組み合わせ、「何かの情報をまとめて得る事ができる」独立したコンテンツとみなすことができるので、Organisms の粒度です。

また、Feed は Organisms である Content を一覧表示する独立したコンテンツとなるので、同じく Organisms の粒度となります。

(余談)Molecules と Organisms の分け方

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

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

Molecules:
- 独立して存在できず、他のコンポーネントの機能を補助する役割が強いコンポーネント
- 状態(State)を直接変更しない
Organisms:
- 独立して存在することのできるコンポーネント
- 状態(State)を直接変更することができる

SignUpForm を例にとると、

  • EmailAddressForm
    • メールアドレスの入力を受け付けるのみ
      • 補助的なコンポーネントであり、独立して存在することができない
    • 状態(State)を直接変更せず、validation を通過したメールアドレス文字列を callback する
  • PasswordConfirmationForm
    • パスワードとその確認の入力を受け付けるのみ
      • 補助的なコンポーネントであり、独立して存在することができない
    • 状態(State)を直接変更せず、validation を通過したパスワード文字列を callback する
  • SignUpForm
    • 「サービスへのサインアップ」という機能そのものを提供
      • 単体で一つの機能を実現でき、独立して存在することができる
    • validation を通過したメールアドレスとパスワードを受け取り、状態(State)を更新する

のように役割を充てることができ、それぞれの粒度を決定することができます。

Templates

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

Organisms 以下のコンポーネントでは「上位層のレイヤーに大きさや表示位置の決定権を委ねる」ように実装しましたが、それらの決定権を最終的に持つのがこの Templates 層です。

例)サインアップ画面

class SignUpTemplate extends StatelessWidget {
  const SignUpTemplate();

  
  Widget build(BuildContext context) {
    return Center(
      child: SizedBox(
        height: 400,
        child: SignUpForm(),
      ),
    );
  }
}

サインアップ画面の Template は、Organisms である SignUpForm の高さを 400dp に、表示位置を画面中央に指定し、サインアップ画面の Wireframe を提供します。

Pages

Pages はユーザがプロダクト上で実際に触れるものであり、役割としては Template 層を介してコンテンツやルーティングをコンポーネントに接続することです。

接続されるコンテンツを非同期で取得する場合、「読み込み中に表示する画面」、「取得が完了した際に表示する画面」、「取得時にエラーが発生した際に表示する画面」のパターンが考えられます。
それらの状況に応じて表示する Template を切り替えるのも Pages 層の責務です。

Templates 層でレイアウト、Pages 層でコンテンツと責務を分割することができ、コンテンツに依存することなくレイアウトのテストを行うことが可能です。

例)サインアップページ

class SignUpPage extends StatelessWidget {
  const SignUpPage();

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("Sign up")),
      body: const SafeArea(
        child: SignUpTemplate(),
      ),
    );
  }
}

サインアップページは、SignUpTemplate を表示するためのコンポーネントです。

ビジネスロジックは SignUpForm が、コンポーネントの配置などの Wireframe 部分は SignUpTemplate が担っているため、Page はそれらを表示するのみです。

例)コンテンツフィードページ

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

class SignUpPage extends ConsumerWidget {
  const SignUpPage();

  
  Widget build(BuildContext context, WidgetRef ref) {
    final AsyncValue<List<String>> state = ref.watch(contentsProvider);

    return Scaffold(
      appBar: AppBar(title: const Text("Sign up")),
      body: const SafeArea(
        child: state.when(
          data: (List<String> contents) => ContentFeedTemplate(contents: contents),
          loading: () => LoadingTemplate(),
          error: (Object error, StackTrace stackTrace) => ErrorTemplate(),
        ),
      ),
    );
  }
}

List<String> である contents は非同期で取得されるコンテンツであり、「読み込み中」、「取得完了」、「取得エラー」のパターンが考えられるため、その 3 パターンに応じた Template を指定し、パターンごとの画面を表示します。

まとめ

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

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

皆さんの疑問点や感想をぜひコメントとして残していってください!

GitHubで編集を提案

Discussion