53
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

FlutterAdvent Calendar 2023

Day 4

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

Last updated at Posted at 2023-12-03

背景

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

たとえば、以下のようなウィジェットです。

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 のウィジェットが、以下ようなの独特な動きがあるから、と考えられます。

  • ThemeDefaultTextStyle などのサブツリーのウィジェットの挙動に影響を与える様々な InheritedWidget がある。
  • クロスプラットフォームに対応するため、それぞれのプラットフォームで自然な振る舞いをするように、さまざまな分岐が行われている。

現に、上で挙げた FullWidthButton の例でもわかるように、「幅いっぱいのボタン」を作るためだけに、多くの考慮事項が挙がります。

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

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

記事内容

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

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

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

ButtonStyleButton とは

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

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

基礎

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

以下が Flutter で用意されている ButtonStyleButton です。

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

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

defaultStyleOf

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

たとえば、FilledButton M3minimumSize{width: 64, height: 40} で設定されています。
これによって、テーマ設定しなくてもボタンの見た目がある程度の大きさが保たれています。
スクリーンショット 2023-12-03 13.28.20.png

サイズを決めるもう一つの要因に、VisualDensity があります。
高さ、幅をそれぞれ可変させる値が入っていて、デフォルトが 0、それぞれ -4~+4 まで可変します。

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

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

ボタンの状態

ButtonStyle には、もう1つ認識しておかなければいけない概念があります。
それは、MaterialStateProperty です。

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

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

MateialStateButtonStyleButton でどう扱われているかは、ほとんどこの 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 のボタンは共通ウィジェット化する必要があるのか

こういう共通ウィジェットもたまに見ます。

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 を作ることは、非効率であると分かると思います。

ですが、以下のようなコードを書くのは長いと感じるのはわかります。

FilledButton(
  style: FilledButton.styleForm(
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(8),
    ),
  ),
);

これは、「Button の実装を短くする」というアプローチではなく、「ButtonStyle の定義を短くする」というアプローチをすべきだと考えています。

class ButtonStyles {
  static final roundedShapeButtunStyle = ButtonStyle(
    shape: MaterialStatePropertyAll(
      RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(8),
      ),
    ),
  );
}

FilledButton(
  style: ButtonStyles.roundedShapeButtunStyle,
)

FullWidthButton はどう実装するのか

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

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

有効な共通ウィジェットの例

Flutter で用意されていない場合は、自前で実装することになります。

ToggleOutlinedButton

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

タイトルなし.gif

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);
      }
      ),
    ),
  )
);
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
  • FormFormField ウィジェット、バリデーションと向き合う。
  • ColorScheme が難しかったら、ThemeExtension を使おう。
  • 共通ウィジェットで、flutter_hooks を使いたくない話。

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

53
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
53
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?