🫶

詳解 Flutter Widgetbook

2023/12/21に公開

この記事はGENDA Advent Calendar 2023 の21日目の記事です。
https://qiita.com/advent-calendar/2023/genda

この記事の要約

  • Flutter の Widgetbook の実装方法が詳細にまとめてあります
  • これから Widgetbook の導入を考えている方向けの内容です

Widgetbook

Widgetbook は、Flutter の Widget を組織化し、視覚化するためのツールです。
Widget をカタログ化し、それらを異なる状態とデバイスで表示することができます。

https://docs.widgetbook.io/

Discover Widgetbook, a powerful Flutter package inspired by Storybook.js that simplifies the process of cataloging widgets, testing them across various devices and themes, and sharing them effortlessly with designers and clients.

Widgetbook 導入のモチベーション

下記が実現されることを期待しています。

  • 実装済みの Widget の一覧を視覚的に把握可能
  • 様々なデバイスで手軽にレイアウト確認できる
  • ダークモードの確認が簡単に行える
  • デザイナーとのデザイン確認の連携が非常に簡単に行える

例えば、複数人開発をしているときに様々な画面で使われるコンポーネントが共通化されてすでに実装済みかをコードを読み込まずとも把握できたら嬉しいなどです。

Widgetbook 導入

公式のドキュメントに加えてサンプルが公開されていますのでそれらを参考に実装を進めていけば簡単に導入が可能です。
導入にあたって少し躓いたところなどがいくつかあったので自分へのメモの意味も込めて少し丁寧に解説をしていきます。

1.Widgetbook をインストールする

この記事を公開した 2023/12/21 時点では 3.7.0 が最新のバージョンです。
https://pub.dev/packages/widgetbook

widgetbook を単体で導入するよりも widgetbook_generatorwidgetbook_annotationと一緒に導入することでより簡単に実装ができるので今回はこの方法で進めていきます。

下記のコマンドを実行し各パッケージをインストールします

flutter pub add widgetbook widgetbook_annotation dev:widgetbook_generator

2.Widgetbook App のエントリーポイントを作成

下記のように記述した Widgetbook.dart を作成します。

widgetbook.dart
// widgetbook.dart

import 'package:flutter/material.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;

// Import the generated directories variable
import 'widgetbook.directories.g.dart';

void main() {
  runApp(const WidgetbookApp());
}

// The @App annotation generates a file containing
// a single variable called directories.
.App()
class WidgetbookApp extends StatelessWidget {
  const WidgetbookApp({super.key});

  
  Widget build(BuildContext context) {
    return Widgetbook.material(
      // Use the generated directories variable
      directories: directories,
    );
  }
}

ポイントは下記です。

  • ファイル名は好きに命名して問題ない(ex. main.widgetbook.dart など)
  • lib配下に追加する
  • この時点では directories が定義されていないためエラーとなるが問題なし

single app / separate app

現在開発中のアプリにエントリポイントを作成することもできますし、新しいアプリとしてエントリポイントを作成できます。(と公式ドキュメントに記述があります)

// single app
flutter_app
└─── lib
| └─── feature.dart
│ └─── main.dart
│ └─── main.widgetbook.dart
└─── pubspec.yaml

// separate app
flutter_app
└─── feature_1
└─── app
|    └───lib
|    |    └─── main.dart
|    └─── pubspec.yaml
└─── widgetbook_app
|    └─── lib
|    |    └─── main.widgetbook.dart
|    └─── pubspec.yaml

引用: AlenaNicolay / Directories

この記事は single app の想定で執筆しています。

Component と Use Case

widgetbook では widget を Component と呼び、この Component が単一または複数の Use Case を持つ構造となっています。

In Figma, components are reusable UI elements that can have multiple variants. For consistency, in Widgetbook, Widgets are referred to as components.
Widgetbook follows a component and use-case approach, with a single component having one or multiple use cases. A use-case can be a variant of a component or represent a specific component state.
Developers use a folder tree structure to organize and catalog components in Widgetbook.

3.Annotation を追加する

ここからは UI カタログに表示したい Widget に Annotation をつけて UI カタログを作成する準備に入っていきます。

下記は現在開発しているプロジェクトで自前実装している CustomTextField です。

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

class CustomTextField extends StatelessWidget {

  const CustomTextField({
    super.key,
    required this.controller,
    required this.label,
    this.keyboardType,
    this.obscureText = false,
    this.hintText,
  });
  final TextEditingController controller;
  final String label;
  final TextInputType? keyboardType;
  final bool obscureText;
  final String? hintText;

  
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10),
      decoration: BoxDecoration(
        border: Border.all(color: const Color.fromRGBO(121, 116, 126, 1)),
        borderRadius: BorderRadius.circular(4),
      ),
      child: TextField(
        controller: controller,
        obscureText: obscureText,
        keyboardType: keyboardType,
        decoration: InputDecoration(
          labelText: label,
          border: InputBorder.none,
          hintText: hintText,
        ),
      ),
    );
  }
}

この Widget に Annotation をつけます。具体的にやることは下記の2点です。

  • widgetbook_annotation.dart を import する
  • @widgetbook.UseCase を実装する

対応したものが下記となります。

text_field.dart
import 'package:flutter/material.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;

class CustomTextField extends StatelessWidget {

