【Flutter】Widgetを管理しやすいように分割する

## はじめに

学習当初に悩んでいたことでWidgetの作り方があります。

具体的には以下のことで悩んでいました。

- 画面を作っていくとビルド関数が肥大化して見づらい
- 画面を自分で決めたブロックを作って切り出す時って、どうしたらいい？
- ブロックごとに切り出してもメンテしづらい

ただただ動くだけのサンプルアプリであればビルド関数にバーっと書いてしまっていいでしょう。
しかし長期サービスでメンテナンスが必要であったり、複数人での開発で誰もが読みやすいコードにする必要な場合があると思います。
そこで、私が現場に入って学んだWidgetの構築方法を書いていきたいと思います。

ただし、現場によって規格が統一されていたり個人によってWidgetの切り出し方の理解のしやすさには差があると考えます。
よって、あくまで参考程度にしていただけたらと考えます。

尚、エディターはVSCodeを前提としています。

## 筆者の経歴

- UIKitでのiOSアプリケーション開発の学習9ヶ月
- SwiftUIでのiOSアプリケーション開発学習４ヶ月
- Flutterでのモバイルアプリケーション開発３ヶ月

### 記事の対象者

- Flutter初学者の方
- 画面の構築で悩んでいる方

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

- macOS 14.3.1
- Xcode 15.2
- Swift 5.9
- iPhone11 pro ⇒ iOS 17.2.1
- Flutter 3.19.0
- Dart 3.3.0
- Pixel 7a ⇒ Android 14

## 1. 前提

私がWidgetを構築している上で、コードを整理しようと考える判断基準は以下の内容です。

- コードが１００行を超えている
- ネストが深くて可読性が悪い
- 他の画面でも共通で使いまわせる部分がある

今回の例題と以下のアプリを用意しました。
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/7a5f05aa-3a9b-3e01-ea6c-1c38f1900f6b.png">


ソースは以下からご覧ください

https://github.com/HaruhikoMotokawa/sample_of_dividing_widget_into_manageable_parts

元ネタはNintendoSwitchOnlineのアプリでスプラトゥーン３用のイカリング３です。

https://www.nintendo.com/jp/hardware/switch/onlineservice/app/index.html

各Widgetは見た目だけです。
本来は画像がスライドしたり、各ボタンから別画面へ遷移しますが今回はあくまで見た目だけ構築しています。

アプリ内のディレクトリー構造は以下のようになっています。
```
lib
├── no_refactor
│   └── no_refactor_screen.dart
├── refactor
│   ├── widgets
│   │   ├── action_item.dart
│   │   ├── friend_list_tile.dart
│   │   ├── image_slider_view.dart
│   │   ├── main_actions.dart
│   │   └── sub_actions.dart
│   └── refactor_screen.dart
└── main.dart
```

no_refactor_screen.dartのビルド関数にバーっと書いて３６４行になっています。
それをリファクタリングしたものがrefactorディレクトリ内に入っています。
今回の記事ではno_refactor_screen.dartをリファクタリングしていく様を書いていきたいと思います。


なお、Widgetの間に入れるスペースをgapというパッケージを使って入れています。
スペースを指定して入れたい場合に便利なパッケージです。

https://pub.dev/packages/gap

### 2.  レイアウトに関する内容を隠蔽する

まず最初に画面全体に関するリファクタリングを行います。

レイアウトを司るクラスを作り画面のレイアウトに関わる内容は

全てそこに書いてしまいます。

こうすることでビルド関数内に表示されるのは何を表示させるかの対象になるべく限定されます。また、コードのネストが浅くなります。

### 2-1. レイアウトを司るクラスに切り出す

`no_refactor_screen.dart`の `Scaffold`を選択して「⌘ + .」（コマンド + ドット）
から`Extract Widget`を選択してWidgetとして切り出します。

![スクリーンショット 2024-05-03 18.26.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/cfeb7d47-27c4-fdb0-34d3-e5e71fa2ca69.png)

このクラスはこのファイル内でしか使わないことが確定しているのでアンダースコアをつけて
プライベートなクラスで宣言します。
命名は`_NoRefactorScreenLayout`とします

![スクリーンショット 2024-05-03 18.28.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/ea7a33d9-635d-c32b-4680-c260162bf6c0.png)

### 2-3. レイアウト関連ではないものを引数にする：title編

ここでは上から順番に整理していきます。
最初にAppBarの部分です。
titleで表示している文字列’イカリング３’を引数にします。

変数`final String appBarTitle;`で定義

![スクリーンショット 2024-05-03 18.40.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/f32c0f4c-dad8-57c7-b669-6347610edab9.png)

コストラクターにも名前付き引数で宣言

![スクリーンショット 2024-05-03 18.40.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/a9d06aa6-ab73-8bfb-f8a8-fbe50a86667d.png)

この時、`super.key`は現在使う予定がないので消しておいて大丈夫

