📦

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

30 min read

はじめに

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 ElevatedButtonStyle {
  const ElevatedButtonStyle({
    this.margin,
    this.padding,
    this.primary,
    this.onPrimary,
    this.onSurface,
    this.shadowColor,
    this.elevation,
    this.textStyle,
    this.minimumSize,
    this.fixedSize,
    this.side,
    this.shape,
    this.enabledMouseCursor,
    this.disabledMouseCursor,
    this.visualDensity,
    this.tapTargetSize,
    this.animationDuration,
    this.alignment,
    this.splashFactory,
  });

  final EdgeInsetsGeometry? margin;
  final EdgeInsetsGeometry? padding;
  final Color? primary;
  final Color? onPrimary;
  final Color? onSurface;
  final Color? shadowColor;
  final double? elevation;
  final TextStyle? textStyle;
  final Size? minimumSize;
  final Size? fixedSize;
  final BorderSide? side;
  final OutlinedBorder? shape;
  final MouseCursor? enabledMouseCursor;
  final MouseCursor? disabledMouseCursor;
  final VisualDensity? visualDensity;
  final MaterialTapTargetSize? tapTargetSize;
  final Duration? animationDuration;
  final AlignmentGeometry? alignment;
  final InteractiveInkFeatureFactory? splashFactory;
}

class StylableElevatedButton extends StatelessWidget {
  const StylableElevatedButton({
    required this.onPressed,
    required this.child,
    Key? key,
    this.style = const ElevatedButtonStyle(),
    this.onLongPress,
    this.enableFeedback,
    this.focusNode,
    this.autofocus = false,
    this.clipBehavior = Clip.none,
    this.enabled = true,
  }) : super(key: key);

  final ElevatedButtonStyle style;
  final VoidCallback? onPressed;
  final Widget? child;
  final VoidCallback? onLongPress;
  final bool? enableFeedback;
  final FocusNode? focusNode;
  final bool autofocus;
  final Clip clipBehavior;
  final bool enabled;

  
  Widget build(BuildContext context) => Container(
        margin: style.margin,
        child: ElevatedButton(
          onPressed: enabled ? onPressed : null,
          onLongPress: enabled ? onLongPress : null,
          style: ElevatedButton.styleFrom(
            primary: style.primary,
            onPrimary: style.onPrimary,
            onSurface: style.onSurface,
            shadowColor: style.shadowColor,
            elevation: style.elevation,
            textStyle: style.textStyle,
            padding: style.padding,
            minimumSize: style.minimumSize,
            fixedSize: style.fixedSize,
            side: style.side,
            shape: style.shape,
            enabledMouseCursor: style.enabledMouseCursor,
            disabledMouseCursor: style.disabledMouseCursor,
            visualDensity: style.visualDensity,
            tapTargetSize: style.tapTargetSize,
            animationDuration: style.animationDuration,
            enableFeedback: enableFeedback,
            alignment: style.alignment,
            splashFactory: style.splashFactory,
          ),
          focusNode: focusNode,
          autofocus: autofocus,
          clipBehavior: clipBehavior,
          child: child,
        ),
      );
}

Button の責務と関心

Button コンポーネントの責務と関心は、

  • 責務: ユーザのタップ操作に対して反応する
  • 関心: ユーザからのインタラクション(タップされた、長押しされた、etc)

となります。

Button コンポーネントが UI として関心を持つべきは ユーザのタップ操作に対して反応する ということのみで、Button の形や大きさ、どんなラベル / アイコンを内部で表示するか、にまで関心をもたせてしまうのは過剰です。

Button の実装

基本的には プラットフォームのデフォルトUI として実装したいので、Flutter の Widget である RaisedButton の wrapper として実装します。

意識する点としては、

  • 上位層のコンポーネントからボタン上に表示するラベル / アイコンは任意のものを表示できるように、child 引数を提供
  • ButtonStyle を実装することで、上位層のコンポーネントが Button の大きさや座標を自由に決定する事ができるように
  • Button 押下時のイベントはそのまま上位層のコンポーネントに渡し、Button それ自体がロジックや処理を持たない

