Flutter x Atomic Design で発生する悩みをズバッと解決したお話
はじめに
Flutter は宣言的 UI フレームワークを採用しており、階層的に UI を実装することが可能となっています。
Flutter を始めとして SwiftUI や React Native などの台頭により、近年では宣言的 UI でのネイティブアプリ開発がトレンドとなっていますが、その原型と言えるものは HTML の言語仕様として Web フロント開発では既に定着していました。
その Web フロント開発において注目されているコンポーネント設計手法があります。それが Atomic Design です。
しかし、Atomic Design を導入した際に発生しやすい問題として、実装者の「Atomic Design の理念と基本性質」への理解度や習熟度によってコンポーネント粒度のバラつきが発生しやすいことが挙げられます。
上記の問題に対する解決アプローチとして、この記事では Flutter での Atomic Design を採用した UI コンポーネント実装を補助するためのインターフェースを紹介し、「コンポーネント粒度の統一」や「各粒度が果たす責務を意識しやすくなる」ことを目指します。
目次
- おさらい:Atomic Design とは?
- Atomic Design の補助をするインターフェース
おさらい:Atomic Design とは?
プラグインの紹介に入る前に、Atomic Design の理念と基本性質について軽くおさらいをします。
Atomic Design は、小さい UI コンポーネントを組み合わせてより大きなコンポーネントを作っていくための設計フレームワークです。
コンポーネントを化学要素(原子と物体)に見立て、それらを組み合わせることで画面を構成します。
各コンポーネントの粒度と責務は、以下のようになっています。
- Atoms(原子)
- あらゆる UI コンポーネントの最小単位
- 責務:デザインの統一性
- Molecules(分子)
- 機能を組み合わせてユーザの具体的な動機に応える
- 責務:行動を阻害しない操作性
- Organisms(有機体)
- コンポーネントで完結するコンテンツを提供
- 責務:ユーザの行動を促すコンテンツ
- Templates(テンプレート)
- ページの雛形
- コンポーネントがページ上で正しくレイアウトされるかを確認する
- 責務:画面全体のレイアウト
- Pages(ページ)
- Template 層のコンポーネントに実際のコンテンツを流し込んだもの
- 責務:コンテンツそのもの
そして、Atomic Design では小さいコンポーネントを組み合わせて大きいコンポーネントを作るため、(実際の自然界と同様に) 小さいコンポーネントが大きなコンポーネントを含まない という依存の方向性が存在します。
Atomic Design の補助をするインターフェース
Atomic Design を踏襲したコンポーネント実装は、「Atomic Design の理念と基本性質」への理解度や習熟度によってコンポーネント粒度のバラつきが発生しやすく、気づけばカオスな構成になっていることもしばしばあります。
そこで、コンポーネント粒度に対する理解や責務の遵守を目的とした、各粒度のコンポーネント実装を補助するインターフェースを定義しました。
Atoms(原子)
Atoms 層は あらゆる UI コンポーネントの最小単位 であり、対象としては、
- プラットフォームのデフォルトUI
- プラットフォームのデファクト・スタンダードなUI
- レイアウト・パターン
- セマンティックな(意味を付加するような)デザイン要素
となります。
これらを実装するためのインターフェースを定義すると、以下になります。
abstract class Wrapper extends StatelessWidget {
const Wrapper({
super.key,
final EdgeInsetsGeometry padding = EdgeInsets.zero,
final EdgeInsetsGeometry margin = EdgeInsets.zero,
}) : _padding = padding,
_margin = margin;
final EdgeInsetsGeometry _padding;
final EdgeInsetsGeometry _margin;
Widget? buildWrapper(
final BuildContext context,
final Widget? child,
) =>
// padding や margin が指定されている場合は Padding で子要素を包含する
_padding != EdgeInsets.zero || _margin != EdgeInsets.zero
? Padding(
padding: _padding.add(_margin),
child: child,
)
: null;
}
Wrapper
は、コンポーネントの padding や margin を指定するためのインターフェースです。
上記は Stateless Widget 用となり、Stateful Widget 用の wrapper も同様に定義することが可能です。
abstract class AtomWidget extends Wrapper {
const AtomWidget({
super.key,
super.padding,
super.margin,
});
// Material Design に則っているコンポーネントを実装
Widget buildMaterial(final BuildContext context);
// iOS スタイルに則っているコンポーネントを実装
Widget? buildCupertino(final BuildContext context) => null;
Widget build(final BuildContext context) {
final Widget? cupertinoWidget = buildCupertino(context);
final Widget materialWidget = buildMaterial(context);
// OS に応じてコンポーネントを出し分け
final Widget child = CupertinoUserInterfaceLevel.maybeOf(context) != null
? cupertinoWidget ?? materialWidget
: materialWidget;
return buildWrapper(context, child) ?? child;
}
}
Atoms 層はあらゆる UI コンポーネントの最小単位であるため、状態(State)に関心を持ちません。そのため、Stateless Widget を拡張したインターフェースとして定義しています。
以下の利用例を参考に、実装イメージを膨らませてみてください。
例)角丸な ElevatedButton
class StadiumElevatedButton extends AtomWidget {
const StadiumElevatedButton({
super.key,
super.padding,
super.margin,
required VoidCallback onPressed,
required Widget child,
}): _onPressed = onPressed,
_child = child;
final VoidCallback _onPressed;
final Widget _child;
Widget buildMaterial(final BuildContext context) {
return ElevatedButton(
style: ElevatedButton.styleFrom(), // デザインガイドで指定されているスタイル
onPressed: _onPressed,
child: _child,
);
}
}
例)OS ごとにスタイルの異なる TextField
class ProperlyTextField extends AtomWidget {
const StadiumElevatedButton({
super.key,
super.padding,
super.margin,
required ValueChanged<String> onChanged,
}): _onPressed = onPressed;
final ValueChanged<String> _onChanged;
final Widget _child;
Widget buildMaterial(final BuildContext context) {
return TextField(
decoration: InputDecoration(), // デザインガイドで指定されているスタイル
onChanged: _onChanged,
);
}
Widget buildCupertino(final BuildContext context) {
return CupertinoTextField(
decoration: BoxDecoration(), // デザインガイドで指定されているスタイル
onChanged: _onChanged,
);
}
}
Molecules(分子)
Molecules 層が担っているのは、ユーザが意識してやりたいと思っていることに対して機能を提供する ことです。
「ユーザが意識してやりたいと思っていることに対する機能の提供」は「 具体的な操作に応えるコンポーネントの提供 」と言い換えられ、Molecules 層で実装したコンポーネントを通じて、ユーザは 提供された機能に対する操作 を実現します。
この役割を意識しつつインターフェースを定義すると、以下になります。
abstract class MoleculeWidget extends Wrapper {
const MoleculeWidget({
super.key,
super.padding,
super.margin,
});
Widget buildMolecule(final BuildContext context);
Widget build(final BuildContext context) {
final Widget child = buildMolecule(context);
return buildWrapper(context, child) ?? child;
}
}
Molecules 層は 具体的な操作に応える ことを責務としているため、コンポーネント操作の内容は扱いますが、操作の結果をビジネスロジックに伝えることは意識しません。
また、Molecules 層も Atoms 層と同様に、汎用的に利用されることを意識する粒度です。
これらの特徴から、Molecules 層も状態(State)に関心を持たないため、Stateless Widget を拡張したインターフェースとして定義しています。
以下の利用例を参考に、実装イメージを膨らませてみてください。
例)パスワード設定フォーム
class ConfirmPasswordForm extends MoleculeWidget {
const ConfirmPasswordForm({
super.key,
super.padding,
super.margin,
required ValueChanged<String> onChangedPassword,
required ValueChanged<String> onChangedConfirmString,
}): _onChangedPassword = onChangedPassword,
_onChangedConfirmString = onChangedConfirmString;
final ValueChanged<String> _onChangedPassword;
final ValueChanged<String> _onChangedConfirmString;
Widget buildMolecule(final BuildContext context) {
return Column(
children: <Widget>[
TextFormField(
key: const Key("PasswordForm"),
decoration: InputDecoration(
labelText: "パスワード *",
),
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.next,
obscureText: true,
enableSuggestions: false,
onChanged: _onChangedPassword,
validator: (final 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: _onChangedConfirmString,
validator: (final String? value) {
// TODO: 設定するパスワードを確認する
},
autovalidateMode: AutovalidateMode.onUserInteraction,
),
],
);
}
}
Organisms(有機体)
Orgamism 層が担っているのは、コンポーネントで完結するコンテンツの提供 です。
「コンポーネントで完結するコンテンツの提供」は「 ビジネスロジックに触れるインターフェースの提供 」と言い換えられ、Organisms 層で実装したコンポーネントを通じて、ユーザは 機能そのもの にアクセスします。
この役割を意識しつつインターフェースを定義すると、以下になります。
abstract class StatefulOrganismWidget extends StatefulWrapper {
// ignore: public_member_api_docs
const StatefulOrganismWidget({
super.key,
super.padding,
super.margin,
});
OrganismState<StatefulOrganismWidget> createState();
}
abstract class OrganismState<T extends StatefulOrganismWidget> extends WrapperState<T> {
/// Build a organism widget.
Widget buildOrganism(final BuildContext context);
Widget build(final BuildContext context) {
final Widget child = buildOrganism(context);
return buildWrapper(context, child) ?? child;
}
}
Organisms 層の責務は ビジネスロジックに触れるインターフェースの提供 であり、状態(State)の更新やビジネスロジックの発火などを担当します。そのため、Stateful Widget を拡張したインターフェースとして定義しています。
もちろん、状態の更新を行う必要がない場合は、Stateless Widget を元に定義することも可能です。
以下の利用例を参考に、実装イメージを膨らませてみてください。
例)サインアップフォーム
class SignUpForm extends OrganismWidget {
const SignUpForm({
super.key,
super.padding,
super.margin,
});
Widget buildOrganims(final BuildContext context) {
return 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(bottom: 4),
onChanged: (String email) {
// TODO: メールアドレスを扱う状態(State)の更新
},
),
ConfirmPasswordForm(
key: Key("SignUpForm_ConfirmPassword"),
onChangedPassword: (String password) {
// TODO: パスワードを扱う状態(State)の更新
},
onChangedConfirmString: (String confirmedString) {
// 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"),
),
),
),
],
),
);
}
}
Templates(テンプレート)
Templates 層が担っているのは、Organisms 層や Molecules 層、Atoms 層などのコンポーネントを 実際のサービスのページと同様に配置する ことです。
この役割を意識しつつインターフェースを定義すると、以下になります。
abstract class AdaptiveWidget extends StatelessWidget {
const AdaptiveWidget({
super.key,
});
/// Build a widget for small handset.
Widget? buildSmallHandset(final BuildContext context) => null;
/// Build a widget for medium handset.
Widget? buildMediumHandset(final BuildContext context) => null;
/// Build a widget for large handset.
Widget? buildLargeHandset(final BuildContext context) => null;
/// Build a widget for small tablet.
Widget? buildSmallTablet(final BuildContext context) => null;
/// Build a widget for large tablet.
Widget? buildLargeTablet(final BuildContext context) => null;
/// Build a widget for destop.
Widget? buildDesktop(final BuildContext context) => null;
}
AdaptiveWidget
は、画面サイズに応じたレイアウトを組み立てるためのインターフェースです。スマートフォンやタブレット、デスクトップなどに対応しています。
abstract class TemplateWidget extends AdaptiveWidget {
const TemplateWidget({
super.key,
});
/// Build a template widget.
Widget buildTemplate(final BuildContext context);
/// Build a safe area.
SafeArea buildSafeArea(
final BuildContext context,
final Widget child,
) =>
SafeArea(child: child);
Widget build(final BuildContext context) => buildSafeArea(
context,
LayoutBuilder(
builder: (
final BuildContext context,
final BoxConstraints constraints,
) =>
// 表示領域から最適なレイアウトを選択
// AdaptiveWidget で提供されている各種 build メソッドを利用していない場合は
// buildTemplate で組み立てたレイアウトを使用
LayoutType.fromConstraints(constraints).when(
smallHandset: () =>
buildSmallHandset(context) ?? buildTemplate(context),
mediumHandset: () =>
buildMediumHandset(context) ??
buildSmallHandset(context) ??
buildTemplate(context),
largeHandset: () =>
buildLargeHandset(context) ??
buildMediumHandset(context) ??
buildSmallHandset(context) ??
buildTemplate(context),
smallTablet: () =>
buildSmallTablet(context) ??
buildLargeHandset(context) ??
buildMediumHandset(context) ??
buildSmallHandset(context) ??
buildTemplate(context),
largeTablet: () =>
buildLargeTablet(context) ??
buildSmallTablet(context) ??
buildLargeHandset(context) ??
buildMediumHandset(context) ??
buildSmallHandset(context) ??
buildTemplate(context),
desktop: () =>
buildDesktop(context) ??
buildLargeTablet(context) ??
buildSmallTablet(context) ??
buildLargeHandset(context) ??
buildMediumHandset(context) ??
buildSmallHandset(context) ??
buildTemplate(context),
),
),
);
}
Templates 層の責務は 画面を組み立てるワイヤフレーム であるため、状態(State)に関心を持ちません。そのため、Stateless Widget を拡張したインターフェースとして定義しています。
以下の利用例を参考に、実装イメージを膨らませてみてください。
例)サインアップ画面
class SignUpTemplate extends TemplateWidget {
const SignUpTemplate({
super.key,
});
Widget buildLargeHandset(final BuildContext context) {
return Center(
child: SizedBox(
height: 300, // スマホでは高さを 300dp に
child: SignUpForm(),
)
);
}
Widget buildTemplate(final BuildContext context) {
return Center(
child: SizedBox(
height: 400, // タブレットやデスクトップでは高さを 400dp に
child: SignUpForm(),
)
);
}
}
Page(ページ)
Pages 層が担っているのは、Template 層を介してコンテンツやルーティングをコンポーネントに接続する ことです。画面遷移のルーティングや、画面遷移元や DataSource から受け取ったデータを Template に流し込み、ユーザが実際に触れる画面を表示します。
abstract class StatefulPageWidget extends StatefulWidget {
const StatefulPageWidget({
super.key,
});
PageState<StatefulPageWidget> createState();
}
abstract class PageState<T extends StatefulPageWidget> extends State<T> {
/// Build a scaffold body.
Widget buildBody(final BuildContext context);
// Material Design に則っている Scaffold を実装
Scaffold buildMaterialScaffold(
final BuildContext context, {
required final Widget body,
final PreferredSizeWidget? appBar,
final Widget? floatingActionButton,
}) {
// 子要素を Template に制限することで、実装のバラつきが発生しない
if (body is! TemplateWidget) {
throw const AtomicWidgetException.shouldUseTemplateWidget();
}
return Scaffold(
appBar: appBar,
body: body,
floatingActionButton: floatingActionButton,
);
}
// iOS スタイルに則っている Scaffold を実装
CupertinoPageScaffold? buildCupertinoScaffold(
final BuildContext context, {
required final Widget child,
final ObstructingPreferredSizeWidget? navigationBar,
}) {
// 子要素を Template に制限することで、実装のバラつきが発生しない
if (child is! TemplateWidget) {
throw const AtomicWidgetException.shouldUseTemplateWidget();
}
return CupertinoPageScaffold(
navigationBar: navigationBar,
child: child,
);
}
/// Build a app bar.
PreferredSizeWidget? buildMaterialAppBar(final BuildContext context) =>
AppBar();
/// Build a cupertino navigation bar.
ObstructingPreferredSizeWidget? buildCupertinoNavigationBar(
final BuildContext context,
) =>
const CupertinoNavigationBar();
/// Build a floating action button.
Widget? buildMaterialFloatingActionButton(final BuildContext context) => null;
Widget build(final BuildContext context) {
final CupertinoPageScaffold? cupertinoPageScaffold = buildCupertinoScaffold(
context,
navigationBar: buildCupertinoNavigationBar(context),
child: buildBody(context),
);
final Scaffold materialScaffold = buildMaterialScaffold(
context,
appBar: buildMaterialAppBar(context),
body: buildBody(context),
floatingActionButton: buildMaterialFloatingActionButton(context),
);
// OS に応じてコンポーネントを出し分け
return CupertinoUserInterfaceLevel.maybeOf(context) != null
? cupertinoPageScaffold ?? materialScaffold
: materialScaffold;
}
}
Pages 層の責務は Template 層へのコンテンツやルーティングの接続 であり、コンテンツの状態(State)に関心を持ちます。そのため、Stateful Widget を拡張したインターフェースとして定義しています。
もちろん、Template へ流し込むコンテンツが immutable である場合は、Stateless Widget を元に定義することも可能です。
以下の利用例を参考に、実装イメージを膨らませてみてください。
例)サインアップページ
class SignUpPage extends PageWidget {
const SignUpPage({
super.key,
super.padding,
super.margin,
});
Widget buildBody(final BuildContext context) {
return SignUpTemplate();
}
CupertinoPageScaffold? buildCupertinoScaffold(
final BuildContext context, {
required final Widget child,
final ObstructingPreferredSizeWidget? navigationBar,
}) =>
null; // iOS スタイルを利用しない
}
まとめ
この記事では Atomic Design を導入する際の悩みである「コンポーネント粒度にバラつきが発生してしまう」ことや「各粒度のコンポーネントが責務を忠実に果たせない」ことに対する解決アプローチとして、各粒度に対応したインターフェースを定義し、それを利用することで「コンポーネント粒度の統一」と「各粒度が果たす責務の意識」が行いやすくなりました。
みなさんも上記インターフェースを活用し、Atomic Design による汎用性の高い堅牢な View 設計を学びましょう!
皆さんの疑問点や感想をぜひコメントとして残していってください!
Discussion