![スクリーンショット 2024-05-03 18.41.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/df414841-158d-1b18-6c69-fead1c9198a1.png)


`_RefactorScreenLayout`の’イカリング３’の部分をappBarTitleに置き換え
（constの付け直しも忘れずに）

![スクリーンショット 2024-05-03 18.43.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/4dadfb2a-b060-48a9-3dbd-a45fb129a342.png)


`RefactorScreen` のビルド関数内で呼び出している`_RefactorScreenLayout`の引数を追加

![スクリーンショット 2024-05-03 18.45.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/ac8f8eeb-1348-ae30-696b-1898d657954f.png)


引数に’イカリング３’を追記

![スクリーンショット 2024-05-03 18.46.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/742bd69a-50c6-dcae-fd5d-34191023c086.png)


差分は以下

![スクリーンショット 2024-05-04 12.02.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/147ef868-b2e4-b6d6-a5df-7e895d497617.png)

### 2-4. レイアウト関連ではないものを引数にする：actions編

次にactionsに入っている`おわる`ボタンを変更していきます。
actionsは元々Widgetを配列で渡せるようになっています。
今後ここに他のWidgetが追加になるかもしれないことも考えて、ここの引数はWidgetの配列で渡すようにします。

先ほどの要領でやっていく結果は以下のようになります。

_NoRefactorScreenLayout

![スクリーンショット 2024-05-04 12.16.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/0de85a91-e00a-68df-dcee-6875d991123d.png)


NoRefactorScreen

![スクリーンショット 2024-05-04 12.17.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/82c9ea0f-62d3-f468-393f-46dbd7221f5a.png)

## 3. 意味のあるまとまりでWidgetを切り出していく

この調子でいくと、引数がどんどん増えていって途方もない作業になります。
そこで画面の構成している部分をある程度パーツに分けて考えていきたいと思います。

最初はScaffoldのAppBarの内容はLayoutクラスの引数としました。
bodyの内容はパーツに分けて考えて切り出していきます。

### 3-1. 画面をパーツに分ける

今回は以下の内容で区切って考えていきます。

![【Flutter】Widgetを管理しやすいように分割する.001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/7a275da0-6633-e0d6-ad53-46867ad328b2.png)


### 3-2. ファイル内からそれぞれのパーツで切り出していく

最初にImageSliderViewを切り出していきます。
今回は画像部分と下のプログレスバーに見立てた部分をセットで切り出したいです。


５３〜１０４行目を選択して⌘ + .からColumnでラップ
![スクリーンショット 2024-05-04 12.57.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/137a81f4-5cfa-713c-5084-3ad0f256d5eb.png)


ColumnでラップしたものをExtract WidgetでImageSliderViewとして切り出す
![スクリーンショット 2024-05-04 13.00.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/d3e97f44-6d06-962e-30f7-4e3d64a35b4b.png)


ここで好みは分かれますが、私はさらに別ファイルに切り出します。

切り出す場所としてわかりやすいようにno_refactorディレクトリ配下にwidgetsディレクトリを作成してその中に切り出します。

:::note
そのまま同じファイル内におく、またはプライベートクラスでおく場合があると思います。
その場合は同一ファイル内で使っていることが明確なのと探す手間が省けます。
逆にコード量は増えてしまい、目的の部分を見失う可能性もあります。

ここは個人の考え方やチームの方針にそう必要があります。
:::

ImageSliderViewを選択して⌘ + . でMove to ImageSliderView fileと移動場所を選択
![スクリーンショット 2024-05-04 13.02.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/862153a1-4da4-c918-4314-bae7163c370d.png)

変更後
![スクリーンショット 2024-05-04 13.18.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/9ed89082-c466-02ed-0969-b358d7c479c7.png)


NoRefactorScreenLayoutの引数にfinal Widget imageSlider;を宣言し、ImageSliderViewを直接呼び出している部分を差し替えます。

_NoRefactorScreenLayout
![スクリーンショット 2024-05-04 13.22.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/d2196638-4661-b48b-cb8c-5a818c4c4882.png)

NoRefactorScreen
![スクリーンショット 2024-05-04 13.21.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/03f6037d-aae6-fc49-cf44-64389affb58a.png)

### 3-3. 他のパーツも切り出していく

上記の手順と同じように切り出していきます。
前回は最初にColumnでラップしましたが、他のパーツは必要ありません。

６１〜８８行目のClipRRect()を**FriendListTile**としてExtract Widget、別ファイルに移動、変数宣言と差し替え
![スクリーンショット 2024-05-04 13.55.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/cbcead04-1c13-8db6-4f48-da1a1e14eb73.png)


７０〜１５５行目のRow()を**MainActions**としてExtract Widget、別ファイルに移動、変数宣言と差し替え
![スクリーンショット 2024-05-04 14.03.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/0dfcedbe-3395-7cad-f80c-c425e2cd4318.png)