です。

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

text_form_field.dart
import "package:flutter/foundation.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";

class TextFormFieldStyle {
  const TextFormFieldStyle({
    this.margin,
    this.labelStyle,
    this.helperStyle,
    this.helperMaxLines,
    this.hintStyle,
    this.hintTextDirection,
    this.hintMaxLines,
    this.errorStyle,
    this.errorMaxLines,
    this.floatingLabelBehavior,
    this.isCollapsed = false,
    this.isDense,
    this.contentPadding,
    this.prefixIconConstraints,
    this.prefixStyle,
    this.suffixIconConstraints,
    this.suffixStyle,
    this.counterStyle,
    this.filled,
    this.fillColor,
    this.focusColor,
    this.hoverColor,
    this.errorBorder,
    this.focusedBorder,
    this.focusedErrorBorder,
    this.disabledBorder,
    this.enabledBorder,
    this.border,
    this.alignLabelWithHint,
    this.textStyle,
    this.strutStyle,
    this.textDirection,
    this.textAlign = TextAlign.start,
    this.textAlignVertical,
    this.expands = false,
    this.cursorWidth = 2.0,
    this.cursorHeight,
    this.cursorRadius,
    this.cursorColor,
    this.keyboardAppearance,
    this.scrollPadding = const EdgeInsets.all(20),
    this.scrollPhysics,
  });

  final EdgeInsetsGeometry? margin;
  final TextStyle? labelStyle;
  final TextStyle? helperStyle;
  final int? helperMaxLines;
  final TextStyle? hintStyle;
  final TextDirection? hintTextDirection;
  final int? hintMaxLines;
  final TextStyle? errorStyle;
  final int? errorMaxLines;
  final FloatingLabelBehavior? floatingLabelBehavior;
  final bool isCollapsed;
  final bool? isDense;
  final EdgeInsetsGeometry? contentPadding;
  final BoxConstraints? prefixIconConstraints;
  final TextStyle? prefixStyle;
  final BoxConstraints? suffixIconConstraints;
  final TextStyle? suffixStyle;
  final TextStyle? counterStyle;
  final bool? filled;
  final Color? fillColor;
  final Color? focusColor;
  final Color? hoverColor;
  final InputBorder? errorBorder;
  final InputBorder? focusedBorder;
  final InputBorder? focusedErrorBorder;
  final InputBorder? disabledBorder;
  final InputBorder? enabledBorder;
  final InputBorder? border;
  final bool? alignLabelWithHint;
  final TextStyle? textStyle;
  final StrutStyle? strutStyle;
  final TextDirection? textDirection;
  final TextAlign textAlign;
  final TextAlignVertical? textAlignVertical;
  final bool expands;
  final double cursorWidth;
  final double? cursorHeight;
  final Radius? cursorRadius;
  final Color? cursorColor;
  final Brightness? keyboardAppearance;
  final EdgeInsets scrollPadding;
  final ScrollPhysics? scrollPhysics;
}

class StylableTextFormField extends StatelessWidget {
  const StylableTextFormField({
    Key? key,
    this.style = const TextFormFieldStyle(),
    this.controller,
    this.initialValue,
    this.focusNode,
    this.icon,
    this.labelText,
    this.helperText,
    this.hintText,
    this.errorText,
    this.prefixIcon,
    this.prefix,
    this.prefixText,
    this.suffixIcon,
    this.suffix,
    this.suffixText,
    this.counter,
    this.counterText,
    this.enabled = true,
    this.semanticCounterText,
    this.keyboardType,
    this.textCapitalization = TextCapitalization.none,
    this.textInputAction,
    this.autofocus = false,
    this.readOnly = false,
    this.copy = false,
    this.cut = false,
    this.paste = false,
    this.selectAll = false,
    this.showCursor,
    this.obscuringCharacter = "•",
    this.obscureText = false,
    this.autocorrect = true,
    this.smartDashesType,
    this.smartQuotesType,
    this.enableSuggestions = true,
    this.maxLengthEnforcement,
    this.maxLines = 1,
    this.minLines,
    this.maxLength,
    this.onChanged,
    this.onTap,
    this.onEditingComplete,
    this.onFieldSubmitted,
    this.onSaved,
    this.validator,
    this.inputFormatters,
    this.enableInteractiveSelection = true,
    this.selectionControls,
    this.buildCounter,
    this.autofillHints,
    this.autovalidateMode,
    this.scrollController,
  }) : super(key: key);

