adaptive-layout について
Flutter 開発者向けのアダプティブレイアウトのスキルです。デバイスの種類ではなく利用可能な空間に応じて UI を調整する方法を示し、Material 3 のウィンドウサイズ分類、ナビゲーション、リストと詳細の配置、インセットを扱います。
主な機能
- 01制約とウィンドウ幅に基づくレイアウト選択
- 02Material 3 のウィンドウサイズ分類
- 03幅に応じたナビゲーション方式の統一
- 04コンパクトでは単一ペイン、広幅では二ペイン
- 05セーフエリア・キーボード・折りたたみ端末のヒンジに対応
- 06スクリプトによるレイアウト規則の検査
活用シーン
- 01サイズ変更可能なデスクトップや分割画面への適応
- 02スマートフォンと広幅画面のリスト詳細 UI 設計
- 03幅に応じたナビゲーションコンポーネントの切り替え
- 04プルリクエスト前のアダプティブレイアウト検査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill adaptive-layoutインストール後、Claude Code で/adaptive-layoutを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/adaptive_scaffold.dartlist_detail_pane.dartreferences/list-detail-and-navigation.mdwindow-size-classes.mdscripts/check_adaptive.sh詳細
アダプティブレイアウト
利用できるスペースに適応させ、使用中だと思うデバイスには決して適応させないでください。折りたたみ式デバイスの前面ディスプレイに表示したスマートフォンアプリ、サイズ変更したデスクトップウィンドウ、分割画面で使用するタブレットでは、Platform.isX のチェックはどれも役に立ちません。一方、実際の制約はどれも正しく示されます。ハードウェアではなく、幅を基準に分岐してください。
作業内容に応じて、次のリファレンスを参照してください。
references/window-size-classes.md— 共通の語彙として使う Material 3 のウィンドウサイズクラスのブレークポイント、WindowSizeClass列挙型のパターン、MediaQuery.sizeOf/paddingOf/viewInsetsOfと.ofの違い、読みやすさを保つ最大幅、SafeArea とディスプレイの切り欠き、キーボードのインセット、画面の向き、折りたたみ式デバイスのヒンジへの対応。references/list-detail-and-navigation.md— 単一ペインでの画面遷移と、横並びの 2 ペイン表示の使い分け、幅に応じたナビゲーション手段の選択と go_router シェルとの連携、両方のペインで選択状態を共有するために Notifier に状態を保持する方法。
PR を作成する前に scripts/check_adaptive.sh を実行してください。
絶対に守るルール
-
デバイスやプラットフォームではなく、制約やサイズに合わせて適応させてください。 レイアウトの選択に
Platform.isAndroid/Platform.isIOS/kIsWebを使わないでください。LayoutBuilder(ローカルのボックス制約)またはMediaQuery.sizeOf(context)(ウィンドウサイズ)を使います。理由:ウィンドウのサイズ変更、分割画面、折りたたみ式デバイスではデバイスの判定が通用しませんが、制約は常に正確です。 -
ブレークポイントの共通語彙として Material 3 のウィンドウサイズクラスを使ってください。 Compact
<600、Medium600–840、Expanded840–1200、Large1200–1600、Extra-large≥1600(論理ピクセル幅)。理由:これらは標準的な構造上のブレークポイントであり(デザイントークンではありません)、共通の列挙型を使えば、すべての画面でブレークポイントを統一できます。 -
最も限定的な MediaQuery の取得方法を使ってください。
MediaQuery.of(context)ではなく、sizeOf/paddingOf/viewInsetsOf/viewPaddingOfを使います。 理由:.ofはキーボード、画面の回転、テキストスケールなど、MediaQuery のあらゆる変更を受けてウィジェットを再構築します。一方、各アスペクトの取得メソッドは、該当するフィールドが変更されたときだけ再構築します。flutter-performanceを参照してください。 -
画面ごとの推測ではなく、幅に応じてナビゲーション手段を選んでください。
NavigationBar(Compact)→NavigationRail(Medium/Expanded)→NavigationDrawer(Large)。シェルで一度だけ決定します。理由:画面ごとに異なるナビゲーション手段を使うとユーザーが戸惑いますが、幅とナビゲーション手段の対応を統一すれば、常に一貫性を保てます。navigation-and-routingのStatefulShellRouteと連携してください。 -
リストと詳細の表示は、Compact では 1 ペインに折りたたみ、Expanded では 2 ペインに分割してください。 Compact:リストから詳細画面に遷移します。Expanded:リストと詳細を横並びに表示し、選択状態を共有します。理由:スマートフォンでの 2 ペイン表示は読みにくく、デスクトップでの 1 ペイン表示は幅の 70% を無駄にします。
-
画面の向きを固定しないでください(縦向きを強制する
SystemChrome.setPreferredOrientations)。理由:タブレット、折りたたみ式デバイス、アクセシビリティ対応マウントで問題が起きます。どちらの向きにもウィンドウサイズクラスで対応してください。 -
読みやすいコンテンツの最大幅を設定してください。トップレベルの領域には固定ピクセル幅ではなく、
Flexible/Expanded/FractionallySizedBoxを使います。 理由:幅の広い画面で本文を全幅表示すると読みにくくなります(読みやすいのは 1 行あたり約 40~75 文字です)。領域幅を固定すると、はみ出したり無駄な空間ができたりします。 -
画面端のコンテンツは
SafeAreaで囲み、ディスプレイの切り欠きとキーボードのインセットを考慮してください。 切り欠きやシステムバーはMediaQuery.paddingOf/viewPaddingOf、キーボードはMediaQuery.viewInsetsOfで取得します。理由:ノッチ、パンチホール、画面キーボードを考慮しないと、コンテンツが隠れてしまいます。 -
セルや行の高さを固定だと想定しないでください。 大きなテキストスケールではセルの高さが 2 倍になることもあります。コンテンツに合わせてサイズを変えてください。理由:
SizedBox(height: 48)のように固定すると、テキストスケールが 200% のときに表示が切れます。accessibility-as-codeとwidget-compositionを参照してください。 -
1 台のデバイスだけでなく、ゴールデンテストのマトリックスでさまざまなサイズを検証してください。 各アダプティブ画面を Compact/Medium/Expanded で(最大のテキストサイズ、LTR/RTL も含めて)スナップショット撮影します。理由:アダプティブなレイアウトの不具合は、確認しなかったブレークポイントでしか発生しないことがあります。
widget-golden-and-a11y-testingを参照してください。
共有 enum としてのサイズクラス
ブレークポイントは一度だけ定義し、すべての画面で同じマップを参照します。
enum WindowSizeClass { compact, medium, expanded, large, extraLarge }
WindowSizeClass windowSizeClassFor(double width) => switch (width) {
< 600 => WindowSizeClass.compact,
< 840 => WindowSizeClass.medium,
< 1200 => WindowSizeClass.expanded,
< 1600 => WindowSizeClass.large,
_ => WindowSizeClass.extraLarge,
};
// In a widget: rebuilds only when the window SIZE changes.
final sizeClass = windowSizeClassFor(MediaQuery.sizeOf(context).width);
判断がローカルのボックス(分割ビュー内のウィジェットやカードグリッドなど)に依存する場合は LayoutBuilder を、ウィンドウ全体に依存する場合(トップレベルのシェルなど)は MediaQuery.sizeOf を優先してください。
// Local constraints drive a card grid's column count.
LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth ~/ 280; // min card width, structural
return GridView.count(crossAxisCount: columns.clamp(1, 4), children: cards);
},
);
読みやすい幅と柔軟な領域
// Center and cap body text to a legible measure; never full-bleed on desktop.
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720), // structural readable cap
child: article,
),
);
// Split a wide body proportionally, not with magic fixed widths.
Row(
children: [
Expanded(flex: 2, child: primaryPane),
Expanded(flex: 3, child: secondaryPane),
],
);
インセット、切り欠き、キーボード、折りたたみ式デバイス
// Keyboard height: rebuilds only when the keyboard shows/hides.
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
// Notch/system-bar padding without subscribing to text-scale/rotation changes.
final safeTop = MediaQuery.paddingOf(context).top;
// Foldable: a vertical fold/hinge spanning the full height => split around it.
final seam = MediaQuery.displayFeaturesOf(context)
.where((f) =>
(f.type == DisplayFeatureType.fold ||
f.type == DisplayFeatureType.hinge) &&
f.bounds.top == 0 && // starts at the top edge...
f.bounds.width < f.bounds.height) // ...taller than wide => vertical
.firstOrNull; // firstOrNull: import 'package:collection/collection.dart';
最も外側のインタラクティブ領域を SafeArea で囲みます。NavigationBar やスクロール可能な本文を端まで表示する場合は、特定の辺を対象外(bottom: false)にします。
幅に応じたナビゲーション UI
// One place decides the shell chrome; screens stay affordance-agnostic.
Widget shellFor(WindowSizeClass sc, {required Widget body, required int index}) {
return switch (sc) {
WindowSizeClass.compact =>
Scaffold(body: body, bottomNavigationBar: _NavBar(index: index)),
WindowSizeClass.medium || WindowSizeClass.expanded =>
Scaffold(body: Row(children: [_Rail(index: index), Expanded(child: body)])),
WindowSizeClass.large || WindowSizeClass.extraLarge =>
Scaffold(body: Row(children: [_Drawer(index: index), Expanded(child: body)])),
};
}
シェル全体は examples/adaptive_scaffold.dart を、2 ペインのパターンは examples/list_detail_pane.dart を参照してください。
アンチパターン
- レイアウトを選ぶために
if (Platform.isIOS) ... else ...を使うこと。MediaQuery.sizeOf(context).widthに基づいて分岐してください。 MediaQuery.of(context).size.widthを使うこと。MediaQuery の変更すべてを監視します。MediaQuery.sizeOf(context)を使ってください。- 横向きレイアウトを避けるために
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp])を使うこと。縦向きと横向きの両方に対応してください。 - トップレベルのペインのサイズ指定に
Container(width: 375)やSizedBox(width: 320)を使うこと。Expanded/Flexible/FractionallySizedBox/ConstrainedBoxを使ってください。 bool isTablet = MediaQuery.of(context).size.shortestSide > 600のようなフラグをあらゆる場所に渡すこと。シェルで幅からWindowSizeClassを計算してください。- コンパクトな幅で 2 つのペインを表示し、横スクロールを強いること。1 つのペインに折りたたんで、画面遷移で表示してください。
- デスクトップで最大幅を設定せず、段落を全幅で表示すること。本文の最大幅を制限してください。
- キーボードが非表示だと想定すること。コンテンツがキーボードの下に隠れます。
MediaQuery.viewInsetsOf(context).bottomでパディングを設定してください。
完了の定義
- レイアウトの選択に
Platform.is*/kIsWeb/dart:ioを使用していない(プラットフォーム固有の プラグイン は使用してもかまいません)。 - ブレークポイントは、画面ごとに散在する場当たり的な数値ではなく、共有の
WindowSizeClassヘルパーから取得する。 - MediaQuery は
.ofではなく、sizeOf/paddingOf/viewInsetsOf/viewPaddingOfを使って読み取る。 - ナビゲーション UI は幅に応じて 1 か所(シェル)で選択し、
navigation-and-routingと連携する。 - リスト詳細画面は、コンパクトでは 1 ペインに折りたたみ、幅が広い場合は分割し、選択状態を共有する。
- トップレベルの領域に固定ピクセル幅を使用せず、読みやすいコンテンツには最大幅を設定する。
- 画面の向きを固定せず、端のコンテンツには
SafeAreaを使用し、キーボードのインセットを考慮する。 - ゴールデンテストのマトリックスで、コンパクト/ミディアム/幅広(最大のテキストサイズ、RTL を含む)を網羅する。
scripts/check_adaptive.shが成功する。
関連スキル
widget-composition— 構造的なレイアウトプリミティブ、計算によるサイズ指定、端まで表示するレイアウトと SafeArea の使い分け、セルの高さを固定しない方法。navigation-and-routing— このシェルが表示するStatefulShellRoute。幅によってナビゲーション UI を選び、ルートによってブランチを選びます。flutter-performance— 再ビルドの範囲を絞るうえで、sizeOf/selectが.ofより優れている理由。accessibility-as-code— 大きなテキストスケールがすべてのアダプティブセルに影響すること。widget-golden-and-a11y-testing— アダプティブなレイアウトを実証する、デバイス × テキストスケール × RTL のゴールデンテストマトリックス。state-management-riverpod— リスト詳細画面で共有する選択状態の配置先。
参考資料
- Material 3 のレイアウト/ウィンドウサイズクラス:https://m3.material.io/foundations/layout/applying-layout/window-size-classes
- アダプティブおよびレスポンシブデザイン(Flutter):https://docs.flutter.dev/ui/adaptive-responsive
MediaQueryAPI(アスペクトのゲッター):https://api.flutter.dev/flutter/widgets/MediaQuery-class.htmlLayoutBuilder:https://api.flutter.dev/flutter/widgets/LayoutBuilder-class.htmlNavigationRail:https://api.flutter.dev/flutter/material/NavigationRail-class.htmlDisplayFeature/折りたたみ式デバイス:https://api.flutter.dev/flutter/dart-ui/DisplayFeature-class.html