くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

FlutterのWigetbookに入門してみた

Flutter用のUIカタログ「Widgetbook
そろそろちゃんと使いたいなと思い、入門してみたときの備忘録(*´ω`*)

WebフロントエンドのStorybookと同じ感じ

Widgetbookとは

登録したWidgetを一覧にして表示を確認できるツール
公式のデモが用意されているので、これを見るとわかりやすい

いろいろとメリットがあるので便利

  • カタログとしてUIコンポーネントを一覧化できたり
  • エラーやローディングなどの確認/テストができたり
  • Webで出力して開発者以外に共有できたり

コンポーネントのカタログとしてもよいけど、
Widgetの一覧なので、画面とかでもOK

UIプロトとかはWidgetbook上で作ってみてからでもよいかもしれない

https://raw.githubusercontent.com/widgetbook/widgetbook/main/docs/assets/screenshots/widgetbook.png

簡単な使い方

公式だとこの辺り

Widgetbook用のプロジェクトを追加していく形
最終的なフォルダ構成はこんな感じ

your_app/            # 対象のアプリ
├── pubspec.yaml
├── lib/
└── widgetbook/      # UIカタログ
    ├── pubspec.yaml
    └── lib/

プロジェクトの追加/セットアップ

## プロジェクトの追加
$ flutter create widgetbook --empty --platforms=web,macos

$ cd widgetbook

## パッケージの追加
$ flutter pub add widgetbook widgetbook_annotation
$ flutter pub add dev:widgetbook_generator dev:build_runner

widgetbook/pubspec.yamlを以下のように変更する

- name: widgetbook
+ name: widgetbook_workspace

+ dependencies:
+   your_app:
+     path: ../

カタログ/UseCaseの追加

表示したいものを@UseCaseをつけて追加していく感じ

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

// your_appのUIをインポート
import 'package:your_app/cool_button.dart';

// カタログに表示するWidgetを返す関数をUsecaseとして追加
@widgetbook.UseCase(name: 'Default', type: CoolButton)
Widget buildCoolButtonUseCase(BuildContext context) {
  return CoolButton();
}

作成できたら、build_runnerを実行してコードを生成する

dart run build_runner build -d

Widgetbookのmainを追加

あとはWedgetbook用のAppを起動するmain.dartを用意すればOK

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

import 'main.directories.g.dart';

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

@widgetbook.App()
class WidgetbookApp extends StatelessWidget {
  const WidgetbookApp({super.key});

  @override
  Widget build(BuildContext context) {
    return Widgetbook.material(
      // UseCaseから生成されたコード。カタログ情報の一覧
      directories: directories,
    );
  }
}

起動すると、公式デモのような画面が表示される

flutter run

ディレクトリ構成例

ディレクトリ構成もいろんなパターンがある

  • アプリのプロジェクト参照する、別プロジェクトとして追加
  • monorepo構成にして、別プロジェクトとして追加
  • アプリのプロジェクトにmainを分けて組み込む

別プロジェクトで参照

Quick Startなど公式で紹介されている構成

your_app/            # 対象のアプリ
├── pubspec.yaml
├── lib/
└── widgetbook/      # UIカタログ
    ├── pubspec.yaml
    └── lib/

monorepo構成

Melosを利用したmonorepo構成の例
こちらも公式で紹介されている

./
├── your_app/        # 対象のアプリ
│   ├── pubspec.yaml
│   └── lib/
├── widgetbook/      # UIカタログ
│   ├── pubspec.yaml
│   └── lib/
└── pubspec.yaml     # monorepo root

同一プロジェクトの別main

アプリのコードと一緒にしてしまうパターン

your_app/
├── pubspec.yaml
└── lib/
    ├── main.dart        # 対象のアプリ
    └── main_widget.dart # UIカタログ

Widgetbookの構成要素

登場人物/構成要素は、そんなに多くなく、
これくらいを覚えておけば、よさそう

  • UseCase:
    • コンポーネントの一例
    • これを作ってカタログを増やしていく感じ
    • 1コンポーネント:1UseCaseが多いけど、 通常以外に読み込み中やエラーなどの状態/シナリオ/シーンなどを作る感じ
  • Knob:
    • 直訳すると、ノブ/取っ手
    • UseCaseのうち変更できるパラメタの指定
    • ex. textの文字やisLoadingのON/OFFを変えれるようにしたり
  • App:
    • Widgetbookを返す専用のApp
    • 全体の設定など
  • Addon:
    • 自由に追加できる拡張機能。公式で用意されている

UseCase

ユースケースは、対象のWidgetを返す関数を用意すればOK
細かい設定は、@UseCaseで設定する

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

@widgetbook.UseCase(
  type: ElevatedButton,
  name: 'Default',
  path: "components/buttons",
)
Widget elevatedDefault(BuildContext context) {
  return ElevatedButton(child: Text("Button"), onPressed: () {});
}

@widgetbook.UseCase(
  type: ElevatedButton,
  name: 'Secondary',
  path: "components/buttons",
)
Widget elevatedSecondary(BuildContext context) {
  return ElevatedButton(child: Text("Button"), onPressed: () {});
}

@widgetbook.UseCase(
  type: FilledButton,
  name: 'Default',
  path: "components/buttons",
)
Widget filledDefault(BuildContext context) {
  return FilledButton(child: Text("Button"), onPressed: () {});
}

Knob

長い文字を入れたときのデザインなどを確認したいこともある
Widgetbook上で、自由に値を変更にできる機能がKnob

@widgetbook.UseCase(
  name: 'Default',
  type: ElevatedButton,
  path: "components/buttons",
)
Widget elevatedDefault(BuildContext context) {
  return ElevatedButton(
    child: Text(
      context.knobs.string(label: "Button Label", initialValue: "Button"),
    ),
    onPressed: () {},
  );
}

bool/int/stringなど基本的な型のknobは用意されているが、
独自の型などは、Konb自体を実装して、Custom Knobを作ることもできる

App

Appでは、WidgetbookのThemeやAddonなどの全体の設定ができる

@widgetbook.App()
class WidgetbookApp extends StatelessWidget {
  const WidgetbookApp({super.key});

  @override
  Widget build(BuildContext context) {
    return Widgetbook.material(
      directories: directories,
      // theme
      lightTheme: ThemeData.light(), // Custom light theme
      darkTheme: ThemeData.dark(), // Custom dark theme
      themeMode: ThemeMode.light, // Forcing light mode

      // Home画面のWidget
      home: SizedBox.shrink(),

      // custom builder
      appBuilder: (context, child) {
        return ProviderScope(child: MaterialApp(home: child));
      },

      // addon
      addons: [
        ViewportAddon(Viewports.all),
        AlignmentAddon(),
      ],
    );
  }
}

Addon

Widgetbookでは、Addonという形で機能を追加することができる

公式でもいろいろ用意されて、自作のAddonも作れる

また、並び順も重要で、先頭のAddonが一番外側(親Widet)になる
そのため、ViewportAddonDeviceFrameAddonは一番先頭に来る必要があるなど、
それぞれのドキュメントに気をつけないといけないことが書かれている

Riverpodとの併用 / Mock Widget

多くの場合、Widget単体ではなく、
Riverpodなどの状態管理ライブラリと一緒に使っている場合が多い

その場合には、テスト時と同様にMock化して、UseCaseを作成するのがよい

以下のドキュメントや記事が参考になる

お手軽なのは、UseCaseごとにProviderScopeで囲んで、
overrideWithで差し替える形

@widgetbook.UseCase(
  name: 'Default',
  type: FilledButton,
  path: "components/buttons",
)
Widget filledDefault(BuildContext context) {
  return ProviderScope(
    overrides: [
      textLabelProvider.overrideWith((ref) => 'Button'),
    ],
    child: FilledButton(child: Text("Button"), onPressed: () {}),
  );
}

全体的にProviderだけであれば、WidgetbookAppの方に設定する形でもOK

@widgetbook.App()
class WidgetbookApp extends StatelessWidget {
  const WidgetbookApp({super.key});

  @override
  Widget build(BuildContext context) {
    return Widgetbook.material(
      directories: directories,
      appBuilder: (context, child) {
        return ProviderScope(
          overrides: [
            // 
          ],
          child: MaterialApp(home: child),
        );
      },
    );
  }
}

以上!! 思ったよりも簡単に導入できそう(*´ω`*)
いろんなことをやるとはハマりそうだけど、まずはStatelessなWidgetだけでも!

参考にしたサイト様