  final TextFormFieldStyle style;
  final TextEditingController? controller;
  final String? initialValue;
  final FocusNode? focusNode;
  final Widget? icon;
  final String? labelText;
  final String? helperText;
  final String? hintText;
  final String? errorText;
  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 bool enabled;
  final String? semanticCounterText;
  final TextInputType? keyboardType;
  final TextCapitalization textCapitalization;
  final TextInputAction? textInputAction;
  final bool autofocus;
  final bool readOnly;
  final bool copy;
  final bool cut;
  final bool paste;
  final bool selectAll;
  final bool? showCursor;
  final String obscuringCharacter;
  final bool obscureText;
  final bool autocorrect;
  final SmartDashesType? smartDashesType;
  final SmartQuotesType? smartQuotesType;
  final bool enableSuggestions;
  final MaxLengthEnforcement? maxLengthEnforcement;
  final int? maxLines;
  final int? minLines;
  final int? maxLength;
  final ValueChanged<String>? onChanged;
  final GestureTapCallback? onTap;
  final VoidCallback? onEditingComplete;
  final ValueChanged<String>? onFieldSubmitted;
  final FormFieldSetter<String>? onSaved;
  final FormFieldValidator<String>? validator;
  final List<TextInputFormatter>? inputFormatters;
  final bool enableInteractiveSelection;
  final TextSelectionControls? selectionControls;
  final InputCounterWidgetBuilder? buildCounter;
  final Iterable<String>? autofillHints;
  final AutovalidateMode? autovalidateMode;
  final ScrollController? scrollController;

  
  Widget build(BuildContext context) => Container(
        margin: style.margin,
        child: TextFormField(
          key: key,
          controller: controller,
          initialValue: initialValue,
          focusNode: focusNode,
          decoration: InputDecoration(
            icon: icon,
            labelText: labelText,
            labelStyle: style.labelStyle,
            helperText: helperText,
            helperStyle: style.helperStyle,
            helperMaxLines: style.helperMaxLines,
            hintText: hintText,
            hintStyle: style.hintStyle,
            hintTextDirection: style.hintTextDirection,
            hintMaxLines: style.hintMaxLines,
            errorText: errorText,
            errorStyle: style.errorStyle,
            errorMaxLines: style.errorMaxLines,
            floatingLabelBehavior: style.floatingLabelBehavior,
            isCollapsed: style.isCollapsed,
            isDense: style.isDense,
            contentPadding: style.contentPadding,
            prefixIcon: prefixIcon,
            prefixIconConstraints: style.prefixIconConstraints,
            prefix: prefix,
            prefixText: prefixText,
            prefixStyle: style.prefixStyle,
            suffixIcon: suffixIcon,
            suffixIconConstraints: style.suffixIconConstraints,
            suffix: suffix,
            suffixText: suffixText,
            suffixStyle: style.suffixStyle,
            counter: counter,
            counterText: counterText,
            counterStyle: style.counterStyle,
            filled: style.filled,
            fillColor: style.fillColor,
            focusColor: style.focusColor,
            hoverColor: style.hoverColor,
            errorBorder: style.errorBorder,
            focusedBorder: style.focusedBorder,
            focusedErrorBorder: style.focusedErrorBorder,
            disabledBorder: style.disabledBorder,
            enabledBorder: style.enabledBorder,
            border: style.border,
            enabled: enabled,
            semanticCounterText: semanticCounterText,
            alignLabelWithHint: style.alignLabelWithHint,
          ),
          keyboardType: keyboardType,
          textCapitalization: textCapitalization,
          textInputAction: textInputAction,
          style: style.textStyle,
          strutStyle: style.strutStyle,
          textDirection: style.textDirection,
          textAlign: style.textAlign,
          textAlignVertical: style.textAlignVertical,
          autofocus: autofocus,
          readOnly: readOnly,
          toolbarOptions: ToolbarOptions(
            copy: copy,
            cut: cut,
            paste: paste,
            selectAll: selectAll,
          ),
          showCursor: showCursor,
          obscuringCharacter: obscuringCharacter,
          obscureText: obscureText,
          autocorrect: autocorrect,
          smartDashesType: smartDashesType,
          smartQuotesType: smartQuotesType,
          enableSuggestions: enableSuggestions,
          maxLengthEnforcement: maxLengthEnforcement,
          maxLines: maxLines,
          minLines: minLines,
          expands: style.expands,
          maxLength: maxLength,
          onChanged: onChanged,
          onTap: onTap,
          onEditingComplete: onEditingComplete,
          onFieldSubmitted: onFieldSubmitted,
          onSaved: onSaved,
          validator: validator,
          inputFormatters: inputFormatters,
          enabled: enabled,
          cursorWidth: style.cursorWidth,
          cursorHeight: style.cursorHeight,
          cursorRadius: style.cursorRadius,
          cursorColor: style.cursorColor,
          keyboardAppearance: style.keyboardAppearance,
          scrollPadding: style.scrollPadding,
          enableInteractiveSelection: enableInteractiveSelection,
          selectionControls: selectionControls,
          buildCounter: buildCounter,
          scrollPhysics: style.scrollPhysics,
          autofillHints: autofillHints,
          autovalidateMode: autovalidateMode,
          scrollController: scrollController,
        ),
      );
}

