【Flutter】よく使うWidgetをパッケージ化して効率的に活用しよう

## はじめに

よく使うWidgetのテンプレートがあると便利ですよね？
自分は以前、以下の記事で主にスナックバー、ダイアログ、ボトムシートのテンプレートをご紹介しました。

https://qiita.com/Harx02663971/items/85c1c4c04b868ca2160f

ただし、このテンプレートの問題点はプロジェクトごとにいちいちコードをコピペする必要がある点です。
ちゃんとしたプロジェクトであればこのテンプレートをコピペしてさらにカスタマイズして、といったことは必要だと思います。

しかし、何かしらのパッケージを試すためのサンプルを作りたいなどの場合には少々面倒です。
そこで、自分がよく使う上記のWidgetをパッケージ化して簡単に呼び出せるようにしてみたのでご紹介します。

:::note
pub.devなどに公開する用途は対象外ですのでご了承ください。
:::

### 記事の対象者

- 自分のお気に入りWidgetをパッケージ化してみたい方
- GitHubがある程度使える方
- 個人用または社内用のパッケージを作りたい方

### 記事を執筆時点での筆者の環境

```
[✓] Flutter (Channel stable, 3.24.1, on macOS 15.0.1 24A348 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.0)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.3)
[✓] VS Code (version 1.94.0)
```

### サンプルプロジェクト

![utility_widgets.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/c3ac6073-e0b2-5e36-3bab-1673df6e5752.gif)

### ソースコード

https://github.com/HaruhikoMotokawa/utility_widgets

### パッケージの作成手順

基本的には以下の流れで進んでいきます。

1. パッケージプロジェクト作成
2. 作成内容を確認するための実行環境を用意
3. ソースコード記入
4. 動作の確認
5. READMEやCHANGELOGを編集
6. 完成したコミットにタグをつける
7. GitHub経由で読み込む場合

## 1. パッケージプロジェクト作成

ターミナルで作成したいディレクトリに移動し、以下のコマンドで作成します。

```shell
flutter create --template=package パッケージ名
```

すると自動で雛形を作成してくれます！

開いてみると以下のようなディレクトリ構造になっています。

![スクリーンショット 2024-10-14 11.03.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/fbf5e9a1-e192-e075-b0fc-93a873c2175d.png)

ソースコードはlibディレクトリ内に記述していきます。
ひとまず雛形にはサンプルのクラスが入っています。

## 2. 作成内容を確認するための実行環境を用意

実行環境は以下の２通りがあります。

1. 専用のテストプロジェクトを別に作ってパッケージをローカルで読み込む
1. パッケージ内に確認用のプロジェクトを作成する

### 2-1. 専用のテストプロジェクトを別に作ってパッケージをローカルで読み込む場合

まず、パッケージのpathが必要なので、パッケージのコマンドラインにてpwdコマンドで現在のパスを取得します。

```shell
$ pwd
/Users/hogetaro/test_directory/test_hoge
```
次に適当なプロジェクトを作成したらpubspec.yamlに以下のようにパッケージ名とpathを記載し保存します。

```yaml
dependencies:
  flutter:
    sdk: flutter
  test_hoge:
    path: /Users/motokawaharuhiko/Flutter_Practice/test_hoge
```

すると以下のように呼び出すことができるようになります。

```dart
    final cal = Calculator();
    cal.addOne(1);
```

:::note warn
パッケージのソースコードを変更したら再度`flutter pub get`が必要です。
:::

:::note
こちらの方法は動作確認以外でもローカルにソースコードを置いて使用する場合などにも有効です。
:::

### 2-2. パッケージ内に確認用のプロジェクトを作成する場合

今回のutility_widgetsはこちらのパターンで作成しました。
まず、パッケージのルート直下にexampleディレクトリを作成します。

![スクリーンショット 2024-10-14 11.30.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/cc0a921c-da81-7ba8-4a60-238655332fd8.png)

ターミナルでexampleディレクトリに移動し、プロジェクト作成コマンドを実行します。

```shell
~/test_directory/utility_widgets [main]
$ cd example/
~/test_directory/utility_widgets/example [main]
$ flutter create -e .
```

するとプロジェクトが出来上がるので、あとはpubspec.yamlに以下のように書いて依存関係を結びます。

```yaml
dependencies:
  flutter:
    sdk: flutter
  utility_widgets:
    path: ../
```

![スクリーンショット 2024-10-14 11.36.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/692a2ffb-e32d-5409-2917-c586a171c82e.png)

