Flutter: Widgetの基礎
WidgetはFlutterの構成要素です — Widgetを理解すればFlutterのDNAを掴んだことになります。
📋 前提条件: 以下を先に完了している必要があります
- レッスン2:Dart言語速習
1. このレッスンで学ぶこと
- Widget-Element-RenderObjectの三ツリーアーキテクチャとパフォーマンスへの影響
- StatelessWidget vs StatefulWidgetのライフサイクル比較
- 基本的なウィジェット:Text、Image、Icon、Container、Scaffold
- Keyの目的と用途:ValueKey / ObjectKey / GlobalKey
- ShopApp商品カードのStatelessWidget実装
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の三つのツリー
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パッケージでキャッシュとローディングインジケーターを組み合わせてください。📖 まとめ
- Widget(設定) → Element(管理) → RenderObject(描画)の三層アーキテクチャ
- Widgetは不変で軽量、頻繁に再ビルド可能。Elementが差分を行いRenderObjectの更新を判断
- KeyがElementの再利用戦略を制御:ValueKeyは値でマッチング、UniqueKeyは再利用を強制防止
- Containerは万能コンテナ、Scaffoldはページスキャフォールド
- リストではValueKey(product.id)を使い、正しいElementマッチングを確保
📝 練習問題
- 基本(難易度 ⭐): nameとimageUrlを受け取り、円形のアバターとユーザー名を表示する
UserAvatarStatelessWidgetを作成してください。 - 中級(難易度 ⭐⭐): 元値/割引価格表示に対応する
PriceTagウィジェットを実装し、割引価格は大きな緑色のテキスト、元値は取り消し線で表示してください。 - チャレンジ(難易度 ⭐⭐⭐): ドラッグで並べ替え可能な商品リストを構築し、ValueKeyでStateが正しく追従することを確認し、並べ替え後にSnackBarで新しい順序を表示してください。