Flutter: StatefulWidgetとインタラクション
StateはWidgetの記憶です — Stateがなければ、インターフェースは毎回あなたを認識できない健忘者のようなものです。
📋 前提条件: 以下を先に完了している必要があります
- レッスン3:Widgetの基礎
1. このレッスンで学ぶこと
- 完全なStatefulWidgetライフサイクル:createState → initState → build → dispose
setState再ビルドの仕組みと最小再ビルドスコープ- イベント処理:GestureDetector、InkWell、onPressedコールバック
- 入力インタラクション:TextField、TextEditingControllerによるテキストリスニング
- ShopAppショッピングカート数量セレクターの実装
2. インタラクションのジレンマに関するリアルなストーリー
(1) 悩み:クリックが反応しない
BobはShopAppのカートページで奇妙なバグに遭遇しました:ユーザーが「-」ボタンをクリックすると、数量が1から0になるはずが、UIにはまだ1と表示されています。彼は_count = _count - 1と変数を直接変更しましたが、setStateを呼んでいません — Widgetは状態が変更されたことを知らないため、当然UIを再ビルドしません。
(2) setStateのソリューション
StatefulWidgetの中核原則:状態の変更 ≠ UIの変更。setStateを通じてフレームワークに「変更があった、再ビルドして」と通知しなければなりません。
DART
import 'package:flutter/material.dart';
// WRONG: Direct mutation, UI not updated
void _decrement() {
_count = _count - 1; // State changed, but UI doesn't know
}
// RIGHT: setState triggers rebuild
void _decrement() {
setState(() {
_count = _count - 1; // Framework rebuilds this widget
});
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(3) 結果:UIとStateが常に同期
setStateを追加した後、カートの数量はリアルタイムで更新され、合計金額も自動的に再計算されるようになりました。それ以来、Bobは忘れません:データを変更したら、必ずsetStateを呼ぶ。
3. StatefulWidgetのライフサイクル
stateDiagram-v2
[*] --> createState
createState --> initState
initState --> didChangeDependencies
didChangeDependencies --> build
build --> didUpdateWidget: setState / parent rebuild
didUpdateWidget --> build
build --> deactivate: removed from tree
deactivate --> dispose
dispose --> [*]
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(1) ライフサイクルコールバック
| コールバック | 発動タイミング | 目的 |
|---|---|---|
createState |
FrameworkがStateを生成 | Stateオブジェクトの初期化 |
initState |
Stateがツリーに挿入 | 一度きりの初期化(サブスクリプション、コントローラー) |
didChangeDependencies |
依存するInheritedWidgetが変更 | テーマやロケールの読み取りなど |
build |
UIのレンダリングが必要な毎回 | Widgetツリーの構築 |
didUpdateWidget |
親Widgetが再ビルド | 新旧Widgetを比較して対応 |
setState |
開発者が明示的に呼び出し | ダーティマーク、再ビルドをトリガー |
deactivate |
Stateがツリーから削除 | 一時的な削除(再挿入可能) |
dispose |
Stateが永久に破棄 | リソースの解放、サブスクリプションのキャンセル |
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: ライフサイクルの追跡
DART
import 'package:flutter/material.dart';
class LifecycleTracker extends StatefulWidget {
const LifecycleTracker({super.key});
@override
State<LifecycleTracker> createState() => _LifecycleTrackerState();
}
class _LifecycleTrackerState extends State<LifecycleTracker> {
int _buildCount = 0;
@override
void initState() {
super.initState();
debugPrint('initState called');
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
debugPrint('didChangeDependencies called');
}
@override
void didUpdateWidget(covariant LifecycleTracker oldWidget) {
super.didUpdateWidget(oldWidget);
debugPrint('didUpdateWidget called');
}
@override
Widget build(BuildContext context) {
_buildCount++;
debugPrint('build #$_buildCount');
return Text('Build count: $_buildCount');
}
@override
void dispose() {
debugPrint('dispose called');
super.dispose();
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
4. setStateの仕組みを深く理解する
(1) setStateのワークフロー
| ステップ | アクション |
|---|---|
| 1 | setState(fn)を呼び出す |
| 2 | fnを実行、状態変数を更新 |
| 3 | Elementをダーティとマーク |
| 4 | 次のフレームで、Frameworkがbuildを呼び出す |
| 5 | 新旧Widgetツリーを差分比較、RenderObjectを更新 |
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: カート数量セレクター
DART
import 'package:flutter/material.dart';
class QuantitySelector extends StatefulWidget {
final int initialValue;
final ValueChanged<int> onChanged;
const QuantitySelector({
super.key,
this.initialValue = 1,
required this.onChanged,
});
@override
State<QuantitySelector> createState() => _QuantitySelectorState();
}
class _QuantitySelectorState extends State<QuantitySelector> {
late int _count;
@override
void initState() {
super.initState();
_count = widget.initialValue;
}
@override
void didUpdateWidget(covariant QuantitySelector oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialValue != widget.initialValue) {
_count = widget.initialValue;
}
}
void _increment() {
setState(() {
_count = (_count + 1).clamp(1, 99);
});
widget.onChanged(_count);
}
void _decrement() {
setState(() {
_count = (_count - 1).clamp(1, 99);
});
widget.onChanged(_count);
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.remove), onPressed: _decrement),
Text('$_count', style: const TextStyle(fontSize: 18)),
IconButton(icon: const Icon(Icons.add), onPressed: _increment),
],
),
);
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
5. イベント処理
(1) GestureDetector vs InkWell
| ウィジェット | クリック効果 | リップル | 用途 |
|---|---|---|---|
GestureDetector |
視覚的フィードバックなし | なし | カスタムジェスチャー(長押し/ドラッグ/ダブルタップ) |
InkWell |
Materialリップル | あり | カード/リストアイテムのクリック |
IconButton |
アイコン + リップル | あり | ツールバーアクション |
ElevatedButton |
ボタンスタイル | あり | 主要アクション |
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: GestureDetectorジェスチャー認識
DART
import 'package:flutter/material.dart';
// Simplified class definition
class Product {
final String name;
final double price;
final String imageUrl;
final int id;
const Product({required this.name, required this.price, required this.imageUrl, required this.id});
}
GestureDetector(
onTap: () => debugPrint('Tapped'),
onDoubleTap: () => debugPrint('Double tapped'),
onLongPress: () => debugPrint('Long pressed'),
onHorizontalDragEnd: (details) {
// Swipe to add/remove from cart
if (details.primaryVelocity! < 0) {
addToCart(product); // Swipe left
} else {
removeFromCart(product); // Swipe right
}
},
child: ProductCard(product: product),
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: InkWell商品カードクリック
DART
import 'package:flutter/material.dart';
// Product class definition shown above
InkWell(
onTap: () => Navigator.pushNamed(context, '/product/${product.id}'),
borderRadius: BorderRadius.circular(12),
child: Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Image.network(product.imageUrl, width: 60, height: 60),
const SizedBox(width: 12),
Expanded(child: Text(product.name)),
Text('$${product.price.toStringAsFixed(2)}'),
],
),
),
),
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
6. 入力インタラクション:TextField
(1) TextEditingController
| 機能 | メソッド |
|---|---|
| テキストの取得 | controller.text |
| テキストの設定 | controller.text = 'new' |
| 変更のリスニング | controller.addListener(fn) |
| テキストの選択 | controller.selection |
| 入力のクリア | controller.clear() |
▶ サンプル
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
: 検索バーの実装
DART
import 'package:flutter/material.dart';
class SearchBar extends StatefulWidget {
final ValueChanged<String> onSearch;
const SearchBar({super.key, required this.onSearch});
@override
State<SearchBar> createState() => _SearchBarState();
}
class _SearchBarState extends State<SearchBar> {
final _controller = TextEditingController();
@override
void initState() {
super.initState();
_controller.addListener(() {
widget.onSearch(_controller.text);
});
}
@override
void dispose() {
_controller.dispose(); // Important: prevent memory leak
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Search products...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
widget.onSearch('');
},
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)),
),
);
}
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
7. 完成例:ShopAppカートページ
DART
import 'package:flutter/material.dart';
class CartPage extends StatefulWidget {
const CartPage({super.key});
@override
State<CartPage> createState() => _CartPageState();
}
class _CartPageState extends State<CartPage> {
final List<CartItem> _items = [
CartItem(name: 'Pro Laptop', price: 1299.99, quantity: 1),
CartItem(name: 'Wireless Mouse', price: 29.99, quantity: 2),
CartItem(name: 'USB-C Hub', price: 49.99, quantity: 1),
];
double get _total => _items.fold(0.0, (sum, item) => sum + item.price * item.quantity);
int get _itemCount => _items.fold(0, (sum, item) => sum + item.quantity);
void _updateQuantity(int index, int newQty) {
setState(() {
if (newQty <= 0) {
_items.removeAt(index);
} else {
_items[index] = _items[index].copyWith(quantity: newQty);
}
});
}
void _removeItem(int index) {
setState(() => _items.removeAt(index));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Cart ($_itemCount items)')),
body: _items.isEmpty
? const Center(child: Text('Your cart is empty'))
: Column(
children: [
Expanded(
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, index) {
final item = _items[index];
return ListTile(
leading: const Icon(Icons.shopping_bag),
title: Text(item.name),
subtitle: Text('$${item.price.toStringAsFixed(2)} each'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.remove_circle_outline),
onPressed: () => _updateQuantity(index, item.quantity - 1)),
Text('${item.quantity}', style: const TextStyle(fontSize: 16)),
IconButton(icon: const Icon(Icons.add_circle_outline),
onPressed: () => _updateQuantity(index, item.quantity + 1)),
IconButton(icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _removeItem(index)),
],
),
);
},
),
),
// Total bar
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.grey[100],
border: Border(top: BorderSide(color: Colors.grey[300]!))),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
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)),
]),
ElevatedButton(
onPressed: _items.isEmpty ? null : () {},
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16)),
child: const Text('Checkout'),
),
],
),
),
],
),
);
}
}
class CartItem {
final String name;
final double price;
final int quantity;
const CartItem({required this.name, required this.price, required this.quantity});
CartItem copyWith({String? name, double? price, int? quantity}) =>
CartItem(name: name ?? this.name, price: price ?? this.price, quantity: quantity ?? this.quantity);
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
❓ よくある質問
Q initStateの中でsetStateを呼べますか?
A いいえ。initStateが実行される時、Widgetはまだビルドが完了していません。初期化後に更新が必要な場合は、
WidgetsBinding.instance.addPostFrameCallbackを使用してください。Q なぜ1つの変数代入でもsetState内でクロージャを使うのですか?
A クロージャがどの状態が変更されたかを明確にマークし、フレームワークの最適化に役立ちます。また、デバッグ時に状態変更を追跡しやすくなります。
Q GestureDetectorのonTapが動かない場合は?
A 子にジェスチャーの競合相手(例:ListViewスクロール)がないか確認してください。
behavior: HitTestBehavior.opaqueを使用してエリア全体がクリック可能になるようにしてください。Q TextEditingControllerはいつ破棄すべきですか?
A Stateの
disposeメソッドでcontroller.dispose()を呼び出してください。そうしないとメモリリークが発生します。Q setState後にUIが変わらないのはなぜですか?
A setStateクロージャの外で変数を変更していないか確認してください。クロージャ内の変更のみが再ビルドをトリガーします。
Q 複数のsetState呼び出しは複数回の再ビルドを引き起こしますか?
A いいえ。Flutterは1フレーム内の複数のsetState呼び出しをまとめ、1回だけ再ビルドします。
📖 まとめ
- StatefulWidgetには完全なライフサイクルがある:initState → build → dispose
- setStateがUI更新をトリガーする唯一の方法:setStateを呼ばずに状態を変更してもUIは更新されない
- GestureDetectorは豊富なジェスチャーをサポート、InkWellはMaterialリップルフィードバックを提供
- TextEditingControllerは入力フィールドの状態を管理し、disposeメソッドで必ず破棄する必要がある
- カート数量セレクターはsetState + コールバック渡しの典型的な応用
📝 練習問題
- 基本(難易度 ⭐): 「加算/減算/リセット」ボタンと中央の数字表示を持つカウンターページを作成してください。
- 中級(難易度 ⭐⭐): タイプ入力に応じてリストをリアルタイムでフィルタリングする検索バーを実装してください(setState + TextEditingControllerを使用)。
- チャレンジ(難易度 ⭐⭐⭐): 完全なショッピングカートを実装してください:数量調整、アイテム削除、自動合計計算に対応し、空の状態では「カートは空です」と表示。