0
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Flutter入門 第9回】レイアウトの基本 ― Row・Column・Container・Stack で画面を組み立てる

0
Posted at

はじめに

Flutter では、Widget を組み合わせてツリー構造を作ることで UI を構築します。HTML/CSS のような「テンプレート + スタイルシート」方式ではなく、すべてが Widget です。テキストもボタンもパディングもレイアウトもすべて Widget であり、それらを入れ子にしていくことで画面を作ります。

この記事では、Flutter でレイアウトを組む際に最も頻繁に使う Widget を順番に解説します。


1. Column ― 縦方向に Widget を並べる

Column は子 Widget を**縦方向(上から下)**に並べます。

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(
      home: Scaffold(
        appBar: AppBar(title: const Text('Column デモ')),
        body: Column(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Container(width: 100, height: 50, color: Colors.red),
            Container(width: 150, height: 50, color: Colors.green),
            Container(width: 200, height: 50, color: Colors.blue),
          ],
        ),
      ),
    );
  }
}

mainAxisAlignment(主軸方向の配置)

Column の主軸は縦方向です。mainAxisAlignment で子 Widget の縦方向の配置を制御します。

説明
MainAxisAlignment.start 上詰め(デフォルト)
MainAxisAlignment.center 中央揃え
MainAxisAlignment.end 下詰め
MainAxisAlignment.spaceBetween 最初と最後の Widget を端に置き、残りを均等配置
MainAxisAlignment.spaceEvenly すべての間隔を均等にする
MainAxisAlignment.spaceAround 各 Widget の周囲に均等なスペースを置く(端は半分)

crossAxisAlignment(交差軸方向の配置)

Column の交差軸は横方向です。crossAxisAlignment で子 Widget の横方向の配置を制御します。

説明
CrossAxisAlignment.center 中央揃え(デフォルト)
CrossAxisAlignment.start 左揃え
CrossAxisAlignment.end 右揃え
CrossAxisAlignment.stretch 横幅いっぱいに引き伸ばす

2. Row ― 横方向に Widget を並べる

Row は子 Widget を**横方向(左から右)**に並べます。Column と同じ mainAxisAlignment / crossAxisAlignment プロパティを持ちますが、軸の方向が逆になります。

  • Row の主軸は横方向交差軸は縦方向
  • Column の主軸は縦方向交差軸は横方向
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(
      home: Scaffold(
        appBar: AppBar(title: const Text('Row デモ')),
        body: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Container(width: 80, height: 80, color: Colors.red),
            Container(width: 80, height: 120, color: Colors.green),
            Container(width: 80, height: 60, color: Colors.blue),
          ],
        ),
      ),
    );
  }
}

覚え方: Row は「行(横一列)」、Column は「列(縦一列)」。mainAxis は Widget が並ぶ方向、crossAxis はそれと直交する方向です。


3. Container ― 装飾・サイズ・余白を指定する万能 Widget

Container は単一の子 Widget に対して、サイズ・色・余白・装飾などを設定できます。

基本プロパティ

Container(
  width: 200,
  height: 100,
  color: Colors.blue,          // 背景色(decoration と併用不可)
  padding: EdgeInsets.all(16),  // 内側の余白
  margin: EdgeInsets.all(8),    // 外側の余白
  child: Text('Hello'),
)

EdgeInsets のバリエーション

コンストラクタ 説明
EdgeInsets.all(16) 上下左右すべて 16
EdgeInsets.symmetric(horizontal: 16, vertical: 8) 横 16、縦 8
EdgeInsets.only(left: 10, top: 20) 指定した辺だけ
EdgeInsets.fromLTRB(10, 20, 10, 20) 左・上・右・下の順

decoration(BoxDecoration)