pathが`../`でいいので少し楽なのと、わざわざ別プロジェクトを開かないでいいので、私はこちらがおすすめです。

## 3. ソースコード記入

### 3-1. エントリーポイントとディレクトリ構造

パッケージのlib直下にて配置された`パッケージ名.dart`に全てのソースコードを書いていってもいいのですが、コード量が増えると可読性が悪い上に保守しにくくなりす。

この`パッケージ名.dart`がパッケージへのエントリーポイントですので、その部分に専念させるのが良さそうです。
世に公開せれているパッケージを参考に以下のような構成にしてみました。

![スクリーンショット 2024-10-14 11.49.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/ae61297a-2a5f-95bc-d052-b4918067a690.png)

`パッケージ名.dart`にはエントリーポイントの宣言である`library utility_widgets;`を記載しています。
その下には`export`キーワードに続けてソースコードを記載しています。
それぞれのソースコードはsrcディレクトリの配下に置いています。
こうすることで機能やwidget単位で管理しやすくなっています。

::: note
tips
`export`を忘れると外部から参照出来ないので確認しましょう。
`export`に記載するのはパスなので、今回で行けば`src/hoge.dart`なので注意です。
:::

### 3-2. 実装

実装は普段のコーディング同様です。
例えば、独自のスナックバーを定義した`lib/src/custom_snack_bar.dart`は以下のようになっています。

```lib/src/custom_snack_bar.dart
import 'package:flutter/material.dart';

/// カスタマイズしたスナックバーを表示する
///
/// [message]はスナックバーに表示したいメッセージ
/// [duration]はスナックバーを表示する時間（デフォルトは2秒で設定）
void showCustomSnackbar(
  BuildContext context,
  String message, {
  int duration = 2,
}) {
  // スナックバーの設定
  final snackbar = SnackBar(
      content: Text(
        message,
        style: Theme.of(context).textTheme.bodyMedium,
      ),
      duration: Duration(seconds: duration),
      // スナックバーを浮かせる設定
      behavior: SnackBarBehavior.floating,
      // スナックバーの内部のテキストとスナックバーの外側のパディング
      padding:
          const EdgeInsetsDirectional.symmetric(horizontal: 14, vertical: 16),
      // スナックバーと画面とのマージン
      margin: const EdgeInsets.fromLTRB(20, 0, 20, 50),
      // 角丸の設定
      shape: BeveledRectangleBorder(
        borderRadius: BorderRadius.circular(5),
      ),
      backgroundColor: Colors.grey);

  // ScaffoldMessengerを使用してスナックバーを表示
  ScaffoldMessenger.of(context).showSnackBar(snackbar);
}
```

ダイアログやボトムシートの実装はソースコードをご覧ください。

https://github.com/HaruhikoMotokawa/utility_widgets

## 4. 動作の確認

今回の場合はexampleディレクトリ内のプロジェクトで行いました。
`main.dart`に通常のUI構築と同様にコーディングしています。

<details><summary>example/lib/main.dart</summary>

