Flutterのテーマ設計入門(ThemeData + Theme Extension) の次に触れておきたいのが、画面サイズに応じた情報量の切り替えです。スマホ縦画面では読めても、タブレットや横向きで同じ UI をそのまま広げると、余白が空きすぎたり、逆に情報を詰め込みすぎたりします。この記事では MediaQuery と LayoutBuilder の役割を分けながら、スマホでは 1 カラム、幅が広いときは 2 カラムや 2 ペインへ広げる入口を最小例で整理します。
1. ゴールと非対象
対象読者
- Flutter の環境構築、Dart 入門、基本レイアウト、テーマ設計までは終わった人
- スマホ向けに作った画面を、タブレットや横向きでどう調整すればよいか分からず止まりやすい人
MediaQuery.of(context).size.widthは見たことがあるが、LayoutBuilderとどう役割分担するかまだ曖昧な人
この記事で到達する状態
MediaQueryを画面全体の情報を見る用途で使えるLayoutBuilderを親幅に応じたレイアウト分岐で使える- カラム数やパネル分割を、端末名ではなく使える幅で切り替えられる
OrientationBuilderを使う場面と、幅判定へ寄せる場面を分けて考えられる
非対象
flutter_screenutilなど外部パッケージによるレスポンシブ対応- NavigationRail やデスクトップ向け 3 カラム設計
- foldable 端末や複数ウィンドウの詳細
- 状態管理ライブラリと組み合わせた画面分割
今回の主題は「タブレット判定」そのものではありません。いま使える幅と、親から渡された制約を見分けて、情報量を増やす基準を持つことにあります。前記事のテーマ設計で整理した見た目の共通化と、次記事の FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本) で扱う余白・サイズ調整の間をつなぐ位置づけです。
2. 先に 3 つの役割を分ける
最初に MediaQuery、LayoutBuilder、OrientationBuilder の役割を分けます。
| API | 何を見るか | 向いている場面 | まず避けたい誤用 |
|---|---|---|---|
MediaQuery | 画面全体の幅・高さ・余白・向き・文字倍率 | 画面全体の余白、ブレークポイント、SafeArea への対応 | カード内部の幅判定まで全部ここで済ませる |
LayoutBuilder | その親から渡された制約 | カラム数切り替え、カード群の並び替え、2 ペイン分割 | 端末全体の情報までここで読もうとする |
OrientationBuilder | 縦向き / 横向き | 写真ビューア、グリッド密度の切り替え、横向き専用 UI | 幅が足りるかの判定を向きだけで済ませる |
見る対象が違うので、競合する API ではありません。
MediaQuery: 「この画面はいまどれくらい広いか」を見るLayoutBuilder: 「この箱の中でどれだけ使えるか」を見るOrientationBuilder: 「向きそのものに意味があるか」を見る
flowchart TD
A[何を見て分岐したいか] --> B{画面全体の情報か}
B -->|Yes| C[MediaQuery]
B -->|No| D{親の幅や高さか}
D -->|Yes| E[LayoutBuilder]
D -->|No| F{縦横の向きだけで十分か}
F -->|Yes| G[OrientationBuilder]
F -->|No| H[幅と中身を見直す]
タブレット対応で止まりやすいのは、「タブレットなら 2 カラム」と端末名で決めてしまうところです。同じタブレットでも分割表示やサイドバーの有無で使える幅は変わります。まずは端末名ではなく、画面全体の幅と親幅で判断するのが安全です。
以降のコード例はすべて lib/main.dart にそのまま貼って flutter run で確認できます。外部パッケージ不要のため、DartPad でも試せます。
3. MediaQuery で画面全体の情報を読む
MediaQuery は、画面全体の情報を 1 か所で読む入口です。まずは幅、高さ、向き、SafeArea 余白を表示する最小例から見ます。
lib/main.dart は次の内容で作成します。
このファイルは、画面全体の幅、高さ、余白、文字倍率を 1 画面で可視化するサンプルです。注目点は、MediaQuery を端末判定専用ではなく、画面環境をまとめて読む入口として使っていることです。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'MediaQuery sample',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: const ScreenInfoPage(),
);
}
}
class ScreenInfoPage extends StatelessWidget {
const ScreenInfoPage({super.key});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final size = mediaQuery.size;
final orientation = mediaQuery.orientation;
final shortestSide = size.shortestSide;
final sampleTextScale = mediaQuery.textScaler.scale(16) / 16;
final screenClass = switch (size.width) {
>= 840 => 'tablet-wide',
>= 600 => 'tablet-compact',
_ => 'phone',
};
return Scaffold(
appBar: AppBar(title: const Text('MediaQuery sample')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'画面クラス: $screenClass',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Text('width: ${size.width.toStringAsFixed(1)}'),
Text('height: ${size.height.toStringAsFixed(1)}'),
Text('shortestSide: ${shortestSide.toStringAsFixed(1)}'),
Text('orientation: $orientation'),
Text(
'padding: '
'top=${mediaQuery.padding.top.toStringAsFixed(1)}, '
'bottom=${mediaQuery.padding.bottom.toStringAsFixed(1)}',
),
Text(
'sampleTextScale: '
'${sampleTextScale.toStringAsFixed(2)}',
),
],
),
),
),
const SizedBox(height: 16),
Expanded(
child: Card(
color: Colors.teal.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'この例で見たい点',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
const Text('1. 画面全体の幅と高さは MediaQuery で読む'),
const SizedBox(height: 8),
const Text('2. SafeArea 余白や文字倍率も同じ場所で確認できる'),
const SizedBox(height: 8),
const Text('3. ブレークポイント値は目安であり、画面の役割に応じて調整する'),
],
),
),
),
),
],
),
),
);
}
}
コードのポイント
① MediaQuery から画面環境をまとめて読む
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final size = mediaQuery.size;
final orientation = mediaQuery.orientation;
final shortestSide = size.shortestSide;
final sampleTextScale = mediaQuery.textScaler.scale(16) / 16;
ここでは幅と高さだけでなく、向き、SafeArea 余白、文字倍率まで同じ入口で読んでいます。MediaQuery を単なる端末判定 API としてではなく、画面環境の集約点として扱うことが、このあとの判断を安定させます。
② screenClass は全体レイアウトの目安として使う
final screenClass = switch (size.width) {
>= 840 => 'tablet-wide',
>= 600 => 'tablet-compact',
_ => 'phone',
};
ここで決めた screenClass は、画面全体の余白やセクションの情報量を切り替える目安として使いやすい値です。ただし、カードの中や一覧のカラム数まで全部を size.width だけで決めると粗くなりがちなので、親コンテナが狭い場面では LayoutBuilder のほうが合います。
4. LayoutBuilder で親幅に応じてカラム数を切り替える
次は、カード群を 1 カラムと 2 カラムで切り替える例です。ここでは全画面の幅ではなく、親から渡された constraints.maxWidth が判定の基準です。
lib/main.dart は次の内容で作成します。
このファイルは、親コンテナの幅に応じてカード群を 1 カラムと 2 カラムへ切り替えるサンプルです。注目点は、全画面幅ではなく constraints.maxWidth を基準にしていることです。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'LayoutBuilder sample',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
home: const PanelGridPage(),
);
}
}
class PanelItem {
const PanelItem({
required this.title,
required this.value,
required this.color,
});
final String title;
final String value;
final Color color;
}
class PanelGridPage extends StatelessWidget {
const PanelGridPage({super.key});
static const panels = [
PanelItem(title: '未処理', value: '12', color: Color(0xFFE8F5E9)),
PanelItem(title: '要確認', value: '3', color: Color(0xFFFFF3E0)),
PanelItem(title: '出荷済み', value: '42', color: Color(0xFFE3F2FD)),
PanelItem(title: '保留', value: '2', color: Color(0xFFFCE4EC)),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('LayoutBuilder sample')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 960),
child: Padding(
padding: const EdgeInsets.all(16),
child: LayoutBuilder(
builder: (context, constraints) {
final isTwoColumns = constraints.maxWidth >= 720;
final spacing = 16.0;
final panelWidth = isTwoColumns
? (constraints.maxWidth - spacing) / 2
: constraints.maxWidth;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'maxWidth: ${constraints.maxWidth.toStringAsFixed(1)}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(isTwoColumns ? '2 カラム表示' : '1 カラム表示'),
const SizedBox(height: 16),
Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final panel in panels)
SizedBox(
width: panelWidth,
child: Card(
color: panel.color,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
panel.title,
style: Theme.of(context)
.textTheme
.titleMedium,
),
const SizedBox(height: 12),
Text(
panel.value,
style: Theme.of(context)
.textTheme
.headlineMedium,
),
],
),
),
),
),
],
),
],
),
);
},
),
),
),
),
);
}
}
コードのポイント
① constraints.maxWidth を基準にカラム数を決める
child: LayoutBuilder(
builder: (context, constraints) {
final isTwoColumns = constraints.maxWidth >= 720;
final spacing = 16.0;
final panelWidth = isTwoColumns
? (constraints.maxWidth - spacing) / 2
: constraints.maxWidth;
constraints.maxWidth は「全画面の幅」ではなく「この場所で実際に使える幅」です。中央寄せされたカード領域やモーダル内でも同じロジックを再利用しやすいのは、この基準で判定しているからです。
② ConstrainedBox で親幅を絞ってから LayoutBuilder へ渡す
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 960),
child: Padding(
padding: const EdgeInsets.all(16),
child: LayoutBuilder(
この例では ConstrainedBox(maxWidth: 960) を先にかませているため、全画面が広くても親コンテナの幅を基準にできます。タブレット対応でありがちなのは、画面全体が広いからといってカード内部まで一律に 2 カラムへ寄せることですが、レイアウト本体の切り替えは LayoutBuilder へ寄せたほうが安定します。
5. MediaQuery と LayoutBuilder を組み合わせて 1 ペイン / 2 ペインを切り替える
ここからは、一覧と詳細を持つ業務画面に近い例です。画面全体の余白は MediaQuery で決め、内部の 1 ペイン / 2 ペイン切り替えは LayoutBuilder が担います。
lib/main.dart は次の内容で作成します。
このファイルは、外側の余白判定と内側の 1 ペイン / 2 ペイン判定を分けたレスポンシブ画面です。注目点は、MediaQuery で大まかな画面クラスを決め、LayoutBuilder で実際のコンテンツ幅に応じて詳細ペインの配置を切り替えていることです。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Responsive dashboard sample',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueGrey),
),
home: const ShipmentDashboardPage(),
);
}
}
class Shipment {
const Shipment({
required this.code,
required this.customer,
required this.status,
required this.updatedAt,
});
final String code;
final String customer;
final String status;
final String updatedAt;
}
class ShipmentDashboardPage extends StatefulWidget {
const ShipmentDashboardPage({super.key});
@override
State<ShipmentDashboardPage> createState() => _ShipmentDashboardPageState();
}
class _ShipmentDashboardPageState extends State<ShipmentDashboardPage> {
static const shipments = [
Shipment(
code: 'S-1001',
customer: '東京商事',
status: '未処理',
updatedAt: '09:20',
),
Shipment(
code: 'S-1002',
customer: '大阪物産',
status: '要確認',
updatedAt: '09:42',
),
Shipment(
code: 'S-1003',
customer: '名古屋販売',
status: '出荷済み',
updatedAt: '10:05',
),
Shipment(
code: 'S-1004',
customer: '福岡流通',
status: '保留',
updatedAt: '10:18',
),
];
int selectedIndex = 0;
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final outerPadding = mediaQuery.size.width >= 840 ? 24.0 : 16.0;
final isTabletClass = mediaQuery.size.width >= 600;
final selectedShipment = shipments[selectedIndex];
return Scaffold(
appBar: AppBar(
title: const Text('Responsive dashboard sample'),
),
body: Padding(
padding: EdgeInsets.all(outerPadding),
child: LayoutBuilder(
builder: (context, constraints) {
final isTwoPane = constraints.maxWidth >= 900;
final listPane = Card(
child: ListView.separated(
itemCount: shipments.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final shipment = shipments[index];
final isSelected = index == selectedIndex;
return ListTile(
selected: isSelected,
title: Text(shipment.code),
subtitle: Text(shipment.customer),
trailing: Text(shipment.updatedAt),
onTap: () {
setState(() {
selectedIndex = index;
});
},
);
},
),
);
final detailPane = Card(
color: Colors.blueGrey.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedShipment.code,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text('取引先: ${selectedShipment.customer}'),
const SizedBox(height: 8),
Text('状態: ${selectedShipment.status}'),
const SizedBox(height: 8),
Text('最終更新: ${selectedShipment.updatedAt}'),
const SizedBox(height: 16),
Text(
isTwoPane
? '幅が広いので、一覧と詳細を同時に表示しています。'
: '幅が限られるので、一覧の下に詳細を積んでいます。',
),
const SizedBox(height: 12),
Text('screenClass: ${isTabletClass ? 'tablet' : 'phone'}'),
Text(
'contentWidth: ${constraints.maxWidth.toStringAsFixed(1)}',
),
],
),
),
);
if (isTwoPane) {
return Row(
children: [
SizedBox(width: 320, child: listPane),
const SizedBox(width: 16),
Expanded(child: detailPane),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: listPane),
const SizedBox(height: 16),
detailPane,
],
);
},
),
),
);
}
}
コードのポイント
① 外側の余白と大まかな画面クラスは MediaQuery で決める
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final outerPadding = mediaQuery.size.width >= 840 ? 24.0 : 16.0;
final isTabletClass = mediaQuery.size.width >= 600;
外側の余白や大まかな画面クラスは、画面全体の幅を見たほうが決めやすい要素です。screenClass をこの段階で持っておくと、詳細ペイン内の表示文言や全体の情報量調整にも使い回せます。
② 詳細ペインの分割は LayoutBuilder で実幅を見る
child: LayoutBuilder(
builder: (context, constraints) {
final isTwoPane = constraints.maxWidth >= 900;
final listPane = Card(
child: ListView.separated(
itemCount: shipments.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final shipment = shipments[index];
final isSelected = index == selectedIndex;
return ListTile(
selected: isSelected,
title: Text(shipment.code),
subtitle: Text(shipment.customer),
trailing: Text(shipment.updatedAt),
);
},
),
);
if (isTwoPane) {
return Row(
children: [
SizedBox(width: 320, child: listPane),
const SizedBox(width: 16),
Expanded(child: detailPane),
],
);
}
return Column(
同じタブレットでも、アプリバーやサイドパネルの有無でコンテンツ領域の幅は変わります。そのため一覧と詳細の分割は MediaQuery の幅だけで決めず、実際に使える constraints.maxWidth を見て 1 ペイン / 2 ペインを切り替えるほうが安全です。
flowchart LR
A[MediaQuery.size.width] --> B[外側余白と画面クラスを決める]
C[LayoutBuilder.maxWidth] --> D[1ペイン / 2ペインを決める]
B --> E[一覧 + 詳細画面]
D --> E
この考え方は、後続の一覧検索や CRUD 画面でもそのまま使えます。スマホでは縦に積み、広い端末では同時表示へ広げる、という流れです。
6. OrientationBuilder を出す場面と、幅判定へ寄せる場面を分ける
OrientationBuilder は不要ではありません。ただし、幅が足りるかどうかを向きだけで判断する用途には向きません。
OrientationBuilder を使いやすい場面は次のようなケースです。
- 写真や動画ビューアで、横向きのときだけコントロール配置を変えたい
- グリッド一覧で、縦向きは 2 列、横向きは 3 列のように向き自体へ意味を持たせたい
- 入力フォームではなく、閲覧中心の画面で横向き専用 UI を明確に分けたい
逆に、幅判定へ寄せたほうがよい場面は次の通りです。
- 一覧と詳細を同時表示できるかを決めたい
- カードの 1 カラム / 2 カラムを親幅で切り替えたい
- タブレットでも分割表示やサイドバー込みで幅が変わる可能性がある
横向きでも幅が十分とは限りません。縦向きのタブレットでは十分な幅がある場合もあります。情報量やパネル分割は幅判定へ寄せ、向きそのものに意味がある場面だけ OrientationBuilder を使うほうが整理しやすくなります。
7. まとめ
MediaQuery は画面全体の環境を読む入口、LayoutBuilder はその場で使える幅を見る入口です。スマホとタブレットの両対応では、この 2 つを競合させるのではなく、見る対象ごとに分けるほうが崩れにくくなります。前の記事 Flutterのテーマ設計入門(ThemeData + Theme Extension) で整理した見た目の共通化に加えて、今回の幅判定を持てると、次の FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本) で余白やサイズ調整を考えるときも判断がしやすくなります。