decoration を使うと、角丸・枠線・影・グラデーションなど高度な装飾ができます。color プロパティと decoration は同時に使えません。色は BoxDecoration 内の color に指定してください。

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(
      home: Scaffold(
        appBar: AppBar(title: const Text('Container decoration')),
        body: Center(
          child: Container(
            width: 250,
            height: 150,
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(16),
              border: Border.all(color: Colors.grey, width: 2),
              boxShadow: [
                BoxShadow(
                  color: Colors.black.withOpacity(0.2),
                  blurRadius: 8,
                  offset: const Offset(2, 4),
                ),
              ],
              gradient: const LinearGradient(
                colors: [Colors.lightBlue, Colors.blueAccent],
                begin: Alignment.topLeft,
                end: Alignment.bottomRight,
              ),
            ),
            child: const Center(
              child: Text(
                'カード風デザイン',
                style: TextStyle(color: Colors.white, fontSize: 18),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

4. Expanded / Flexible ― 残りスペースの分配

RowColumn の中で、余ったスペースを子 Widget に分配するには Expanded または Flexible を使います。

Expanded

Expanded は残りのスペースをすべて埋めるように子 Widget を拡張します。flex プロパティで比率を指定できます(デフォルトは 1)。

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(
      home: Scaffold(
        appBar: AppBar(title: const Text('Expanded デモ')),
        body: Row(
          children: [
            Expanded(
              flex: 2, // 2/4 = 50%
              child: Container(height: 100, color: Colors.red),
            ),
            Expanded(
              flex: 1, // 1/4 = 25%
              child: Container(height: 100, color: Colors.green),
            ),
            Expanded(
              flex: 1, // 1/4 = 25%
              child: Container(height: 100, color: Colors.blue),
            ),
          ],
        ),
      ),
    );
  }
}

上記の場合、flex の合計は 2 + 1 + 1 = 4 です。赤は 2/4 = 50%、緑と青はそれぞれ 1/4 = 25% の幅になります。

Flexible

FlexibleExpanded と似ていますが、fit プロパティで挙動を制御できます。

  • FlexFit.tight(Expanded と同じ): 残りスペースを埋める
  • FlexFit.loose(Flexible のデフォルト): 子 Widget が必要とするサイズまでしか使わない

5. Stack / Positioned ― 重ね合わせレイアウト

Stack は子 Widget を重ねて配置します。CSS の position: absolute に相当する概念です。

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(
      home: Scaffold(
        appBar: AppBar(title: const Text('Stack デモ')),
        body: Center(
          child: SizedBox(
            width: 300,
            height: 300,
            child: Stack(
              alignment: Alignment.center,
              children: [
                // 一番下のレイヤー
                Container(width: 300, height: 300, color: Colors.red),
                // 中間レイヤー
                Container(width: 200, height: 200, color: Colors.green),
                // 一番上のレイヤー
                Container(width: 100, height: 100, color: Colors.blue),
                // Positioned で自由に配置
                const Positioned(
                  top: 10,
                  right: 10,
                  child: Icon(Icons.star, color: Colors.yellow, size: 40),
                ),
                const Positioned(
                  bottom: 10,
                  left: 10,
                  child: Text(
                    '左下',
                    style: TextStyle(color: Colors.white, fontSize: 16),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
  • alignment: Positioned でない子 Widget の配置を指定(デフォルトは Alignment.topLeft
  • Positioned: top, left, right, bottom で親 Stack の端からの距離を指定

6. SizedBox ― 固定サイズのスペーサー

SizedBox は固定の幅・高さを持つボックスです。Widget 間のスペースとしてよく使います。

Column(
  children: [
    Container(width: 100, height: 50, color: Colors.red),
    const SizedBox(height: 20), // 縦方向に 20px のスペース
    Container(width: 100, height: 50, color: Colors.blue),
  ],
)

Row 内では SizedBox(width: 20) のように横方向のスペースとして使えます。


7. Wrap ― 折り返しレイアウト

Wrap は子 Widget が親の幅(または高さ)に収まらない場合に自動で折り返します。タグ一覧やチップの表示に便利です。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    final tags = ['Flutter', 'Dart', 'Widget', 'レイアウト', 'UI', 'モバイル', '入門'];
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Wrap デモ')),
        body: Padding(
          padding: const EdgeInsets.all(16),
          child: Wrap(
            spacing: 8,        // 横方向のスペース
            runSpacing: 8,     // 行間のスペース
            children: tags.map((tag) {
              return Chip(
                label: Text(tag),
                backgroundColor: Colors.blue[100],
              );
            }).toList(),
          ),
        ),
      ),
    );
  }
}

8. ListView ― スクロール可能なリスト

ListView はスクロール可能なリストを作成します。子 Widget の数が多い場合は ListView.builder を使うと効率的です。

ListView(少量データ向け)

ListView(
  children: [
    ListTile(title: Text('アイテム 1')),
    ListTile(title: Text('アイテム 2')),
    ListTile(title: Text('アイテム 3')),
  ],
)

ListView.builder(大量データ向け)

ListView.builder は表示される部分だけを遅延生成するため、大量のデータを効率的に表示できます。

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(
      home: Scaffold(
        appBar: AppBar(title: const Text('ListView.builder デモ')),
        body: ListView.builder(
          itemCount: 100,
          itemBuilder: (context, index) {
            return ListTile(
              leading: CircleAvatar(child: Text('${index + 1}')),
              title: Text('アイテム ${index + 1}'),
              subtitle: Text('これは ${index + 1} 番目の要素です'),
            );
          },
        ),
      ),
    );
  }
}

itemCount は要素の総数です。itemBuilder は各要素を生成するコールバックで、index は 0 から始まります。上記の場合、表示されるテキストは「アイテム 1」〜「アイテム 100」です。


9. SingleChildScrollView ― 画面全体をスクロール可能にする

Column のコンテンツが画面に収まらない場合、SingleChildScrollView で囲むとスクロール可能になります。

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(
      home: Scaffold(
        appBar: AppBar(title: const Text('SingleChildScrollView デモ')),
        body: SingleChildScrollView(
          padding: const EdgeInsets.all(16),
          child: Column(
            children: List.generate(20, (index) {
              return Container(
                width: double.infinity,
                height: 80,
                margin: const EdgeInsets.only(bottom: 8),
                color: Colors.primaries[index % Colors.primaries.length],
                child: Center(
                  child: Text(
                    'ボックス ${index + 1}',
                    style: const TextStyle(color: Colors.white, fontSize: 18),
                  ),
                ),
              );
            }),
          ),
        ),
      ),
    );
  }
}

注意: Column + SingleChildScrollView の場合、Column 内で Expanded は使えません。スクロール領域にはサイズの制約がないため、「残りスペース」という概念が成立しないからです。


10. 実践例 ― SNS風プロフィール画面

ここまで学んだ Widget を組み合わせて、SNS 風のプロフィール画面を作ってみましょう。

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(
      theme: ThemeData(useMaterial3: true),
      home: const ProfilePage(),
    );
  }
}

class ProfilePage extends StatelessWidget {
  const ProfilePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('プロフィール')),
      body: SingleChildScrollView(
        child: Column(
          children: [
            // ヘッダー部分(カバー画像 + アバター)
            Stack(
              clipBehavior: Clip.none,
              children: [
                Container(
                  width: double.infinity,
                  height: 180,
                  color: Colors.blueAccent,
                  child: const Center(
                    child: Text(
                      'カバー画像エリア',
                      style: TextStyle(color: Colors.white70, fontSize: 16),
                    ),
                  ),
                ),
                const Positioned(
                  bottom: -40,
                  left: 16,
                  child: CircleAvatar(
                    radius: 45,
                    backgroundColor: Colors.white,
                    child: CircleAvatar(
                      radius: 42,
                      backgroundColor: Colors.grey,
                      child: Icon(Icons.person, size: 50, color: Colors.white),
                    ),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 50),

            // ユーザー情報
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text(
                    'Flutter太郎',
                    style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
                  ),
                  const Text(
                    '@flutter_taro',
                    style: TextStyle(color: Colors.grey, fontSize: 14),
                  ),
                  const SizedBox(height: 8),
                  const Text('Flutter と Dart が大好きなエンジニアです。毎日コードを書いています。'),
                  const SizedBox(height: 12),

                  // フォロー数
                  Row(
                    children: [
                      _buildStatItem('120', 'フォロー中'),
                      const SizedBox(width: 24),
                      _buildStatItem('4,500', 'フォロワー'),
                      const SizedBox(width: 24),
                      _buildStatItem('350', '投稿'),
                    ],
                  ),
                  const SizedBox(height: 16),

                  // タグ
                  Wrap(
                    spacing: 8,
                    runSpacing: 4,
                    children: ['Flutter', 'Dart', 'Firebase', 'UI/UX']
                        .map((tag) => Chip(label: Text(tag)))
                        .toList(),
                  ),
                  const SizedBox(height: 16),

                  // 投稿一覧(ListView は Column 内で使う場合に shrinkWrap が必要)
                  const Text(
                    '最近の投稿',
                    style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 8),
                  ListView.builder(
                    shrinkWrap: true,
                    physics: const NeverScrollableScrollPhysics(),
                    itemCount: 5,
                    itemBuilder: (context, index) {
                      return Card(
                        margin: const EdgeInsets.only(bottom: 8),
                        child: Padding(
                          padding: const EdgeInsets.all(12),
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Text(
                                '投稿タイトル ${index + 1}',
                                style: const TextStyle(
                                  fontWeight: FontWeight.bold,
                                  fontSize: 16,
                                ),
                              ),
                              const SizedBox(height: 4),
                              Text(
                                'これは ${index + 1} 番目の投稿の本文です。Flutter でアプリ開発中...',
                                style: const TextStyle(color: Colors.grey),
                              ),
                              const SizedBox(height: 8),
                              Row(
                                children: [
                                  const Icon(Icons.favorite_border, size: 16),
                                  const SizedBox(width: 4),
                                  Text('${(index + 1) * 12}'),
                                  const SizedBox(width: 16),
                                  const Icon(Icons.comment_outlined, size: 16),
                                  const SizedBox(width: 4),
                                  Text('${(index + 1) * 3}'),
                                ],
                              ),
                            ],
                          ),
                        ),
                      );
                    },
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  static Widget _buildStatItem(String count, String label) {
    return Row(
      children: [
        Text(count, style: const TextStyle(fontWeight: FontWeight.bold)),
        const SizedBox(width: 4),
        Text(label, style: const TextStyle(color: Colors.grey)),
      ],
    );
  }
}

このコードでは以下の Widget を活用しています。

Widget 用途
Stack + Positioned カバー画像の上にアバターを重ねる
Column 画面全体を縦方向に構成
Row フォロー数を横に並べる
Wrap タグを折り返して表示
ListView.builder 投稿一覧のリスト
SingleChildScrollView 画面全体のスクロール
Container / SizedBox サイズとスペースの調整

ポイント: Column の中に ListView を置く場合は、shrinkWrap: truephysics: NeverScrollableScrollPhysics() を設定します。そうしないとスクロール領域の高さが無限になりエラーになります。


練習問題

問題 1

以下の要件を満たすカードレイアウトを作成してください。

  • 横幅いっぱいのカード(角丸 12、影付き)
  • カード内に Row でアイコン(Icons.shopping_cart)とテキスト(「買い物リスト」)を横並びにする
  • アイコンとテキストの間は 12px のスペース
  • カード内の padding は上下左右 16px
模範解答
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(
      home: Scaffold(
        appBar: AppBar(title: const Text('練習問題 1')),
        body: Padding(
          padding: const EdgeInsets.all(16),
          child: Container(
            width: double.infinity,
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(12),
              boxShadow: [
                BoxShadow(
                  color: Colors.black.withOpacity(0.15),
                  blurRadius: 6,
                  offset: const Offset(0, 3),
                ),
              ],
            ),
            child: const Row(
              children: [
                Icon(Icons.shopping_cart, size: 28, color: Colors.blue),
                SizedBox(width: 12),
                Text(
                  '買い物リスト',
                  style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

問題 2

RowExpanded を使って、以下のような 3 カラムレイアウトを作成してください。

  • 左カラム: 青色、flex: 1
  • 中央カラム: 赤色、flex: 2
  • 右カラム: 緑色、flex: 1
  • 各カラムの高さは 200
  • カラム間のスペースは 8px(SizedBox を使う)
模範解答
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(
      home: Scaffold(
        appBar: AppBar(title: const Text('練習問題 2')),
        body: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            children: [
              Expanded(
                flex: 1,
                child: Container(height: 200, color: Colors.blue),
              ),
              const SizedBox(width: 8),
              Expanded(
                flex: 2,
                child: Container(height: 200, color: Colors.red),
              ),
              const SizedBox(width: 8),
              Expanded(
                flex: 1,
                child: Container(height: 200, color: Colors.green),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

SizedBoxExpanded ではないため、固定の 8px 幅を確保します。残りのスペースが 1:2:1 の比率で 3 つの Expanded に分配されます。


まとめ

Widget 用途
Column 縦方向に並べる
Row 横方向に並べる
Container サイズ・装飾・余白を設定
Expanded / Flexible 残りスペースを分配
Stack / Positioned 重ね合わせ
SizedBox 固定サイズのスペーサー
Wrap 折り返しレイアウト
ListView / ListView.builder スクロール可能なリスト
SingleChildScrollView 画面全体をスクロール可能にする

これらの Widget を組み合わせれば、ほとんどのレイアウトを実現できます。次回は StatefulWidget と状態管理の基本を学び、ユーザー操作に応じて画面が変化するアプリを作ります。


参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

0
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?