TextFormField の責務と関心

TextFormField コンポーネントの責務と関心は、

  • 責務: ユーザのキーボード入力に対して反応する
  • 関心: ユーザからのキーボード入力

となります。

TextFormField コンポーネントが UI として関心を持つべきは ユーザのキーボード入力に対して反応する ということのみで、Button と同様に形や大きさにまで関心を持たせるべきではありません。

TextFormField の実装

この TextFormField も、Flutter の Widget である TextFormField の wrapper として実装します。

意識する点としては、

  • 上位層のコンポーネントからボタン上に表示するラベル / アイコンは任意のものを表示できるように、child 引数を提供
  • TextFormFieldStyle を実装することで、上位層のコンポーネントが TextFormField の大きさや座標を自由に決定する事ができるように
  • キーボード入力時のイベントはそのまま上位層のコンポーネントに渡し、TextFormField それ自体がロジックや処理を持たない

です。

Molecules

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

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

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

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

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

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

email_address_form.dart
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "/view/atom/text_form_field/text_form_field.dart";

class EmailAddressForm extends StatelessWidget {
  const EmailAddressForm({
    Key? key,
    this.margin,
    this.padding,
  }) : super(key: key);

  final EdgeInsetsGeometry? padding;
  final EdgeInsetsGeometry? margin;

  
  Widget build(BuildContext context) {
    return Container(
      padding: padding,
      margin: margin,
      child: StylableTextFormField(
        key: const Key("EmailAddressForm"),
        style: TextFormFieldStyle.outlined(),
        labelText: "メールアドレス *",
        keyboardType: TextInputType.emailAddress,
        textInputAction: TextInputAction.next,
        autofocus: true,
        enableSuggestions: false,
        onChanged: (String value) {
          // TODO: 入力された文字列を状態として保存する
        },
        validator: (String? value) {
          // TODO: メールアドレスの有効性を確認する
        },
        inputFormatters: <TextInputFormatter>[
          FilteringTextInputFormatter.deny(
            RegExp(r"\s"),
          ),
        ],
        autovalidateMode: AutovalidateMode.onUserInteraction,
      ),
    );
  }
}

EmailAddressForm の責務と関心

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

EmailAddressForm の責務として、

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

を持たせます。

EmailAddressForm の実装

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

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

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

password_confirmation_form.dart
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "/view/atom/text_form_field/text_form_field.dart";

class PasswordConfirmationForm extends StatelessWidget {
  const PasswordConfirmationForm({
    Key? key,
    this.margin,
    this.padding,
  }) : super(key: key);