  const CustomTextField({
    super.key,
    required this.controller,
    required this.label,
    this.keyboardType,
    this.obscureText = false,
    this.hintText,
  });
  final TextEditingController controller;
  final String label;
  final TextInputType? keyboardType;
  final bool obscureText;
  final String? hintText;

  
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10),
      decoration: BoxDecoration(
        border: Border.all(color: const Color.fromRGBO(121, 116, 126, 1)),
        borderRadius: BorderRadius.circular(4),
      ),
      child: TextField(
        controller: controller,
        obscureText: obscureText,
        keyboardType: keyboardType,
        decoration: InputDecoration(
          labelText: label,
          border: InputBorder.none,
          hintText: hintText,
        ),
      ),
    );
  }
}

.UseCase(
  name: 'CustomTextField',
  type: CustomTextField,
)
CustomTextField customTextField(BuildContext context) {
  final TextEditingController emailController = TextEditingController();
  return CustomTextField(controller: emailController, label: 'メールアドレス');
}

@widgetbook.UseCase の引数には下記のような感じで path を設定することも可能です。

text_filed.dart
.UseCase(
  name: 'CustomTextField',
  type: CustomTextField,
  path: '[widgets]/textFields',
)

pathを設定すると Widgetbook をビルドした時に階層が整理されます。
必要に応じて設定してください。

4.コードジェネレータを実行する

下記のコマンドを実行すると widgetbook.directories.g.dart というファイルが自動生成されます。とても便利😊

flutter pub run build_runner build --delete-conflicting-outputs

自動生成されたファイルは下記です。

widgetbook.directories.g.dart
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_import, prefer_relative_imports, directives_ordering

// GENERATED CODE - DO NOT MODIFY BY HAND

// **************************************************************************
// AppGenerator
// **************************************************************************

// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:ff_flutter/components/text_field.dart' as _i2;
import 'package:widgetbook/widgetbook.dart' as _i1;

final directories = <_i1.WidgetbookNode>[
  _i1.WidgetbookCategory(
    name: 'widgets',
    children: [
      _i1.WidgetbookFolder(
        name: 'textFields',
        children: [
          _i1.WidgetbookLeafComponent(
            name: 'CustomTextField',
            useCase: _i1.WidgetbookUseCase(
              name: 'CustomTextField',
              builder: _i2.customTextField,
            ),
          )
        ],
      )
    ],
  )
];


先ほどまで、widgetbook.dart 内の directories でエラーが出ていたと思いますがこのファイルを自動生成したことで解消されたと思います。

5.Widgetbook を実行する

下記のコマンドを実行してください。

flutter run -d chrome -t lib/widgetbook.dart

Widgetbook にて先ほどの CustomTextField が確認できるようになりました🎉

ここまでが Widgetbook の基本的な導入方法になります。

Widgetbook を便利に使う

ここからは Widgetbook をより便利に使うための設定について見ていきます。

Addon

Addon を使用することで Theme ・ Device ・ TextScale ・ TextLocale などを手軽に切り替えて確認することが可能になります。

最初の方に実装した widgetbook.dart を下記のように変更します。

widgetbook.dart
// widgetbook.dart

import 'package:flutter/material.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;

// Import the generated directories variable
import 'widgetbook.directories.g.dart';

void main() {
  runApp(const WidgetbookApp());
}

// The @App annotation generates a file containing
// a single variable called directories.
.App()
class WidgetbookApp extends StatelessWidget {
  const WidgetbookApp({super.key});

  
  Widget build(BuildContext context) {
    return Widgetbook.material(
      // Use the generated directories variable
      directories: directories,
      addons: <WidgetbookAddon>[
        MaterialThemeAddon(
          themes: <WidgetbookTheme<ThemeData>>[
            WidgetbookTheme<ThemeData>(
              name: 'Light',
              data: ThemeData.light(),
            ),
            WidgetbookTheme<ThemeData>(
              name: 'Dark',
              data: ThemeData.dark(),
            ),
          ],
        ),
        DeviceFrameAddon(
          devices: <DeviceInfo>[
            Devices.ios.iPhoneSE,
            Devices.ios.iPhone13,
          ],
        ),
        TextScaleAddon(
          scales: <double>[1.0, 2.0],
        ),
        LocalizationAddon(
          locales: <Locale>[
            const Locale('en', 'US'),
          ],
          localizationsDelegates: <LocalizationsDelegate<dynamic>>[
            DefaultWidgetsLocalizations.delegate,
            DefaultMaterialLocalizations.delegate,
          ],
        ),
      ],
    );
  }
}

chrome でビルドすると下記のようになります。ダークモード時の確認などが手軽に行えます🤩

knob

knob を使用することで Widgetbook から直接、値や状態の変更を行うことが可能です。
先ほどの CustomTextField を例に見てみます。

textfield のラベルには「メールアドレス」という文字がセットされていますが、使う画面によってラベルの文字は変わります。label に違う文字ををセットしたらどういった見た目になるかを手軽に確認したい時に knob が効果的です。

先ほどの text_field.dart の label の引数を下記のように変更します。

text_field.dart
.UseCase(
  name: 'CustomTextField',
  type: CustomTextField,
  path: '[widgets]/textFields',
)
CustomTextField customTextField(BuildContext context) {
  final TextEditingController emailController = TextEditingController();
  return CustomTextField(controller: emailController, label: context.knobs.string(label: 'メールアドレス', initialValue: 'メールアドレスを入力してください'));
}

build_runner を実行して chrome で再度実行してみると下記のように直接値を変更することが可能となります。

まとめ

Widgetbook は導入が簡単で魅力的なツールです。
社内からのみアクセス可能な場所へホスティングすることでデザイナーなど他チームとの連携も非常に効率よく行えると思います。

今後は Github Actions などと連携して定期的にデプロイする仕組みなどを作り zenn などで共有できればと思います。

Discussion