Flutter はどこまで共通ウィジェットを作るのが正解なのか 〜ButtonStyleButton〜

# 背景
いろんなコードを見てると、割とノリで共通ウィジェットを作っており、余計な引数を大量に作って、「これは、わざわざ共通ウィジェット化する必要はあるのか？」と、私はしばしば課題意識を持っています。

たとえば、以下のようなウィジェットです。
```dart: full_width_button.dart
class FullWidthButton extends StatelessWidget {
  const FullWidthButton({
    super.key,
    required this.onPressed,
    required this.child,
  });

  final VoidCallback? onPressed;
  final Widget child;
  
  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: double.infinity,
      child: FilledButton(
        onPressed: onPressed,
        child: child,
      ),
    );
  }
}
```

このウィジェットは、以下のような葛藤、事情によって生まれたものと推測します。
- ボタンを横幅いっぱいにするために、毎回 `SizedBox` で囲むのはめんどい。
- `ButtonStyle(minimumSize: MaterialStatePropertyAll(Size(double.infinity, 40)))` みたいなのを毎回書くのはめんどい（もしくは知らない）。

ですがこのウィジェットには、さまざまな問題があります。
- 色の変更対応や、UI の対応幅を増やしたい時などに、毎回パラメータを追加しなければいけない。
- `FilledButton` にしか対応しておらず、その他の `TextButton` などに対応するには、新たにウィジェットを追加する必要がある。
- スタイル変更したい場合、`backgroundColor` だけでは取り回しが効かないので、`ButtonStyle` を引数に加えた方がいいが、そうなるとサイズを指定するパラメータがあるので、FullWidth を謳ってるのに、そうじゃない見た目に変更可能になる。
etc...