```example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:utility_widgets/utility_widgets.dart';

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

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: HomeScreen(),
    );
  }
}

class HomeScreen extends StatelessWidget {
  const HomeScreen({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              child: const Text('スナックバーを表示'),
              // カスタマイズされたスナックバーはグローバル関数なので、以下のように呼び出す
              onPressed: () => showCustomSnackbar(context, 'スナックバーを表示しました'),
            ),
            const SizedBox(height: 40),
            ElevatedButton(
              child: const Text('ボトムシートを表示'),
              onPressed: () async => onTapBottomSheetButton(context),
            ),
            const SizedBox(height: 40),
            ElevatedButton(
              child: const Text('ダイアログを表示'),
              onPressed: () async => onTapDialogButton(context),
            )
          ],
        ),
      ),
    );
  }
}

extension on HomeScreen {
  Future<void> onTapBottomSheetButton(BuildContext context) async {
    // [ActionBottomSheet]の[show]メソッドを使ってボトムシートを表示
    final result = await ActionBottomSheet.show<int>(
      context,
      actions: [
        // [ActionItem]はアイコン、テキスト、戻り値を持つ
        // タップした場合の挙動はボトムシートを閉じるのと同時にreturnValueに設定した値を返す
        const ActionItem(
          icon: Icons.add,
          text: '追加',
          returnValue: 0,
        ),
        const ActionItem(
          icon: Icons.edit,
          text: '編集',
          returnValue: 1,
        ),
        const ActionItem(
          icon: Icons.delete,
          text: '削除',
          returnValue: 2,
        ),
        // ボトムシート内はListViewになっているのでいるので、スクロールできるため
        // いくつでも追加できる
      ],
    );
    // 基本は以下のように受け取った値によって処理を分岐する書くことを前提にしている
    if (!context.mounted) return;

    switch (result) {
      case 0:
        showCustomSnackbar(context, '追加しました');
      case 1:
        showCustomSnackbar(context, '編集しました');
      case 2:
        showCustomSnackbar(context, '削除しました');
      // nullもあり得るので、その場合も考慮する
      case _:
        // 何もしない
        return;
    }
  }

  Future<void> onTapDialogButton(BuildContext context) async {
    // [PlatformDialog]の[show]メソッドを使ってダイアログを表示
    // [show]メソッドの引数には[AdaptiveAction]のリストを渡す
    final result = await PlatformDialog.show<int>(
      context: context,
      title: const Text('タイトル'),
      content: const Text('内容'),
      actionsBuilder: (context) => [
        // [AdaptiveAction]はテキスト、戻り値を持つ
        const AdaptiveAction(text: Text('キャンセル'), returnValue: 0),
        const AdaptiveAction(text: Text('OK'), returnValue: 1),
      ],
    );
    // 基本は以下のように受け取った値によって処理を分岐する書くことを前提にしている
    if (!context.mounted) return;

    switch (result) {
      case 0:
        showCustomSnackbar(context, 'キャンセルしました');
      case 1:
        showCustomSnackbar(context, 'OKしました');
      // nullもあり得るので、その場合も考慮する
      case _:
        // 何もしない
        return;
    }
  }
}
```
</details>


## 5. READMEやCHANGELOGを編集

ここは必要に応じて編集しましょう。
個人利用の目的であればそこまで必要しっかりやらなくてもいいかもしれません。

READMEはパッケージの概要と簡単な使い方を記載しましょう。



![スクリーンショット 2024-10-14 12.09.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/a9988113-bf86-dfd0-9742-9ab9e4f2fceb.png)

バージョン管理している場合はCHANGELOGにそのバージョンの変更点を記載するといいでしょう。

![スクリーンショット 2024-10-14 12.09.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/a58ed7c0-c6ad-370d-ce21-d4df41a737e4.png)

## 6. 完成したコミットにタグをつける

こちらもバージョン管理していなければいいのですが、後々管理が大変になるのでつけておいた方がいいでしょう。

パッケージの実装が完了した場合に最後のコミットの時点でターミナルまたはVSCodeのGUIで操作しましょう。

ターミナルの場合のコマンドは以下のとおりです。
```shell
git tag v1.0.0
```

そしてGithubにもプッシュしておきます。

```shell
git push origin v1.0.0
```

## 7. GitHub経由で読み込む場合

自分のプロジェクトで使用するが、ローカルにソースを置いておきたくない場合はGitHub経由で読みこむことが可能です。

GitHub上のCodeボタンをタップしてリポジトリのURLをコピーしておきます。

![スクリーンショット 2024-10-14 12.20.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/9a599e22-5520-bf01-a3bc-de4c038e557b.png)

あとは使用したい該当プロジェクトの`pubspec.yaml`に以下のように書いて保存、または`flutter pub get`を実行することでパッケージを使用することができます。

```pubspec.yaml
dependencies:
  utility_widgets:
    git:
      url: https://github.com/HaruhikoMotokawa/utility_widgets.git
      ref: v1.0.0
```

urlに先ほコピーしたリポジトリのurlを貼り付けます。
refにはタグ付けした**タグ名**を記載します。

今回はv1.0.0としていますが、バージョン１とか1.0.0などでも大丈夫です。

## 終わりに

今回の記事では、よく使うWidgetをパッケージ化する手順を解説しました。

この方法を使えば、サンプル作成の際に時間を大幅に短縮できるでしょう。
今回はスナックバー、ダイアログ、ボトムシートの3つのWidgetに焦点を当てました。
これに加えてさらに種類を増やしたり、よく使う便利メソッドを追加したりすることで、より汎用性の高いパッケージに発展させることもできます。

パッケージの作成は難しいと感じるかもしれませんが、今回の手順を参考にすれば、誰でも簡単に実現できます。
ぜひ自分のお気に入りのWidgetをパッケージ化して、開発効率を高めてみてください！

最後まで読んでいただき、ありがとうございました！