Flutter: StatefulWidgetとインタラクション

StateはWidgetの記憶です — Stateがなければ、インターフェースは毎回あなたを認識できない健忘者のようなものです。

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

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


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のライフサイクル

100%
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回だけ再ビルドします。

📖 まとめ


📝 練習問題

  1. 基本(難易度 ⭐): 「加算/減算/リセット」ボタンと中央の数字表示を持つカウンターページを作成してください。
  2. 中級(難易度 ⭐⭐): タイプ入力に応じてリストをリアルタイムでフィルタリングする検索バーを実装してください(setState + TextEditingControllerを使用)。
  3. チャレンジ(難易度 ⭐⭐⭐): 完全なショッピングカートを実装してください:数量調整、アイテム削除、自動合計計算に対応し、空の状態では「カートは空です」と表示。

← 前へ | 次へ →

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%