では、なぜこのような、問題を抱える共通ウィジェットが作られるのでしょうか。
Flutter のウィジェットが、以下ようなの独特な動きがあるから、と考えられます。
- [Theme](https://api.flutter.dev/flutter/material/Theme-class.html
) や [DefaultTextStyle](https://api.flutter.dev/flutter/widgets/DefaultTextStyle-class.html) などのサブツリーのウィジェットの挙動に影響を与える様々な `InheritedWidget` がある。
- クロスプラットフォームに対応するため、それぞれのプラットフォームで自然な振る舞いをするように、さまざまな分岐が行われている。
    
現に、上で挙げた `FullWidthButton` の例でもわかるように、「幅いっぱいのボタン」を作るためだけに、多くの考慮事項が挙がります。

**Flutter は、適切に満足のいく共通ウィジェットを作るのが、かなり難しいと感じています。**

ですが、これらの複雑な挙動に真っ向から向き合い、Flutter の ウィジェットの挙動の理解度を上げることで、開発速度向上、保守性の向上、プラットフォームごとの挙動の違いの理解につながると思っています。


# 記事内容
`ButtonStyleButton` に絞って、それぞれの挙動を紐解き、「共通化すべきかどうか」を検討するのが記事の目的です。

当然ですが、今回扱うテーマが「共通化」なので、「これが絶対」と言うつもりはなく、それぞれの個人の思想や会社の通念ががあるので、僕のいち意見としてみていただきたいです。
ですが、Flutter のウィジェットや `Theme` と付き合った事例として、参考になると思います。

:::note warn
単に可読性のためや、リビルドの影響範囲を防ぐために、コンポーネントに分ける行為は話題からは除きます。
::: 

# ButtonStyleButton とは

https://api.flutter.dev/flutter/material/ButtonStyleButton-class.html

アプリを作ると、様々なボタンを設置することになります。
ボタンの最低限の要件を満たすために用意されたウィジェットが、`ButtonStyleButton` です。

:::note
ButtonStyle を使った Button だから ButtonStyleButton。
名前のぱっと見の見栄えより、機能の正確さを優先した芸術的な命名（個人の意見）。
:::

## 基礎
`ButtonStyleButton` を扱う上で、まず知っておきたい基礎的な挙動を抑えます。

以下が Flutter で用意されている `ButtonStyleButton` です。
- [FilledButton](https://api.flutter.dev/flutter/material/FilledButton-class.html)
- [OutlinedButton](https://api.flutter.dev/flutter/material/OutlinedButton-class.html)
- [ElevatedButton](https://api.flutter.dev/flutter/material/ElevatedButton-class.html)
- [TextButton](https://api.flutter.dev/flutter/material/TextButton-class.html)
- （[IconButton](https://api.flutter.dev/flutter/material/IconButton-class.html)）
    - IconButton 自体は ButtonStyleButton ではないが、内部で使われている。

:::note warn
[FilledButton.tonal](https://api.flutter.dev/flutter/material/FilledButton/FilledButton.tonal.html) というのも Flutter から提案されています。
PrimaryColor を使用するのが基本の `FilledButton` に対し、SecondaryColor を使用するのがデフォルトの塗りつぶしボタンです。
「閉じる」ボタンみたいなものを想定されたウィジェットですが、この利用には、現状少し問題があるので除いています。

👇
(Material3) Cannot Theme FilledButton and FilledButton.tonal Variants Separately
https://github.com/flutter/flutter/issues/118063
:::

#### defaultStyleOf
https://api.flutter.dev/flutter/material/FilledButton/defaultStyleOf.html

`ButtonStyleButton` には、「もし `ButtonStyle` が null の場合、デフォルトでどういう `ButtonStyle` をとるか」を設定する関数が必ず実装されています。
ここを見れば、何もテーマ設定せず、ただ `FilledButton` を置いたときに、どういう動きになるかがわかります。
Flutter が用意する `ButtonStyleButton` では、`defaultStyleOf` の引数で BuildContext を受け取り、そこから、`ThemeData.textTheme` と `ThemeData.colorSchme` を利用して作られています。

たとえば、`FilledButton M3` の`minimumSize` は `{width: 64, height: 40}` で設定されています。
これによって、テーマ設定しなくてもボタンの見た目がある程度の大きさが保たれています。
![スクリーンショット 2023-12-03 13.28.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1805603/7a03fb2e-b479-737d-ab83-2380320d13ae.png)

:::note
サイズを決めるもう一つの要因に、[VisualDensity](https://api.flutter.dev/flutter/material/VisualDensity-class.html) があります。
高さ、幅をそれぞれ可変させる値が入っていて、デフォルトが `0`、それぞれ `-4`~`＋4` まで可変します。

普段そこまで気にすることがないですが、`linux`、`macos`、`windows` では高さと幅が `-2` されていることは、知っておいて損はないと思います。
https://api.flutter.dev/flutter/material/VisualDensity/defaultDensityForPlatform.html

👇 そもそも VisualDensity が何かというドキュメント
https://m2.material.io/design/layout/applying-density.html#usage
:::

## ボタンの状態
`ButtonStyle` には、もう1つ認識しておかなければいけない概念があります。
それは、`MaterialStateProperty` です。

https://api.flutter.dev/flutter/material/MaterialStateProperty-class.html

`ButtonStyle` のほとんどのパラメータは、`MaterialStateProperty` で囲まれています。
これは、ボタンが `disable` の場合、どのような値をとるかをうまいことやってくれるライフサイクルです。

この `MaterialStateProperty` のキモは、値を返すときに、`MaterialState` が、単体の状態を持っているわけではなく、配列で持っているということです。
例えば、「単に非活性である」という状態ではなく、「非活性かつ hover されている」という状態をサポートしてくれているということです。


:::note info
`MateialState` が `ButtonStyleButton` でどう扱われているかは、ほとんどこの InkWell の中身を見ればわかります。

https://github.com/flutter/flutter/blob/9e1c857886/packages/flutter/lib/src/material/ink_well.dart#L1449

大体扱われているのは、この辺りの状態ですね。
   * MaterialState.pressed
   * MaterialState.hovered
   * MaterialState.focused
   * MaterialState.disabled
:::

## 特定の shape のボタンは共通ウィジェット化する必要があるのか
こういう共通ウィジェットもたまに見ます。

```dart: rounded_button.dart
class RoundedButton extends StatelessWidget {
  const RoundedButton({
    super.key,
    required this.onPressed,
    required this.child,
    this.backgroundColor,
  });

  final Widget child;
  final VoidCallback? onPressed;
  final Color? backgroundColor;
  
  @override
  Widget build(BuildContext context) {
    return FilledButton(
      style: FilledButton.styleForm(
        backgroundColor: backgroundColor,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
      ),
      onPressed: widget.onPressed,
      child: widget.child,
    );
  }
}
```

これも、上で挙げた `FullWidthButton` と同じような問題点を抱えています。
- 色の変更対応や、UI の対応幅を増やしたい時などに、毎回パラメータを追加しなければいけない。
- `FilledButton` にしか対応しておらず、その他の `TextButton` などに対応するには、新たにウィジェットを追加する必要がある。

`Text` のスタイルを実装するたびに、`BoldText` を作ることは、非効率であると分かると思います。

ですが、以下のようなコードを書くのは長いと感じるのはわかります。
```dart
FilledButton(
  style: FilledButton.styleForm(
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(8),
    ),
  ),
);
```
これは、「Button の実装を短くする」というアプローチではなく、「`ButtonStyle` の定義を短くする」というアプローチをすべきだと考えています。
```dart
class ButtonStyles {
  static final roundedShapeButtunStyle = ButtonStyle(
    shape: MaterialStatePropertyAll(
      RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(8),
      ),
    ),
  );
}

FilledButton(
  style: ButtonStyles.roundedShapeButtunStyle,
)
```


## FullWidthButton はどう実装するのか
せっかく例を挙げたので、こちらの解決策を提示したいと思います。
１つは、普通に `SizedBox(width: double.infinity, child: //... )` で囲む形。
もう１つは、`minimumSize` を設定する形がいいと考えています。
ですが、Size 指定なので、高さも指定する必要があ流ので、そこは固定値で実装するのがいいと思います。

```dart:constants.dart
/// Material3 の場合は 40、Material2 の場合は 36 で実装されている。
/// デフォルトの動きではなく、アプリごとに明示的に高さを決める必要はあると思うので、それを設定
const kButtonMinimumHeight = 40.0;
```
```dart:hoge_widget.dart
FilledButton(
  style: FilledButton.styleForm(
    minimiumHeight: Size.fromHeight(kButtonMinimumHeight),
  )
)
```

# 有効な共通ウィジェットの例
Flutter で用意されていない場合は、自前で実装することになります。

## ToggleOutlinedButton

Flutter で用意されていない（もしされていたら、すみません）、`MaterialState.selected` を加えたボタンです。

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1805603/8b9ff80d-a1ab-5375-f366-0ae491df4b51.gif)


```dart: my_theme.dart
final filledButtonStyle = // ...
final outlinedButtonStyleBase = //...

final myTheme = ThemeData(
  outlinedButtonThemeData: OutlinedButtonThemeData(
    style: outlinedButtonStyleBase.copyWith(
      bacgroundColor: MaterialStateProperty.resolve((states) {
          // .selected が含まれていた場合に、FilledButton の振る舞いをする
          if (states.contains(MaterialState.selected)) {
            return filledButtonStyle
                .backgroundColor
                ?.resolve(states);
          }

          return outlinedButtonStyleBase.backgroundColor?.resolve(states);
      }
      ),
    ),
  )
);
```

```dart: toggle_outlined_button.dart
class ToggleOutlinedButton extends StatefulWidget {
  const ToggleOutlinedButton({
    super.key,
    required this.onPressed,
    required this.child,
    this.style,
    required this.selected,
  });

  final Widget child;
  final VoidCallback? onPressed;
  final bool selected;
  final ButtonStyle? style;

  @override
  State<ToggleButton> createState() => _ToggleButtonState();
}

class _ToggleButtonState extends State<ToggleButton> {
  late final MaterialStatesController _materialStateController;

  @override
  void initState() {
    _materialStateController = MaterialStatesController(
      widget.selected ? {MaterialState.selected} : null,
    );
    super.initState();
  }

  @override
  void dispose() {
    _materialStateController.dispose();
    super.dispose();
  }

  @override
  void didUpdateWidget(covariant ToggleButton oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.selected != widget.selected) {
      if (widget.selected) {
        _materialStateController.value.add(MaterialState.selected);
      } else {
        _materialStateController.value.remove(MaterialState.selected);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return OutlinedButton(
      statesController: _materialStateController,
      style: widget.style,
      onPressed: widget.onPressed,
      child: widget.child,
    );
  }
}

```

# あとがき
今回、時間がなくて、`ButtonStyleButton` の共通ウィジェットの話についてしか書けませんでしたが、
この辺の内容もそのうち書きたいと思っています。

- `InputDecorator`
- `Dialog`
- `Form`、 `FormField` ウィジェット、バリデーションと向き合う。
- `ColorScheme` が難しかったら、`ThemeExtension` を使おう。
- 共通ウィジェットで、flutter_hooks を使いたくない話。

この記事も、加筆修正予定です。