Single Child な Widget を使いこなす
🧒 ← Single Child
FlutterのWidgetを分類する際、以下の分け方はよくなされますが、本記事では違う切り口で分類してみます。
Widgetがどう組み合わされるかという観点で分類すると、次のように大別できます。
- Widgetを1つも受け取らないWidget
- 1つのWidgetを受け取るWidget(Single Child な Widget)
- 複数のWidgetを受け取るWidget
本記事では2つめのSingle ChildなWidgetに焦点を当てますが、その前に前置きとしてそれぞれ何を指しているのかざっくり説明します。
Widgetを1つも受け取らないWidget
Widgetツリーの末端のWidgetがこれに相当します。Textが代表例ですね。
Text(
// WidgetではなくString
’Hello’,
// このようなプロパティは指定できるがWidgetではない
style: TextStyle(fontSize: 15),
);
1つのWidgetを受け取るWidget(Single ChildなWidget)
本記事で焦点を当てるWidgetです。FlutterではUIはすべてWidgetのネストで組むようになっていて、以下のような構造のコードに馴染みがあるはずです。
A(
child: B(
child: C(
child: D(),
),
),
);
上のA・B・C・Dは、すべて “Single Chlid な Widget”です。
実際の例としては、例えばCenterなどが典型例です。
// child要素をセンター配置するだけの単機能Widget
Center(
child: Text(‘Hello’),
);
複数のWidgetを受け取るWidget
例えば、Columnはchildrenとして複数のWidgetを受け取ります。
Column(
children: [
Text(’Hello’),
Text(‘World’),
],
);
また、Scaffold は複数の色々なWidgetを受け取り、それをマテリアルデザインに準拠したレイアウトで配置します。
Scaffold(
appBar: AppBar(),
floatingActionButton: FloatingActionButton(),
body: const _Body(),
);
それでは以下、“Single Chlid な Widget”に焦点を当てていきます。
Single Child な Widget を自作して活用
Single Child な Widget を使いこなすと Widget ツリーをシンプルに保ちやすくなり、メンテナンスもしやすくなります。
まず、Single Child な Widget が与えられたchildを何かしらのWidgetでラップして返すことで次のようなことができます。
- レイアウトに影響を及ぼす
- 機能を足す
- child以下が依存しているInheritedWidgetで包んで上書きする(副作用を与える)
それぞれ具体例とともに説明していきます。
レイアウトに影響を及ぼす
上述のCenterがこの例ですが、もう少しだけ複雑なパターンとして、ListViewの要素それぞれに適切な横マージンを与えたい場合を考えます。
普通に組むと、Paddingを使うことになります。
EdgeInsets.symmetric(horizontal: 16);
の指定が重複していて微妙なのでそれを次のようにローカル変数でまとめてそれを利用するというのは、よくやる手段だと思います。
const horizontalPadding = EdgeInsets.symmetric(horizontal: 16);
これでも良いのですが、次のようにListTileと同じ横マージンを与えられる単機能Widget(TilePadding
)を加えるやり方もありです。利用側のコードが少しスッキリして意図も分かりやすくなります。今回はPaddingの指定だけですが、もう少し複雑な指定の場合は差がより顕著に出ます。
上記のコードで一見良いように見えますが、実は横にSafe Areaがある場合(iPhone X系をランドスケープにした場合など)に、ListTileと違ってSafeAreaからはみ出してしまいます。
PaddingをSafeAreaでさらに囲めば良いのかな?と思うかもしれませんが、ListTileの横マージンは max(safeArea, padding)
であって、 safeArea + padding
ではないので、ちょっと違います。
ListTileのソースコードを参考に修正すると、次のようになります。
Single ChildなWidgetで共通化していると、こういうミスに後から気付いた時にも一括で対応できて捗ります👍
機能を足す
例えば、タップされたらキーボードを閉じるようにしたい場合、次のようにGestureDetectorで囲みます。
onTap
の処理を毎度書くのも面倒ですし、behavior: HitTestBehavior.opaque
を指定し忘れてタップに反応しない領域が発生するバグも生みそうです🤔
これも、次のようにSingle Childなそれ用の UnfocusOnTap
を用意すると利用側のコードがシンプルになります。
child以下が依存しているInheritedWidgetで包んで上書きする(副作用を与える)
FlutterのTextのサイズは、OSで設定した文字サイズに自動追従します。なので、何もケアしてないと次のように意図とは違う挙動になってしまう可能性があります。
これを抑制するには、 Text
の textScaleFactor
を設定すれば良いですが、個別指定だと面倒ですし漏れがあり得ます。
まず第一段階の改善として、次の事実を利用して、フォントサイズを固定したい箇所(この場合Scaffold全体と仮定)を textScaleFactor
を1固定で上書きしたMediaQueryで囲みます。
- textScaleFactorはMediaQueryでも指定できる
- MediaQueryはInheritedWidgetであり、TextのtextScaleFactorが無指定の時はそれに依存している
こうすると、ページ全体の文字サイズをOSの設定に依らず確実に固定することができます。
さらに、それ用のSingle ChildなWidgetに切り出すと以下のように、利用側のコードがシンプルで意図も分かりやすくなります。
同様のテクニックは、他のInheritedWidget継承クラスの上書きにももちろん使えて、例えば特定ページだけThemeを変えたい時などにも有用です。
このように、child Widgetを受け取る目的特化型の再利用可能なWidgetを必要に応じて用意していくと、アプリコードをクリーンに保てます。
SingleChildStatelessWidget を活用
さらに、Single ChildなWidget作成時に、StatelessWidgetの代わりにnested パッケージに含まれるSingleChildStatelessWidgetを継承すると、次のメリットが得られます。
- 少しだけ書きやすくなる
- Nestedと組み合わせるとSingle ChildなWidgetのネストをフラットに書ける
上のFixedTextScaleFactorを、SingleChildStatelessWidgetで書き換えると次のようになります。 child
の保持を親クラスに任せて、 build
メソッドも buildWithChild
に変わってそこに渡ってくる child
を使うことになります。
もちろん外部パッケージに依存することになるのでその意識は必要ですが、ソースもシンプルなのでこれに依存することで問題が発生することは通常無いはずです。
Nested の活用
nestedパッケージのREADMEから拝借しますが、SingleChildStatelessWidgetなどを継承したWidgetの入れ子は、Nestedを利用することで次のようにフラットに書けます。
MyWidget(
child: AnotherWidget(
child: Again(
child: AndAgain(
child: Leaf(),
)
)
)
)
Nested利用後:
Nested(
children: [
MyWidget(),
AnotherWidget(),
Again(),
AndAgain(),
],
child: Leaf(),
),
Single Child な Widget が複数ネストされる場合、それぞれのWidgetが順順にchildに指定されていくことは自明です。つまり、Single Child な Widgetという制約を設ければそのフラットなListからネストされたWidgetを構築できる、という発想です。
nestedパッケージは、Providerと同じ作者により作られたもので、MultiProviderの親クラスでもあります。またMultiProviderのproviders引数はList<SingleChildWidget>となっていますが、SingleChildWidgetはSingleChildStatelessWidgetの親クラスです。
つまり、SingleChildStatelessWidgetで組んだWidgetはMultiProviderのprovidersの要素として指定することもできます。Providerと無関係のWidgetツリーならもちろんNested(children:)で済ませられますが、Provider系のWidgetと混ぜたいならMultiProviderのprovidersに入れてしまうと便利です。
実際の利用例
普通の画面コードでNestedを利用することはあまり無いです。なぜなら、標準WidgetのSingle Child な Widget は SingleChildStatelessWidget ではないため、SingleChildStatelessWidgetの連続指定がなかなか無いからです。
また上述のとおり、MultiProviderがNestedの上位互換的立ち位置なので、Providerパッケージを使っている場合はNestedは直接使わず、MultiProviderを利用することが多いはずです。
というわけで、次のような感じで活用することが多いです。
上記コードにも少しコメントしてありますが、アプリを組む際、 MaterialApp の builder が実は重要な存在です。
ここに渡ってくる child
はNavigatorです。MaterialAppに依存してかつNavigatorの上に置きたいものをここに配置します。具体的には、ここに渡ってくる context
経由で以下などにアクセスできます。
- MediaQuery
- Theme
- 多言語対応クラス
また、Navigatorの上ということは、ここでProvideしたものは画面遷移をしても普通にアクセスできます(Navigatorの下でProvideしたものは画面遷移で分断されるのでProvider.value()などで手動で次の画面に渡す必要があります)。また、画面遷移などされても常に前面に表示しておきたいもの(YouTubeのミニプレイヤーなど)は、ここにStackで前面に表示しておくことで簡単に実装できます。
最後少し脱線気味でしたが、Single ChildなWidgetを活用してアプリコードをきれいにする話でした🧒