７７〜１１１行目の GridView.builder()を**SubActions**としてExtract Widget、別ファイルに移動、変数宣言と差し替え
![スクリーンショット 2024-05-04 14.11.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/6c571d82-c66a-ef90-104f-436eff29398e.png)

合わせて
- class _SubActionItemConfig
- List<_SubActionItemConfig> _subActionItemConfigs

上記２点をsub_actions.dartに移動しておく

これでNoRefactorScreenのリファクタリングは終了です。
３６４行だったコードが→８８行になりだいぶすっきりしました。

```no_refactor_screen.dart
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:sample_of_dividing_widget_into_manageable_parts/no_refactor/widgets/friend_list_tile.dart';
import 'package:sample_of_dividing_widget_into_manageable_parts/no_refactor/widgets/image_slider_view.dart';
import 'package:sample_of_dividing_widget_into_manageable_parts/no_refactor/widgets/main_actions.dart';
import 'package:sample_of_dividing_widget_into_manageable_parts/no_refactor/widgets/sub_actions.dart';

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

  @override
  Widget build(BuildContext context) {
    return _NoRefactorScreenLayout(
      appBarTitle: 'イカリング３',
      appBarActions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text(
            'おわる',
            style: TextStyle(color: Colors.white),
          ),
        ),
      ],
      imageSlider: const ImageSliderView(),
      friendListTile: const FriendListTile(),
      mainActions: const MainActions(),
      subActions: const SubActions(),
    );
  }
}

class _NoRefactorScreenLayout extends StatelessWidget {
  const _NoRefactorScreenLayout({
    required this.appBarTitle,
    required this.appBarActions,
    required this.imageSlider,
    required this.friendListTile,
    required this.mainActions,
    required this.subActions,
  });

  final String appBarTitle;
  final List<Widget> appBarActions;
  final Widget imageSlider;
  final Widget friendListTile;
  final Widget mainActions;
  final Widget subActions;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(
          appBarTitle,
          style: const TextStyle(
            fontWeight: FontWeight.bold,
            color: Colors.white,
          ),
        ),
        automaticallyImplyLeading: false,
        backgroundColor: Colors.black,
        centerTitle: true,
        actions: appBarActions,
      ),
      body: SingleChildScrollView(
        child: ColoredBox(
          color: const Color(0xFF292E35),
          child: Column(
            children: [
              imageSlider,
              const Gap(20),
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 20),
                child: friendListTile,
              ),
              const Gap(20),
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: mainActions,
              ),
              const Gap(20),
              subActions,
            ],
          ),
        ),
      ),
    );
  }
}

```

## ４. 切り出したWidgetのリファクタリング

切り出した各Widgetも必要に応じてリファクタリングします。
あくまで必要に応じてとしているのは過剰にリファクタリングしすぎるのは
時間を消費してしまい他の開発に支障をきたすからです。
ベターだけど、マストではない、といった感じです。

- 意味のあるまとまりになった
- ネストが浅くなった
- 重複している部分を共通化した

となれば可読性は上がると言えるでしょう。
わかりやすくした結果、逆にコード量が増えることもあります。
しかし、繰り返しになりますがそのことで可読性が上がれば良いと考えます。

今回で行けば`friend_list_tile.dart`は手をつけていません。

`image_slider_view.dart`は*2.レイアウトに関する内容を隠蔽する*
の手順に沿ってクラス内に切り出しているだけなので説明は割愛します。

本項ではでMainActionsとSubActionsに関するリファクタリングを解説したいと思います。

![【Flutter】Widgetを管理しやすいように分割する.002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/3b08849c-2638-76e5-0752-b3c7a571fa8b.png)


### 4-1. MainActionsの中からActionItemを切り出す

MainActionsの１３行目をExtract Widgetし、さらに別ファイルに移動します。

そこから引数を以下のように取ります。

- final IconData iconData; ⇒ アイコンのデータ
- final Color iconColor; ⇒ アイコン自体の色
- final double iconWidthRatio; ⇒ アイコンの横幅に対するアスペクト比
- final Color backgroundColor; ⇒ 背景色
- final String title; ⇒ タイトルの文字列

![スクリーンショット 2024-05-06 10.22.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/7ce620ec-0177-ac92-b137-2d61470fc62f.png)


### 4-2. MainActionsのそれぞれの部分にActionItemを適用する

![スクリーンショット 2024-05-06 10.33.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/c41e6aa1-c9fd-2803-918a-19f1aad94681.png)


### 4-3. SubActionsにActionItemを適用する

![スクリーンショット 2024-05-06 10.38.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/b235dfd1-0a19-d4c0-e57f-107a41d4e12a.png)


## 終わりに

このほかにもまだまだ細かい部分はあると思いますが、今回は基本的な切り出し方について説明させていただきました。
Widgetの切り出しやファイル移動は簡単に行えます。
切り出す時のまとまりの意味を考えていくと自ずと保守性の高いWidgetになると思います。

この記事が誰かのお役に立てれば幸いです。