Flutter: レイアウトシステム

Flutterのレイアウトは組み立てラインのようなもの — 親が制約を渡し、子がサイズを報告し、親が位置を決定します。

📋 前提条件: 以下を先に完了している必要があります

1. このレッスンで学ぶこと


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. ボックスモデルと制約受け渡し

100%
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です。

📖 まとめ


📝 練習問題

  1. 基本(難易度 ⭐): 水平スクロールするカテゴリタグバー(Chip + ListView horizontal方向)を作成してください。
  2. 中級(難易度 ⭐⭐): 商品詳細ページのレイアウトを実装してください:上部の大きな画像 + 中央の情報セクション + 下部の価格/購入ボタンバー、Column + Expandedを使用。
  3. チャレンジ(難易度 ⭐⭐⭐): LayoutBuilderを使ってレスポンシブ商品グリッドを実装してください:スマートフォンでは2列、タブレットでは3列、デスクトップでは4列、ウィンドウリサイズでリアルタイムに切り替え。

← 前へ | 次へ →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%