Flutter: レイアウトシステム
Flutterのレイアウトは組み立てラインのようなもの — 親が制約を渡し、子がサイズを報告し、親が位置を決定します。
📋 前提条件: 以下を先に完了している必要があります
- レッスン3:Widgetの基礎
1. このレッスンで学ぶこと
- ボックスモデル:制約 → サイズ → 位置の制約受け渡しプロトコル
- 線形レイアウト:Row/ColumnとMain/CrossAxisAlignmentの配置
- 弾性レイアウト:Flex/Expanded/Flexibleの比例配分
- 重ね合わせレイアウト:Stack/Positionedによるオーバーレイとバッジ
- ShopApp商品グリッドレイアウト:GridView.countとウォーターフォールフロー
2. UIデザイナーが開発に転向したストーリー
(1) 悩み:黄色と黒のオーバーフローストライプの悪夢
BobはShopAppのUIデザイン仕様を受け取りました — 商品カードは左に画像、右に情報、下部に価格行が必要です。Columnの中にRowをネストすると、画面いっぱいに黄黒のストライプのオーバーフロー警告が表示されました。paddingとmarginを長時間調整しても、テキストが途切れたり画像が歪んだりしました。1000商品、10画面サイズ、レイアウト問題だらけでした。
(2) 制約受け渡しのソリューション
Flutterのレイアウトには一つの中核ルールがあります:制約は下に渡され、サイズは上に報告され、位置は親が決定する。このルールを理解すれば、すべてのWidgetの動作を予測できます。
DART
import 'package:flutter/material.dart';
// Parent gives tight constraints: exactly 200x100
SizedBox(
width: 200,
height: 100,
child: Text('I will be 200x100'), // Text forced to 200x100
)
// Parent gives loose constraints: 0 to maxWidth
Row(
children: [
Expanded(child: Text('I take remaining space')),
SizedBox(width: 80, child: Text('I am 80 wide')),
],
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(3) 結果:初回でレイアウトを正しく
制約受け渡しをマスターした後、Bobの初回レイアウトの精度は30%から90%に向上し、オーバーフロー警告は完全に消えました。
3. ボックスモデルと制約受け渡し
graph TD
subgraph Constraint Passing
P[Parent Constraints] --> C[Child Layout]
C --> S[Child Size]
S --> P
end
subgraph Common Layouts
R[Row] --> EX1[Expanded]
CO[Column] --> EX2[Flexible]
ST[Stack] --> POS[Positioned]
end
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(1) 制約の種類
| 種類 | 説明 | 例 |
|---|---|---|
| Tight | min = max、固定サイズ | SizedBox(width: 100) |
| Loose | min = 0、max = 親の最大値 | Center内の子 |
| Unbounded | max = 無限大 | ListView内のColumn |
| Bounded | minとmax両方が有限 | Page内のColumn |
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 制約受け渡しのデモ
DART
import 'package:flutter/material.dart';
// Demo: constraint passing in action
class ConstraintDemo extends StatelessWidget {
const ConstraintDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// Tight constraint: exactly 200 height
Container(
height: 200,
color: Colors.blue[100],
child: const Center(child: Text('Height: 200 (tight)')),
),
// Loose constraint: takes remaining space
Expanded(
child: Container(
color: Colors.green[100],
child: const Center(child: Text('Expanded (loose, fills remaining)')),
),
),
],
),
);
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
4. 線形レイアウト:RowとColumn
(1) 配置プロパティ
| プロパティ | 方向 | Rowでの効果 | Columnでの効果 |
|---|---|---|---|
mainAxisAlignment |
主軸 | 水平配置 | 垂直配置 |
crossAxisAlignment |
交差軸 | 垂直配置 | 水平配置 |
mainAxisSize |
主軸サイズ | 幅 | 高さ |
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 商品情報行(Row)
DART
import 'package:flutter/material.dart';
// Product info row: image + details
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Fixed-size image
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
'https://cdn.shopapp.com/laptop.jpg',
width: 100,
height: 100,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
// Flexible details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pro Laptop 16"',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text('Electronics > Laptops',
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
const SizedBox(height: 8),
Row(
children: [
const Text('\$1,299.99',
style: TextStyle(fontSize: 18, color: Colors.green)),
const SizedBox(width: 8),
Text('\$1,599.99',
style: TextStyle(
fontSize: 14,
decoration: TextDecoration.lineThrough,
color: Colors.grey[400],
)),
],
),
],
),
),
],
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(2) 一般的な配置値の比較
| MainAxisAlignment | 効果 |
|---|---|
start |
先頭に配置(デフォルト) |
center |
中央配置 |
end |
末尾に配置 |
spaceBetween |
最初と最後を端に、間は等間隔 |
spaceAround |
各アイテムの両側に等しい間隔 |
spaceEvenly |
すべての間隔が等しい |
5. 弾性レイアウト:ExpandedとFlexible
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 価格とボタンの弾性レイアウト
DART
import 'package:flutter/material.dart';
// Bottom bar: price (expanded) + button (fixed)
Container(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
flex: 2, // Takes 2/3 of space
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Total', style: TextStyle(fontSize: 12)),
Text('\$${total.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
],
),
),
Expanded(
flex: 1, // Takes 1/3 of space
child: ElevatedButton(
onPressed: onCheckout,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('Checkout'),
),
),
],
),
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
| ウィジェット | 動作 | 残りのスペース | 用途 |
|---|---|---|---|
Expanded |
強制的に埋める | 比例配分される | すべてのスペースを埋める必要がある場合 |
Flexible |
オプションで埋める | 比例配分されるが、少なくてもよい | 弾力的だが強制しない場合 |
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: Expanded vs Flexible
DART
import 'package:flutter/material.dart';
Row(
children: [
// Expanded: must fill allocated space
Expanded(
flex: 1,
child: Container(color: Colors.red, child: const Text('Expanded')),
),
// Flexible: can be smaller than allocated space
Flexible(
flex: 1,
child: Container(
color: Colors.blue,
child: const Text('Flexible'), // May not fill all space
),
),
// Fixed width
SizedBox(width: 80, child: Container(color: Colors.green, child: const Text('Fixed'))),
],
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
6. 重ね合わせレイアウト:StackとPositioned
Stackは子を描画順に重ねることを許可し、Positionedは絶対位置を制御します。
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 商品画像のバッジ
DART
import 'package:flutter/material.dart';
// Simplified class definition
class Product {
final String imageUrl;
const Product({required this.imageUrl});
}
// Product image with discount badge and favorite button
Stack(
children: [
// Base image
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
product.imageUrl,
height: 200,
width: double.infinity,
fit: BoxFit.cover,
),
),
// Discount badge (top-left)
Positioned(
top: 8,
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: Text('${discountPercent}% OFF',
style: const TextStyle(color: Colors.white, fontSize: 12)),
),
),
// Favorite button (top-right)
Positioned(
top: 8,
right: 8,
child: CircleAvatar(
backgroundColor: Colors.white.withOpacity(0.8),
child: const Icon(Icons.favorite_border, size: 18),
),
),
// Stock indicator (bottom)
Positioned(
bottom: 8,
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: const Text('In Stock', style: TextStyle(color: Colors.white, fontSize: 10)),
),
),
],
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
| Stackのプロパティ | 説明 | デフォルト |
|---|---|---|
alignment |
Positioned以外の子の配置 | AlignmentDirectional.topStart |
fit |
子の適応方法 | StackFit.loose |
clipBehavior |
はみ出しのクリッピング | Clip.hardEdge |
7. GridViewグリッドレイアウト
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: ShopApp商品グリッド
DART
import 'package:flutter/material.dart';
// Simplified class definition
class Product {
final int id;
final String name;
final double price;
const Product({required this.id, required this.name, required this.price});
}
// Two-column product grid
GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.75, // height = width / 0.75
padding: const EdgeInsets.all(8),
children: products.map((product) => ProductCard(
key: ValueKey(product.id),
product: product,
onAddToCart: () => addToCart(product),
)).toList(),
)
// Responsive grid with LayoutBuilder
LayoutBuilder(
builder: (context, constraints) {
int columns = (constraints.maxWidth / 180).floor().clamp(2, 4);
return GridView.count(
crossAxisCount: columns,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.75,
children: productWidgets,
);
},
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
| GridViewのプロパティ | 説明 |
|---|---|
crossAxisCount |
1行あたりの列数 |
mainAxisSpacing |
主軸の間隔 |
crossAxisSpacing |
交差軸の間隔 |
childAspectRatio |
子アイテムの幅と高さの比 |
padding |
内側の余白 |
8. 完成例:ShopAppホームページレイアウト
DART
import 'package:flutter/material.dart';
class ShopHomePage extends StatelessWidget {
const ShopHomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ShopApp'),
actions: [
Stack(
children: [
IconButton(icon: const Icon(Icons.shopping_cart), onPressed: () {}),
Positioned(right: 4, top: 4,
child: CircleAvatar(radius: 8, backgroundColor: Colors.red,
child: const Text('5', style: TextStyle(fontSize: 10, color: Colors.white)))),
],
),
],
),
body: CustomScrollView(
slivers: [
// Banner section
SliverToBoxAdapter(
child: Container(
height: 180,
color: Colors.blue[50],
child: const Center(child: Text('Flash Sale - Up to 50% OFF',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold))),
),
),
// Category chips
SliverToBoxAdapter(
child: SizedBox(
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: ['All', 'Electronics', 'Clothing', 'Books', 'Home']
.map((c) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Chip(label: Text(c)),
)).toList(),
),
),
),
// Product grid
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid.count(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.7,
children: List.generate(20, (i) => _buildProductCard(i)),
),
),
],
),
);
}
Widget _buildProductCard(int index) {
return Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: Container(
color: Colors.grey[200],
child: const Icon(Icons.image, size: 48, color: Colors.grey),
),
),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Product ${index + 1}', maxLines: 1, overflow: TextOverflow.ellipsis),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('\$${(19.99 + index * 10).toStringAsFixed(2)}',
style: const TextStyle(color: Colors.green)),
const Icon(Icons.add_circle, size: 20),
],
),
],
),
),
),
],
),
);
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
❓ よくある質問
Q Rowで黄黒のオーバーフローストライプが出た場合は?
A Rowの子の合計幅がRowの幅を超えています。可変幅の子をExpandedでラップするか、Flexibleでアダプティブサイズにしてください。
Q ExpandedとFlexibleの違いは?
A Expandedは子に割り当てられたスペースを強制的に埋めます(Flexible(fit: FlexFit.tight)と同等)。Flexibleは子が割り当てより小さくてよいことを許可します(fit: FlexFit.loose)。
Q StackでPositioned以外の子はどう配置されますか?
A Stackのalignmentプロパティに従って配置され、デフォルトは左上角です。
Q GridView.countとGridView.builderの違いは?
A countは少なく固定数のアイテム用。builderは大規模データリストで遅延読み込みを行います(百万件の商品リストにはbuilderを使用)。
Q Column内にListViewを入れるとエラーになるのはなぜ?
A Columnは子に無制限の高さ制約を与え、ListViewは自身の高さを決定できません。ListViewをExpandedでラップするか、shrinkWrap: trueを使用してください。
Q childAspectRatioはどう計算しますか?
A ratio = 幅 / 高さ。各アイテムが幅180、高さ240の場合、ratio = 180/240 = 0.75です。
📖 まとめ
- Flutterレイアウトの中核:制約は下に渡され、サイズは上に報告され、位置は親が決定する
- Row/Columnは線形レイアウト、Main/CrossAxisAlignmentで制御
- Expandedは強制埋め、Flexibleは弾性的に適応、flexが比例を制御
- Stack + Positionedで重ね合わせ効果(バッジ、オーバーレイ)を作成
- GridView.countで商品グリッドを構築、LayoutBuilderでレスポンシブ列数を実現
📝 練習問題
- 基本(難易度 ⭐): 水平スクロールするカテゴリタグバー(Chip + ListView horizontal方向)を作成してください。
- 中級(難易度 ⭐⭐): 商品詳細ページのレイアウトを実装してください:上部の大きな画像 + 中央の情報セクション + 下部の価格/購入ボタンバー、Column + Expandedを使用。
- チャレンジ(難易度 ⭐⭐⭐): LayoutBuilderを使ってレスポンシブ商品グリッドを実装してください:スマートフォンでは2列、タブレットでは3列、デスクトップでは4列、ウィンドウリサイズでリアルタイムに切り替え。