Flutter: Widgetの基礎

WidgetはFlutterの構成要素です — Widgetを理解すればFlutterのDNAを掴んだことになります。

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

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


2. フロントエンド開発者がFlutterに転向したリアルなストーリー

(1) 悩み:DOM的思考の罠

BobはReactからFlutterに転向し、直感的に「コンポーネントがDOMをレンダリングする」という思考モデルでWidgetを理解しようとしました。商品カードがリストスクロール中に頻繁に再ビルドされ、1000アイテムの商品ページが30fpsでカクつきました。さらに混乱したことに、リストにkeyを追加したらパフォーマンスが悪化 — UniqueKeyを使っていたからです。

(2) 三ツリーアーキテクチャのソリューション

Flutterは単純な「コンポーネント → DOM」のマッピングではなく、「Widget → Element → RenderObject」の3層アーキテクチャです。Widgetは設定の記述(軽量)、Elementはインスタンスを管理(差分の中核)、RenderObjectはレイアウトと描画を担当(重い)です。三つのツリーを理解したBobは、Widgetレイヤーで再ビルドするのではなく、Elementレイヤーでの差分処理で最適化すべきことに気付きました。

DART
import 'package:flutter/material.dart';

// Lightweight widget: just configuration
const ProductCard({required this.product, super.key});

// Flutter internally creates Element and RenderObject
// Widget rebuilds are cheap - Element diffing avoids RenderObject rebuild
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

(3) 結果:スムーズな60fpsスクロール

ValueKey(product.id)に切り替えた後、Elementの差分が正確にアイテムをマッチングし、1000商品リストは安定した60fpsを維持しました。


3. Widget-Element-RenderObjectの三つのツリー

100%
graph LR
    W[Widget Tree] --> E[Element Tree]
    E --> R[RenderObject Tree]
    W -.->|canUpdate| E
    E -.->|adoptChild| R
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

(1) 三ツリーの責務

レイヤー 生成元 責務 重さ
Widget 開発者のbuild() 不変の設定記述 非常に軽い
Element Frameworkが自動生成 ツリー構造の管理、差分更新 中程度
RenderObject Frameworkが自動生成 測定、レイアウト、描画 重い

(2) canUpdateルール

ElementはWidget.canUpdate(oldWidget, newWidget)で再利用するかどうかを判断します:

DART
import 'package:flutter/material.dart';

// canUpdate logic
static bool canUpdate(Widget oldWidget, Widget newWidget) {
  return oldWidget.runtimeType == newWidget.runtimeType
      && oldWidget.key == newWidget.key;
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
シナリオ runtimeType key 結果
同じ型、同じキー 同じ 同じ Elementを再利用
同じ型、異なるキー 同じ 異なる Elementを再ビルド
異なる型 異なる - Elementを再ビルド

4. 基本的なウィジェット

(1) Textとスタイル

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: 様々なスタイルのText

DART
import 'package:flutter/material.dart';

// Basic text
const Text('ShopApp')

// Styled text
Text(
  'Flash Sale!',
  style: TextStyle(
    fontSize: 24,
    fontWeight: FontWeight.bold,
    color: Colors.red,
    letterSpacing: 1.5,
  ),
)

// Rich text with multiple styles
Text.rich(
  TextSpan(
    text: 'Price: ',
    style: const TextStyle(fontSize: 16, color: Colors.grey),
    children: [
      TextSpan(
        text: '\$99.99',
        style: const TextStyle(
          fontSize: 20,
          fontWeight: FontWeight.bold,
          color: Colors.green,
        ),
      ),
      TextSpan(
        text: ' \$149.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) Container:万能コンテナ

Containerのプロパティ 目的
padding 内側の余白 EdgeInsets
margin 外側の余白 EdgeInsets
decoration 背景の装飾 BoxDecoration
constraints 制約 BoxConstraints
alignment 子ウィジェットの配置 Alignment

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: Container商品カード

DART
import 'package:flutter/material.dart';

Container(
  margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  padding: const EdgeInsets.all(12),
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(12),
    boxShadow: [
      BoxShadow(
        color: Colors.black.withOpacity(0.1),
        blurRadius: 8,
        offset: const Offset(0, 2),
      ),
    ],
  ),
  child: Row(
    children: [
      ClipRRect(
        borderRadius: BorderRadius.circular(8),
        child: Image.network(
          'https://cdn.shopapp.com/products/laptop.jpg',
          width: 80,
          height: 80,
          fit: BoxFit.cover,
        ),
      ),
      const SizedBox(width: 12),
      Expanded(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('Pro Laptop', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
            const SizedBox(height: 4),
            Text('\$1,299.99', style: const TextStyle(fontSize: 18, color: Colors.green)),
          ],
        ),
      ),
    ],
  ),
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

(3) IconとIconButton

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: ECコンテキストでのアイコン

DART
import 'package:flutter/material.dart';

// Icon with color and size
const Icon(Icons.shopping_cart, color: Colors.blue, size: 32)

// IconButton for actions
IconButton(
  icon: const Icon(Icons.favorite_border),
  onPressed: () {
    // Add to wishlist
  },
  tooltip: 'Add to Wishlist',
)

// Badge icon for cart count
Stack(
  children: [
    const Icon(Icons.shopping_cart, size: 28),
    Positioned(
      right: 0,
      top: 0,
      child: Container(
        padding: const EdgeInsets.all(2),
        decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle),
        constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
        child: const Text('3', style: TextStyle(fontSize: 10, color: Colors.white),
          textAlign: TextAlign.center),
      ),
    ),
  ],
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

5. Keyの仕組み

KeyはElementの再利用戦略を制御し、リスト更新時に特に重要です。

(1) Keyの種類

Keyの種類 比較基準 用途
ValueKey(value) 値の等価性 一意識別子を持つリストアイテム(例:product.id)
ObjectKey(object) オブジェクト参照 一意のオブジェクトインスタンス
UniqueKey() 毎回異なる 再利用を強制的に防止(ほとんど使われない)
GlobalKey グローバルに一意 ウィジェット間のStateアクセス

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: リストにおけるKeyの役割

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});
}

// BAD: No key, Flutter matches by position
ListView(
  children: [
    ProductTile(product: products[0]), // index 0
    ProductTile(product: products[1]), // index 1
  ],
)

// When products reorder, Element mismatches State
// Solution: Use ValueKey with unique id

// GOOD: ValueKey enables correct Element-Widget matching
ListView(
  children: [
    ProductTile(key: ValueKey(products[0].id), product: products[0]),
    ProductTile(key: ValueKey(products[1].id), product: products[1]),
  ],
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

6. 完成例:ShopApp商品カードWidget

DART
import 'package:flutter/material.dart';

class Product {
  final int id;
  final String name;
  final double price;
  final String imageUrl;
  final double rating;
  final int reviewCount;

  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.imageUrl,
    this.rating = 0.0,
    this.reviewCount = 0,
  });
}

