WidgetsApp は、Flutter アプリの最上位に置くウィジェットのうち、デザイン言語を持たない土台にあたるクラスです。package:flutter/widgets.dart に入っていて、画面遷移を管理する Navigator、ロケール、キーボードショートカット、アプリ名の登録といった「どのアプリにも要る部品」をまとめて組み立てます。公式 API リファレンスのとおり、MaterialApp と CupertinoApp もこの WidgetsApp を土台に使っています。
この記事では、WidgetsApp が組み立てるもの、起動に必要な引数、ルーティング、MaterialApp との違い、Material のウィジェットを混ぜたときのエラー、MaterialApp を外すと自作になる部品を順に説明します。ソースの引用は Flutter 3.47.9(2026年10月29日(UTC)公開の安定版・Dart 3.13.7)のタグ、コード例とエラーメッセージは 2026年11月6日に同じ版の SDK(macOS x64)で flutter analyze と flutter test を通した結果です。
まとめ:WidgetsAppの役割とMaterialAppとの使い分け
- 役割:Navigator・Localizations・Shortcuts/Actions・Title・デバッグ用バナーを組み立てる、デザイン非依存のアプリの土台です。StatefulWidget で、MaterialApp と CupertinoApp も内部でこれを使っています。
- 必須の引数:
colorは必ず渡します。homeかroutesを使うならpageRouteBuilderも要ります。 - MaterialAppとの差:テーマ、MaterialLocalizations、ScaffoldMessenger、MaterialPageRoute、Hero の制御が入っていません。
- よく出るエラー:WidgetsApp の下で AppBar・TextField・
showDialogを使うとNo MaterialLocalizations found.で止まります。Scaffold 単体は動きます。 - 自作になるもの:ボタンの押下表示と読み上げ、画面単位の Semantics、戻る UI、Hero の制御です。
- 使い分け:Material か Cupertino の部品を1つでも使うなら MaterialApp/CupertinoApp を選びます。WidgetsApp が向くのは、UI をすべて自前のウィジェットで組むアプリです。
WidgetsAppが組み立てるウィジェットの層
WidgetsApp の build メソッドは、外側から順に次のウィジェットを重ねて返します(3.47.9 の widgets/app.dart)。プロパティの多くは、どれか1つの層の設定値です。
| 層(外側から) | 担う機能 | 関係するプロパティ |
|---|---|---|
| RootRestorationScope | 状態の復元 | restorationScopeId |
| SharedAppData | アプリ全体の共有値 | なし |
| Shortcuts/Actions | キー操作と処理の対応 | shortcuts、actions |
| FocusTraversalGroup | Tab キーでのフォーカス移動順 | なし |
| Localizations | ロケールと文字の向き | locale、supportedLocales、localizationsDelegates |
| Title | OS に見せるアプリ名と色 | title、onGenerateTitle、color |
| CheckedModeBanner | 右上の DEBUG 帯(デバッグ時のみ) | debugShowCheckedModeBanner |
| DefaultTextStyle | Text の既定スタイル(指定時のみ) | textStyle |
| Navigator | 画面の積み重ねと遷移 | home、routes、onGenerateRoute ほか |
MediaQuery は WidgetsApp ではなく外側の View が用意します。useInheritedMediaQuery は非推奨で、値は無視されます。
home に置いた Builder から祖先の Element をたどると、今回のウィジェットテストでは、テスト用のルート要素も含めて WidgetsApp は 97 段、MaterialApp は 133 段、CupertinoApp は 117 段でした。要素数は画面構成や実行条件で変わります。差の 36 段は MaterialApp が足すテーマ・ScaffoldMessenger などの層です。
WidgetsApp を StatelessWidget と書いた解説もありますが、正しくは StatefulWidget で、ロケールの解決結果や Navigator の GlobalKey を状態に持ちます。2つの違いはStatelessWidgetとStatefulWidgetの違いと使い分けで実測しています。
起動に必要な引数:colorとpageRouteBuilderの条件
color は WidgetsApp で唯一の required 引数です。Title ウィジェットへ不透明にした色で渡され、OS がアプリを一覧表示するときの色に使われます。Android のタスク切り替え画面がこれにあたります。
ほかの引数の組み合わせは、コンストラクタの assert などで検査されます。デバッグ実行で違反すると、次のメッセージで起動時に止まります。
| 違反する組み合わせ | 3.47.9 で出るメッセージ(冒頭) |
|---|---|
| builder・onGenerateRoute・pageRouteBuilder がすべて未指定 | the pageRouteBuilder must be specified |
home と routes の '/' を両方指定 |
the routes table cannot include an entry for "/" |
| 画面の生成元が1つも無い(※) | Either the home property must be specified |
| ルート指定なしで navigatorKey や initialRoute を渡す | If no route is provided using home, routes... |
| home と onGenerateInitialRoutes を両方指定 | the home argument will be redundant |
※生成元とは、home・builder・onGenerateRoute・onUnknownRoute・routes の '/' のいずれかです。routes に '/' が無く、ほかの生成元もすべて未指定の場合に、この assert に当たります。
MaterialApp からの書き換えで当たるのは1行目です。MaterialApp は pageRouteBuilder に MaterialPageRoute を作る関数を自動で渡しています。builder か onGenerateRoute があれば1行目の assert は通りますが、home か routes から画面を作る時点で別の assert が pageRouteBuilder を要求します。不要なのは、home と routes を使わない構成だけです。
最小構成のWidgetsApp:Hello Worldのコード
package:flutter/widgets.dart だけで動く最小のアプリです。pageRouteBuilder は遷移アニメーションの無い PageRouteBuilder を返しています。
import 'package:flutter/widgets.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return WidgetsApp(
title: 'WidgetsApp Demo',
color: const Color(0xFF2196F3),
textStyle: const TextStyle(fontSize: 16, color: Color(0xFF212121)),
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) {
return PageRouteBuilder<T>(
settings: settings,
pageBuilder: (context, animation, secondaryAnimation) => builder(context),
);
},
home: const ColoredBox(
color: Color(0xFFFFFFFF),
child: Center(child: Text('Hello, WidgetsApp')),
),
);
}
}
このコードの3つの値は、省くと次の問題が出ます。
- textStyle:渡したときだけ DefaultTextStyle が挿入されます。省くと文字の大きさと色は Flutter の初期値のままです。
- ColoredBox:WidgetsApp も PageRouteBuilder も背景を塗りません。画面の地の色は自分で用意します。
- title:Web では省くと Title ウィジェット自体が挿入されません(
kIsWebの分岐)。ブラウザのタブ名を出すなら指定します。
WidgetsAppのルーティング:routes・onGenerateRoute・onUnknownRoute
名前付きルートが解決される順番
WidgetsApp の Navigator は、名前でルートを探すとき次の順に生成元を当たります。
'/'ならhomeroutesの表に名前があればその WidgetBuilderonGenerateRoute(null を返すと次へ)- 最後に
onUnknownRoute
次の例は、固定のルートを routes に、/items/42 のように値を含むパスを onGenerateRoute に、それ以外を onUnknownRoute に振り分けます。
import 'package:flutter/widgets.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
class RoutingApp extends StatelessWidget {
const RoutingApp({super.key});
@override
Widget build(BuildContext context) {
return WidgetsApp(
color: const Color(0xFF2196F3),
navigatorKey: navigatorKey,
initialRoute: '/',
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) {
return PageRouteBuilder<T>(
settings: settings,
pageBuilder: (context, animation, secondaryAnimation) => builder(context),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
);
},
routes: <String, WidgetBuilder>{
'/': (context) => const Page(label: 'home'),
'/settings': (context) => const Page(label: 'settings'),
},
onGenerateRoute: (settings) {
final uri = Uri.parse(settings.name ?? '');
if (uri.pathSegments.length == 2 && uri.pathSegments.first == 'items') {
return PageRouteBuilder<void>(
settings: settings,
pageBuilder: (context, a1, a2) => Page(label: 'item ${uri.pathSegments[1]}'),
);
}
return null;
},
onUnknownRoute: (settings) => PageRouteBuilder<void>(
settings: settings,
pageBuilder: (context, a1, a2) => Page(label: 'not found: ${settings.name}'),
),
);
}
}
class Page extends StatelessWidget {
const Page({super.key, required this.label});
final String label;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xFFFFFFFF),
child: Center(child: Text(label, textDirection: TextDirection.ltr)),
);
}
}
テストで順に push すると「settings」「item 42」「not found: /nope」が表示されました。routes の WidgetBuilder は pageRouteBuilder で包まれますが、onGenerateRoute と onUnknownRoute は Route を自分で返すので、遷移の見た目も自分で決めます。
onUnknownRouteを省いたときの例外
onUnknownRoute を渡さずに存在しない名前を push すると、次の例外で止まります。
Could not find a generator for route RouteSettings("/nope", null) in the _WidgetsAppState.
...
Unfortunately, onUnknownRoute was not set.
起動時の初期ルート(initialRoute や Web で直接開いた URL)はこの処理を通りません。解決できない名前だと「Could not navigate to initial route.」を報告したうえで '/' の画面で起動します。初期 URL も独自のエラー画面に振り分けるなら、onGenerateInitialRoutes か Router 側で処理します。
builder は Navigator の外側を包むための引数です。実測では、builder の context から Navigator.maybeOf と Overlay.maybeOf を呼ぶとどちらも null でした。builder の中のボタンから画面遷移するなら、navigatorKey を渡して navigatorKey.currentState!.pushNamed(...) で呼びます。
builder だけで作ると Navigator と Overlay が存在せず、ダイアログも出せません。1画面だけのツールやテスト用の外枠に限った構成です。
go_routerとWidgetsApp.routerの接続
Flutter 公式のNavigation and routingは、名前付きルートを「We don’t recommend using named routes for most applications」として多くのアプリには推奨していません。理由はディープリンクの挙動を変えられないことと、ブラウザの「進む」ボタンに対応しないことで、代わりに go_router などを挙げています。
Router API を使うには別のコンストラクタ WidgetsApp.router に routerConfig を渡します。go_router 18.1.3(Flutter 3.44 以上が必要)の GoRouter もそのまま渡せて、home・routes・pageRouteBuilder は引数に存在しません。
注意するのは画面遷移です。WidgetsApp.router の下で GoRoute の builder を使うとページは NoTransitionPage になり、実測では go('/b') の次のフレームで切り替わりました。遷移を付けるには次のように pageBuilder で CustomTransitionPage を返します。存在しないパスでは go_router の Page Not Found 画面が出るので、errorBuilder で差し替えます。
GoRoute(
path: '/b',
pageBuilder: (context, state) => CustomTransitionPage<void>(
key: state.pageKey,
child: const DetailPage(),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
),
MaterialApp・CupertinoAppとの違い
MaterialApp と CupertinoApp は、自分の build の中で WidgetsApp に決まった値を渡しています。渡している値と追加している層を比べると次のとおりです。
| 項目 | WidgetsApp | MaterialApp | CupertinoApp |
|---|---|---|---|
| 画面遷移 | pageRouteBuilder を自分で指定 | MaterialPageRoute | CupertinoPageRoute |
| Text の既定スタイル | textStyle 指定時のみ | 警告用の赤字スタイル | CupertinoTheme の文字 |
| color の既定 | 必須(既定なし) | theme.primaryColor → 青 | テーマの primaryColor |
| ローカライズ | Widgets のみ | Material+Cupertino+Widgets | Cupertino+Widgets |
| テーマ | なし | Theme(切替時アニメーション) | CupertinoTheme |
| ScaffoldMessenger | なし | あり | なし |
| Hero の制御 | なし | あり | あり |
| スクロール挙動 | ScrollBehavior | MaterialScrollBehavior | CupertinoScrollBehavior |
| 祖先 Element 数(実測) | 97 | 133 | 117 |
「警告用の赤字スタイル」は、Scaffold や Material の外に Text を置くと出る黄色い二重下線の正体です。WidgetsApp を直接使うアプリでは起きません。
material.dartの非推奨化とWidgetsAppへの影響
Material と Cupertino の部品は Flutter 3.47 から別パッケージでも配布されています。2026年11月6日時点の最新は material_ui 1.10.0 と cupertino_ui 1.1.6 で、どちらも Flutter 3.47.0 以上が必要です。
安定版の 3.47.9 では、SDK 同梱の package:flutter/material.dart は非推奨になっていません。次の版のベータである 3.50.0-0.4.pre の material.dart には @Deprecated が付き、package:material_ui/material_ui.dart への移行を促しています。cupertino.dart にも同様に付いていて、widgets.dart には付いていません。同じベータで本記事のコードを確かめると、テストはすべて通り、flutter analyze は material.dart を import したファイルにだけ非推奨の info を出しました。担当者は追跡 Issue で、コードの削除は翌年(2027年)に行う見込みとコメントしていますが、削除の版は確認できません。
WidgetsApp だけで組むアプリに、この移行作業はありません。import するのは widgets.dart で、material_ui 1.10.0 の MaterialApp も内部で SDK の WidgetsApp を返しています。Material を使うアプリの移行判断はFlutter 3.47のmaterial_ui移行にまとめています。
WidgetsAppの下でMaterialのウィジェットを使うときのエラー
AppBar・TextField・showDialogで出るNo MaterialLocalizations found
WidgetsApp の Localizations は、既定で Widgets 用のデリゲートしか持っていません。ボタンのツールチップや読み上げ用ラベルを MaterialLocalizations から取る部品は、配置した時点で次の例外を出します。
No MaterialLocalizations found.
AppBar widgets require MaterialLocalizations to be provided by a Localizations widget ancestor.
3.47.9 で試した結果は次のとおりです。material_ui 1.10.0 の AppBar でも同じ例外が出ました。
- 例外になる:AppBar、TextField(Material で包んでも同じ)、
showDialog(呼び出した時点で同期的に例外) - 動く:Scaffold を body だけで置いた場合、widgets 層の
showGeneralDialog
Theme未配置時のTheme.ofのフォールバック
例外が出ないせいで気づきにくいのが Theme です。WidgetsApp の下で Theme.of(context) を呼ぶと、祖先に Theme が無くても既定の ThemeData が返り、colorScheme.primary は #6750A4(Material 3 の基準色の紫)でした。ブランド色のはずの部品が紫で描かれたら、Theme が届いていないことを疑います。
Materialの部品を混ぜるときの最小の追加
WidgetsApp のまま Material の部品を使う場合は、デリゲート・画面遷移・テーマの3点を足します。次のコードでは例外が出ず、AppBar が描画されました。import を package:material_ui/material_ui.dart に替えても同じコードが動きます。
import 'package:flutter/material.dart';
class HybridApp extends StatelessWidget {
const HybridApp({super.key});
@override
Widget build(BuildContext context) {
return WidgetsApp(
color: const Color(0xFF2196F3),
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
DefaultMaterialLocalizations.delegate,
DefaultWidgetsLocalizations.delegate,
],
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) =>
MaterialPageRoute<T>(settings: settings, builder: builder),
builder: (context, child) => Theme(
data: ThemeData(colorSchemeSeed: const Color(0xFF2196F3)),
child: child!,
),
home: Scaffold(
appBar: AppBar(title: const Text('Material on WidgetsApp')),
body: const Center(child: TextField()),
),
);
}
}
これでも SnackBar を出す ScaffoldMessenger や Hero の制御は入っていません。ここまで足すなら MaterialApp に切り替えた方が早く、WidgetsApp を選んだ意味もなくなります。
MaterialAppを外すと自作になる部品と実装
Material の部品を使わないなら、MaterialApp が肩代わりしていた処理を自分で書きます。先に手を付けるのは、操作に直結するボタンと画面の読み上げです。
GestureDetectorだけのボタンに足りない読み上げとキーボード操作
GestureDetector に Text を入れただけの部品は、Semantics のタップ操作とラベルは持つものの、ボタンの印(isButton)が false でした。押下の表示もキーボードのフォーカスもありません。次の PressButton は、この3つをまとめて補います。
import 'package:flutter/widgets.dart';
class PressButton extends StatefulWidget {
const PressButton({super.key, required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
State<PressButton> createState() => _PressButtonState();
}
class _PressButtonState extends State<PressButton> {
bool _pressed = false;
bool _focused = false;
void _invoke(Intent intent) => widget.onPressed();
@override
Widget build(BuildContext context) {
return Semantics(
container: true,
button: true,
enabled: true,
child: FocusableActionDetector(
onShowFocusHighlight: (value) => setState(() => _focused = value),
actions: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(onInvoke: _invoke),
ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(onInvoke: _invoke),
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
onTap: widget.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
decoration: BoxDecoration(
color: _pressed ? const Color(0xFF0D47A1) : const Color(0xFF1565C0),
border: Border.all(
color: _focused ? const Color(0xFFFFB300) : const Color(0x00000000),
width: 2,
),
),
child: Text(widget.label),
),
),
),
);
}
}
実測では isButton が true になり、Tab でフォーカスしたあと Enter と Space のどちらでも onPressed が呼ばれました。WidgetsApp の defaultShortcuts がキーを Intent に結び付けているので、受け取る Action だけ書けば足ります。Web の Enter は ActivateIntent でなく ButtonActivateIntent に結び付くので、Action は2つ登録します。1つだけだと、Web と同じ割り当てのテストで Enter が効きませんでした。
- onTapCancel:指を押したまま外へ滑らせると
onTapUpは呼ばれず、onTapCancelだけが呼ばれました。省くと押下色のまま残ります。 - HitTestBehavior.opaque:既定の
deferToChildは、子を持たない SizedBox に置くとタップを1回も受けませんでした。余白まで押せる部品では指定します。
PageRouteBuilderに足すSemanticsのscopesRoute
MaterialPageRoute の buildPage は、画面全体を Semantics(scopesRoute: true, explicitChildNodes: true) で包んでいます。scopesRoute は、画面名を読み上げる単位の根を示す印です。PageRouteBuilder にはこれがありません。
Semantics ツリーで scopesRoute の付いたノードを数えると、ただの PageRouteBuilder は 0、MaterialPageRoute と次の appRoute は 1 でした。画面ごとに書くと漏れるので、pageRouteBuilder: appRoute と渡して1か所に集めます。
import 'package:flutter/widgets.dart';
PageRoute<T> appRoute<T>(RouteSettings settings, WidgetBuilder builder) {
return PageRouteBuilder<T>(
settings: settings,
pageBuilder: (context, animation, secondaryAnimation) => Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: builder(context),
),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
);
}
システムの戻る操作と画面内の戻るUI
Android の戻る操作は WidgetsApp だけで効きます。handlePopRoute を呼ぶと Navigator が1画面戻り、PopScope(canPop: false) の画面では戻らずに onPopInvokedWithResult が didPop=false で呼ばれました。
足りないのは画面に見える戻るボタンで、AppBar が無いので自作します。PressButton の onPressed に Navigator.maybePop をつなぐのが最短です。Esc キーでは画面は戻らなかったので、デスクトップ向けなら Shortcuts と Action を自分で足します。
HeroアニメーションのHeroController追加
Hero ウィジェットは WidgetsApp でも置けますが、そのままでは飛びません。遷移の途中で「hero」の文字を探すと、制御なしでは遷移元と遷移先の2か所に同時に出ました。HeroController を足すと、2点の間を移動する1つだけになりました。
class _AppState extends State<App> {
final HeroController _heroController = HeroController();
@override
void dispose() {
_heroController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return WidgetsApp(
color: const Color(0xFF2196F3),
navigatorObservers: <NavigatorObserver>[_heroController],
pageRouteBuilder: appRoute,
routes: routes,
);
}
}
HeroController は State のフィールドに1回だけ作り、build のたびに作り直さないようにします。
Androidのオーバースクロールとデスクトップのスクロールバー
WidgetsApp のスクロール挙動は素の ScrollBehavior で、Android の ListView には色が #FFFFFF 固定の GlowingOverscrollIndicator が付きました(MaterialApp は StretchingOverscrollIndicator)。macOS では RawScrollbar が付きます。scrollBehavior 引数は無いので、ScrollBehavior を継承して buildOverscrollIndicator で child を返すクラスを builder の ScrollConfiguration で差し込むと、グローは消えました。
文字入力のEditableText
TextField の代わりになるのは widgets 層の EditableText です。必須の5引数を渡すと、WidgetsApp の下でも例外なく文字列を入力できました。枠線・ラベル・エラー表示は持たないので、フォームが多いアプリでは自作の量が増えます。
WidgetsAppでのテーマとダークモードの書き方
WidgetsApp には theme・darkTheme・themeMode が無く、見た目に関わる引数は textStyle と color だけです。端末のダークモードに合わせるには、builder の中で MediaQuery.platformBrightnessOf を読み、背景と既定の文字色を切り替えます。
import 'package:flutter/widgets.dart';
class BrightnessApp extends StatelessWidget {
const BrightnessApp({super.key, required this.home});
final Widget home;
@override
Widget build(BuildContext context) {
return WidgetsApp(
color: const Color(0xFF2196F3),
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) =>
PageRouteBuilder<T>(
settings: settings,
pageBuilder: (context, a1, a2) => builder(context),
),
builder: (context, child) {
final dark = MediaQuery.platformBrightnessOf(context) == Brightness.dark;
return ColoredBox(
color: dark ? const Color(0xFF121212) : const Color(0xFFFFFFFF),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16,
color: dark ? const Color(0xFFE0E0E0) : const Color(0xFF212121),
),
child: child!,
),
);
},
home: home,
);
}
}
テストで端末をダークに切り替えると、Text の既定色が #212121 から #E0E0E0 に変わりました。
WidgetsAppで日本語ロケールを設定する方法
supportedLocales の既定値は [Locale('en', 'US')] で、何も指定しないと Localizations.localeOf(context) は en_US を返します。次の例ではロケールを ja_JP に固定します。端末の言語設定に追従させる場合は locale を省略し、supportedLocales に対応言語を並べます。
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
class JaApp extends StatelessWidget {
const JaApp({super.key, required this.home});
final Widget home;
@override
Widget build(BuildContext context) {
return WidgetsApp(
color: const Color(0xFF2196F3),
locale: const Locale('ja', 'JP'),
supportedLocales: const <Locale>[Locale('ja', 'JP'), Locale('en', 'US')],
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
GlobalWidgetsLocalizations.delegate,
],
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) =>
PageRouteBuilder<T>(
settings: settings,
pageBuilder: (context, a1, a2) => builder(context),
),
home: home,
);
}
}
GlobalWidgetsLocalizations は flutter_localizations パッケージ(SDK 同梱)のデリゲートです。実行前に flutter pub add flutter_localizations:"{sdk: flutter}" で依存関係に追加します。実行すると Localizations.localeOf は ja_JP でした。RichText は locale を指定されないとこの値を段落の locale に使うので、漢字が中国語の字形で出る現象への対策にもなります。WidgetsApp だけで組むなら GlobalMaterialLocalizations は不要です。
WidgetsAppを選ぶ場面と選ばない場面
判断の軸は1つで、Material か Cupertino の部品を使うかどうかです。
- WidgetsApp を選ぶ:ボタン・入力欄・ダイアログまで自社のデザインシステムで作るアプリ(ゲーム風の UI、店頭端末のキオスク画面、ブランド表現が最優先のアプリ)。パッケージのサンプルやウィジェットテストで、外枠を最小にしたい場合。
- MaterialApp を選ぶ:TextField・AppBar・SnackBar・
showDialogのどれか1つでも使う場合。テーマ切り替えやダークモードの自動追従が欲しい場合。Cupertino の部品だけで組むなら CupertinoApp です。
避けたいのは、「軽そうだから」という理由で WidgetsApp から始め、あとで Material の部品を足すたびにデリゲート・テーマ・ScaffoldMessenger を手で積み直すパターンです。祖先 Element の差は 36 段ありますが、処理時間は測っていないので、軽さは読み上げや操作部品を自作する根拠になりません。部品を自作する計画が無いなら、最初から MaterialApp で始めてください。
WidgetsApp は特定のデザイン言語を提供しないため、自作UIをOSの新しい見た目に合わせるかどうかはアプリ側で判断します。iOS の新しい見た目に合わせたいアプリは、FlutterのLiquid Glass対応状況を先に確かめます。部品の自作量は見積もりを大きく左右するので、外部に開発を頼むならFlutter / React Native開発のような窓口に、部品の一覧を添えて相談します。層の切り方はFlutterアーキテクチャのベストプラクティスで整理しています。
WidgetsAppについてよくある質問
WidgetsAppとMaterialAppはどちらを使えばよいですか?
Material の部品を1つでも使うなら MaterialApp です。部品をすべて自作するアプリだけが WidgetsApp を選ぶ理由を持ちます。今回の祖先 Element 数の差は36段ですが、処理時間やメモリ使用量は測定していません。性能を理由に選ぶ場合は、対象画面で別途比較が必要です。
WidgetsAppの中でScaffoldは使えますか?
Scaffold を body だけで置く分には動きます。AppBar や TextField を入れると No MaterialLocalizations found. で止まるので、localizationsDelegates に DefaultMaterialLocalizations.delegate(日本語なら GlobalMaterialLocalizations.delegate)と DefaultWidgetsLocalizations.delegate を渡します。日本語で表示する場合は、supportedLocales にも日本語を登録します。
WidgetsAppのcolorは画面のどこに使われますか?
アプリ内の画面には使われません。Title ウィジェットに渡され、OS がアプリを一覧表示するとき(Android のタスク切り替え画面など)の色になります。画面の背景色は ColoredBox などで自分で塗ります。
できます。GlobalKey<NavigatorState> を navigatorKey に渡せば、builder の中や context を持たないクラスから navigatorKey.currentState!.pushNamed('/settings') で遷移できます。builder の context で Navigator.of を呼んでも Navigator は見つかりません。
WidgetsAppでThemeExtensionやthemeModeは使えますか?
WidgetsApp 自体には theme も themeMode もありません。ThemeExtension は ThemeData の機能なので、builder で Theme ウィジェットを挿入して ThemeData の extensions に渡せば Theme.of(context).extension<T>() で読めます。ダークモードの切り替えは MediaQuery.platformBrightnessOf を見て自分で行います。