  final EdgeInsetsGeometry? padding;
  final EdgeInsetsGeometry? margin;

  
  Widget build(BuildContext context) {
    return Container(
      padding: padding,
      margin: margin,
      child: Column(
        children: <Widget>[
          StylableTextFormField(
            key: const Key("PasswordForm"),
            style: TextFormFieldStyle.outlined(
              margin: const EdgeInsets.only(
                bottom: 8,
              ),
            ),
            labelText: "パスワード *",
            keyboardType: TextInputType.visiblePassword,
            textInputAction: TextInputAction.next,
            obscureText: true,
            enableSuggestions: false,
            onChanged: (String value) {
              // TODO: 入力された文字列を状態として保存する
            },
            validator: (String? value) {
              // TODO: パスワードの有効性を確認する
            },
            inputFormatters: <TextInputFormatter>[
              FilteringTextInputFormatter.deny(
                RegExp(r"\s"),
              ),
            ],
            autovalidateMode: AutovalidateMode.onUserInteraction,
          ),
          StylableTextFormField(
            key: const Key("ConfirmPasswordForm"),
            style: TextFormFieldStyle.outlined(),
            labelText: "パスワードの確認 *",
            keyboardType: TextInputType.visiblePassword,
            textInputAction: TextInputAction.done,
            obscureText: true,
            onChanged: (String value) {
              // TODO: 入力された文字列を状態として保存する
            },
            validator: (String? value) {
              // TODO: 設定するパスワードを確認する
            },
            autovalidateMode: AutovalidateMode.onUserInteraction,
          ),
        ],
      ),
    );
  }
}

PasswordConfirmationForm の責務と関心

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

PasswordConfirmationForm の責務として、

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

を持たせます。

PasswordConfirmationForm の実装

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

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

Organisms

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

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

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

sign_up_form.dart
import "package:flutter/material.dart";
import "/view/atom/button/raised_button.dart";
import "/view/atom/label/label.dart";
import "/view/molecule/email_address_form/widget.dart";
import "/view/molecule/password_confirmation_form/widget.dart";

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

  final EdgeInsetsGeometry? padding;
  final EdgeInsetsGeometry? margin;

  
  Widget build(BuildContext context) {
    return Container(
      padding: padding,
      margin: margin,
      child: LayoutBuilder(
        builder: (BuildContext _, 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),
                    ),
                    PasswordConfirmationForm(
                      key: Key("SignUpForm_PasswordConfirmation"),
                      margin: EdgeInsets.only(top: 8),
                    ),
                  ],
                ),
              ),
            ),
            Align(
              alignment: Alignment.bottomCenter,
              child: SizedBox(
                width: double.infinity,
                child: StylableElevatedButton(
                  key: const Key("SignUpForm_ConfirmButton"),
                  onPressed: () {
                    // TODO: サインアップをリクエストする
                  },
                  child: const StylableLabel("SIGN UP"),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

SignUpForm の責務と関心

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

SignUpForm の責務として、

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

の役割を持たせます。

SignUpForm の実装

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

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

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

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

  final EdgeInsetsGeometry margin;

  
  Widget build(BuildContext context) => Container(
        margin: 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) => Container(
        margin: 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:
  独立して存在できず、他のコンポーネントの機能を補助する役割が強いコンポーネント
Organisms:
  独立して存在することのできるコンポーネント

SignUpForm を例にとると、

  • EmailAddressForm
    • メールアドレスの入力を受け付けるのみ → 補助的なコンポーネントであり、独立して存在することができない
  • PasswordConfirmationForm
    • パスワードとその確認の入力を受け付けるのみ → 補助的なコンポーネントであり、独立して存在することができない
  • SignUpForm
    • 「サービスへのサインアップ」という機能そのものを提供 → 単体で一つの機能を実現でき、独立して存在することができる

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

Templates

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

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

例)サインアップ画面

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: Padding(
          padding: EdgeInsets.all(8.0),
          child: SignUpForm(),
        ),
      ),
    );
  }
}

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

Pages

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

例)サインアップページ

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 設計を学びましょう!

Discussion

ログインするとコメントできます