class ProductCard extends StatelessWidget {
  final Product product;
  final VoidCallback? onAddToCart;
  final VoidCallback? onTap;

  const ProductCard({
    super.key,
    required this.product,
    this.onAddToCart,
    this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Card(
        clipBehavior: Clip.antiAlias,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
        elevation: 2,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Expanded(
              flex: 3,
              child: Stack(
                children: [
                  Image.network(product.imageUrl, fit: BoxFit.cover,
                    width: double.infinity),
                  if (product.price < 50)
                    Positioned(top: 8, left: 8,
                      child: _buildBadge('SALE', Colors.red)),
                ],
              ),
            ),
            Expanded(
              flex: 2,
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(product.name, maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                      style: const TextStyle(fontWeight: FontWeight.w600)),
                    const SizedBox(height: 4),
                    Text('\$${product.price.toStringAsFixed(2)}',
                      style: const TextStyle(color: Colors.green, fontSize: 16)),
                    const Spacer(),
                    Align(
                      alignment: Alignment.centerRight,
                      child: IconButton(
                        icon: const Icon(Icons.add_shopping_cart, size: 20),
                        onPressed: onAddToCart,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildBadge(String text, Color color) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
      decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(4)),
      child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 10)),
    );
  }
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

❓ よくある質問

Q StatelessWidgetのbuildメソッドはいつ呼ばれますか?
A 親ウィジェットが再ビルドされる時、またはsetStateが親の再ビルドをトリガーする時です。StatelessWidget自体は再ビルドをトリガーできません。
Q なぜWidgetは不変に設計されているのですか?
A 不変性により、副作用を気にせず頻繁に生成できます。Flutterフレームワークは新旧のWidgetを安全に比較し、Elementを更新するかどうかを判断できます。
Q いつKeyを使う必要がありますか?
A リスト内のWidgetが同じ型で異なるデータを持ち、アイテムが追加/削除/並べ替えされる可能性がある場合。静的なリストは通常Keyを必要としません。
Q GlobalKeyはウィジェット間のStateアクセスを許可しますが、カプセル化を壊しませんか?
A はい、GlobalKeyは「脱出ハッチ」であり、控えめに使用すべきです。一般的な用途:Formのバリデーション、ScrollControllerの管理。コールバックでのデータ渡しを優先してください。
Q ContainerとScaffoldの違いは?
A Containerは汎用の装飾コンテナです。ScaffoldはMaterial Designのページスキャフォールドで、AppBar/Body/FAB/Drawer/BottomNavのスロットを提供します。
Q Image.networkの読み込みに失敗した場合は?
A errorBuilderパラメータでプレースホルダー画像を表示し、cached_network_imageパッケージでキャッシュとローディングインジケーターを組み合わせてください。

📖 まとめ


📝 練習問題

  1. 基本(難易度 ⭐): nameとimageUrlを受け取り、円形のアバターとユーザー名を表示するUserAvatar StatelessWidgetを作成してください。
  2. 中級(難易度 ⭐⭐): 元値/割引価格表示に対応するPriceTagウィジェットを実装し、割引価格は大きな緑色のテキスト、元値は取り消し線で表示してください。
  3. チャレンジ(難易度 ⭐⭐⭐): ドラッグで並べ替え可能な商品リストを構築し、ValueKeyでStateが正しく追従することを確認し、並べ替え後にSnackBarで新しい順序を表示してください。

← 前へ | 次へ →

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%