背景
いろんなコードを見てると、割とノリで共通ウィジェットを作っており、余計な引数を大量に作って、「これは、わざわざ共通ウィジェット化する必要はあるのか?」と、私はしばしば課題意識を持っています。
たとえば、以下のようなウィジェットです。
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 や DefaultTextStyle などのサブツリーのウィジェットの挙動に影響を与える様々な
InheritedWidget
がある。 - クロスプラットフォームに対応するため、それぞれのプラットフォームで自然な振る舞いをするように、さまざまな分岐が行われている。
現に、上で挙げた FullWidthButton
の例でもわかるように、「幅いっぱいのボタン」を作るためだけに、多くの考慮事項が挙がります。
Flutter は、適切に満足のいく共通ウィジェットを作るのが、かなり難しいと感じています。
ですが、これらの複雑な挙動に真っ向から向き合い、Flutter の ウィジェットの挙動の理解度を上げることで、開発速度向上、保守性の向上、プラットフォームごとの挙動の違いの理解につながると思っています。
記事内容
ButtonStyleButton
に絞って、それぞれの挙動を紐解き、「共通化すべきかどうか」を検討するのが記事の目的です。
当然ですが、今回扱うテーマが「共通化」なので、「これが絶対」と言うつもりはなく、それぞれの個人の思想や会社の通念ががあるので、僕のいち意見としてみていただきたいです。
ですが、Flutter のウィジェットや Theme
と付き合った事例として、参考になると思います。
単に可読性のためや、リビルドの影響範囲を防ぐために、コンポーネントに分ける行為は話題からは除きます。
ButtonStyleButton とは
アプリを作ると、様々なボタンを設置することになります。
ボタンの最低限の要件を満たすために用意されたウィジェットが、ButtonStyleButton
です。
ButtonStyle を使った Button だから ButtonStyleButton。
名前のぱっと見の見栄えより、機能の正確さを優先した芸術的な命名(個人の意見)。
基礎
ButtonStyleButton
を扱う上で、まず知っておきたい基礎的な挙動を抑えます。
以下が Flutter で用意されている ButtonStyleButton
です。
- FilledButton
- OutlinedButton
- ElevatedButton
- TextButton
- (IconButton)
- IconButton 自体は 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.textTheme
と ThemeData.colorSchme
を利用して作られています。
たとえば、FilledButton M3
のminimumSize
は {width: 64, height: 40}
で設定されています。
これによって、テーマ設定しなくてもボタンの見た目がある程度の大きさが保たれています。
サイズを決めるもう一つの要因に、VisualDensity があります。
高さ、幅をそれぞれ可変させる値が入っていて、デフォルトが 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
です。
ButtonStyle
のほとんどのパラメータは、MaterialStateProperty
で囲まれています。
これは、ボタンが disable
の場合、どのような値をとるかをうまいことやってくれるライフサイクルです。
この MaterialStateProperty
のキモは、値を返すときに、MaterialState
が、単体の状態を持っているわけではなく、配列で持っているということです。
例えば、「単に非活性である」という状態ではなく、「非活性かつ hover されている」という状態をサポートしてくれているということです。
MateialState
が ButtonStyleButton
でどう扱われているかは、ほとんどこの InkWell の中身を見ればわかります。
大体扱われているのは、この辺りの状態ですね。
- MaterialState.pressed
- MaterialState.hovered
- MaterialState.focused
- MaterialState.disabled
特定の shape のボタンは共通ウィジェット化する必要があるのか
こういう共通ウィジェットもたまに見ます。
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 指定なので、高さも指定する必要があ流ので、そこは固定値で実装するのがいいと思います。
/// Material3 の場合は 40、Material2 の場合は 36 で実装されている。
/// デフォルトの動きではなく、アプリごとに明示的に高さを決める必要はあると思うので、それを設定
const kButtonMinimumHeight = 40.0;
FilledButton(
style: FilledButton.styleForm(
minimiumHeight: Size.fromHeight(kButtonMinimumHeight),
)
)
有効な共通ウィジェットの例
Flutter で用意されていない場合は、自前で実装することになります。
ToggleOutlinedButton
Flutter で用意されていない(もしされていたら、すみません)、MaterialState.selected
を加えたボタンです。
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);
}
),
),
)
);
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 を使いたくない話。
この記事も、加筆修正予定です。