0
3

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入門 第10回】StatefulWidget と状態管理の基本 ― 動きのあるアプリを作る

0
Posted at

はじめに

前回まで使ってきた StatelessWidget は、一度描画したら自分自身では変化しない Widget です。しかし実際のアプリでは、ボタンを押したらカウントが増える、テキストを入力したら画面に反映される、といった動的な UI が必要です。

Flutter でこれを実現するのが StatefulWidget です。この記事では、StatefulWidget の仕組みから実践的なアプリ作成まで段階的に解説します。


1. StatelessWidget と StatefulWidget の違い

特徴 StatelessWidget StatefulWidget
状態 持たない 持つ
UI の更新 親から渡されるデータが変わった時のみ 自身の状態が変わった時にも更新可能
用途 静的な表示(テキスト、アイコンなど) 動的な表示(カウンター、フォームなど)
構成 Widget クラス 1 つ Widget クラス + State クラスのペア

2. StatefulWidget の構造

StatefulWidget は Widget クラスState クラスの 2 つで構成されます。

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: const MyHomePage(),
    );
  }
}

// ① Widget クラス
class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

// ② State クラス
class _MyHomePageState extends State<MyHomePage> {
  // 状態変数をここに定義
  String _message = 'こんにちは!';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('StatefulWidget の構造')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(_message, style: const TextStyle(fontSize: 24)),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                setState(() {
                  _message = 'ボタンが押されました!';
                });
              },
              child: const Text('メッセージを変更'),
            ),
          ],
        ),
      ),
    );
  }
}

各部分の役割

  • Widget クラス(MyHomePage: createState() メソッドで対応する State オブジェクトを生成します。Widget クラス自体は immutable(不変)です。
  • State クラス(_MyHomePageState: 状態変数(_message)と build() メソッドを持ちます。状態が変わるとここの build() が再実行されて UI が更新されます。
  • クラス名の _ プレフィックス: State クラスはプライベート(同一ファイル内のみアクセス可能)にするのが慣例です。

3. setState() の仕組み

setState() は Flutter の UI 更新の中核です。

setState() を呼ぶ → Flutter フレームワークが State を「dirty」とマーク → 次のフレームで build() が再実行 → UI が更新

重要なルール

  1. setState() の中で状態変数を変更する
// 正しい使い方
setState(() {
  _count = _count + 1;
});
  1. setState() の外で変更してから呼んでも動作する(非推奨)
// 動作はするが、意図が不明確なので非推奨
_count = _count + 1;
setState(() {});
  1. setState() を呼ばずに変数だけ変更しても UI は更新されない
// UI が更新されない!
_count = _count + 1;  // 変数は変わるが画面は古いまま
  1. build() メソッド内で setState() を呼んではいけない

build() 内で setState() を呼ぶと無限ループになります。


4. カウンターアプリの実装

Flutter プロジェクト作成時のデフォルトアプリを一から書いてみましょう。

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(
      title: 'カウンターアプリ',
      theme: ThemeData(
        colorSchemeSeed: Colors.blue,
        useMaterial3: true,
      ),
      home: const CounterPage(),
    );
  }
}

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  void _decrementCounter() {
    setState(() {
      _counter--;
    });
  }

  void _resetCounter() {
    setState(() {
      _counter = 0;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('カウンターアプリ'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: _resetCounter,
            tooltip: 'リセット',
          ),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('ボタンを押した回数:', style: TextStyle(fontSize: 18)),
            const SizedBox(height: 8),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.displayLarge,
            ),
            const SizedBox(height: 24),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                FloatingActionButton(
                  heroTag: 'decrement',
                  onPressed: _decrementCounter,
                  tooltip: '-1',
                  child: const Icon(Icons.remove),
                ),
                const SizedBox(width: 16),
                FloatingActionButton(
                  heroTag: 'increment',
                  onPressed: _incrementCounter,
                  tooltip: '+1',
                  child: const Icon(Icons.add),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

_counter の初期値は 0 です。+ ボタンを 3 回押すと _counter3 になり、画面には「3」と表示されます。- ボタンを 1 回押すと 2 になります。リセットボタンを押すと 0 に戻ります。

注意: 同じ画面に複数の FloatingActionButton を配置する場合は、それぞれに異なる heroTag を設定する必要があります。設定しないと Hero アニメーションの競合でエラーになります。


5. ライフサイクルメソッド

State クラスには、オブジェクトの生成から破棄までの各段階で呼ばれるメソッドがあります。

主要なライフサイクルメソッド

メソッド 呼ばれるタイミング 用途
initState() State が作成された直後に 1 回だけ 初期化処理(コントローラー作成、リスナー登録)
build() 初回および setState() 後 UI の構築
didUpdateWidget() 親 Widget が再ビルドされて設定が変わった時 旧 Widget との比較・更新処理
didChangeDependencies() 依存する InheritedWidget が変わった時 テーマやロケールの変更への対応
dispose() State が破棄される直前に 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: const LifecyclePage(),
    );
  }
}

class LifecyclePage extends StatefulWidget {
  const LifecyclePage({super.key});

  @override
  State<LifecyclePage> createState() => _LifecyclePageState();
}

class _LifecyclePageState extends State<LifecyclePage> {
  int _counter = 0;

  @override
  void initState() {
    super.initState();
    // 最初に 1 回だけ呼ばれる
    debugPrint('initState() が呼ばれました');
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    debugPrint('didChangeDependencies() が呼ばれました');
  }

  @override
  void dispose() {
    // State が破棄される直前に呼ばれる
    debugPrint('dispose() が呼ばれました');
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    debugPrint('build() が呼ばれました (_counter=$_counter)');
    return Scaffold(
      appBar: AppBar(title: const Text('ライフサイクル')),
      body: Center(
        child: Text('$_counter', style: const TextStyle(fontSize: 48)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          setState(() {
            _counter++;
          });
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

このアプリを起動すると、デバッグコンソールに以下の順序で出力されます。

initState() が呼ばれました
didChangeDependencies() が呼ばれました
build() が呼ばれました (_counter=0)

ボタンを 1 回押すと以下が追加されます。

build() が呼ばれました (_counter=1)

initState()didChangeDependencies() は再度呼ばれません。setState() によって build() だけが再実行されます。

initState() と dispose() の典型的な使い方

@override
void initState() {
  super.initState();           // 必ず先頭で super を呼ぶ
  _controller = TextEditingController();
  _controller.addListener(_onTextChanged);
}

@override
void dispose() {
  _controller.removeListener(_onTextChanged);
  _controller.dispose();       // コントローラーのリソースを解放
  super.dispose();             // 必ず末尾で super を呼ぶ
}

6. テキスト入力の管理

TextEditingController を使う方法

TextEditingController を使うと、テキストフィールドの値をプログラムから読み書きできます。

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: const TextInputPage(),
    );
  }
}

class TextInputPage extends StatefulWidget {
  const TextInputPage({super.key});

  @override
  State<TextInputPage> createState() => _TextInputPageState();
}

class _TextInputPageState extends State<TextInputPage> {
  final TextEditingController _nameController = TextEditingController();
  String _greeting = '';

  @override
  void dispose() {
    _nameController.dispose();
    super.dispose();
  }

  void _updateGreeting() {
    setState(() {
      final name = _nameController.text;
      if (name.isEmpty) {
        _greeting = '名前を入力してください';
      } else {
        _greeting = 'こんにちは、$nameさん!';
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('テキスト入力')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            TextField(
              controller: _nameController,
              decoration: const InputDecoration(
                labelText: '名前',
                hintText: '名前を入力',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: _updateGreeting,
              child: const Text('挨拶する'),
            ),
            const SizedBox(height: 16),
            Text(_greeting, style: const TextStyle(fontSize: 20)),
          ],
        ),
      ),
    );
  }
}

onChanged コールバックを使う方法

リアルタイムで入力値に反応したい場合は onChanged を使います。

TextField(
  onChanged: (value) {
    setState(() {
      _greeting = 'こんにちは、$valueさん!';
    });
  },
  decoration: const InputDecoration(
    labelText: '名前',
    border: OutlineInputBorder(),
  ),
)

onChanged は文字が入力されるたびに呼ばれます。TextEditingController は値の読み取り・設定やリスナー登録ができるのでより柔軟ですが、単純な用途では onChanged で十分です。


7. チェックボックス / スイッチ / スライダー の状態管理

Flutter の入力 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: const InputWidgetsPage(),
    );
  }
}

class InputWidgetsPage extends StatefulWidget {
  const InputWidgetsPage({super.key});

  @override
  State<InputWidgetsPage> createState() => _InputWidgetsPageState();
}

class _InputWidgetsPageState extends State<InputWidgetsPage> {
  bool _agreeToTerms = false;
  bool _notificationsEnabled = true;
  double _fontSize = 16.0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('入力 Widget')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // チェックボックス
            CheckboxListTile(
              title: const Text('利用規約に同意する'),
              value: _agreeToTerms,
              onChanged: (bool? value) {
                setState(() {
                  _agreeToTerms = value ?? false;
                });
              },
            ),
            Text(
              _agreeToTerms ? '同意済み' : '未同意',
              style: TextStyle(
                color: _agreeToTerms ? Colors.green : Colors.red,
              ),
            ),
            const Divider(height: 32),

            // スイッチ
            SwitchListTile(
              title: const Text('通知を有効にする'),
              value: _notificationsEnabled,
              onChanged: (bool value) {
                setState(() {
                  _notificationsEnabled = value;
                });
              },
            ),
            Text('通知: ${_notificationsEnabled ? "ON" : "OFF"}'),
            const Divider(height: 32),

            // スライダー
            Text('フォントサイズ: ${_fontSize.round()}'),
            Slider(
              value: _fontSize,
              min: 8,
              max: 40,
              divisions: 32,
              label: _fontSize.round().toString(),
              onChanged: (double value) {
                setState(() {
                  _fontSize = value;
                });
              },
            ),
            Text(
              'プレビューテキスト',
              style: TextStyle(fontSize: _fontSize),
            ),
          ],
        ),
      ),
    );
  }
}

スライダーの初期値は 16.0、範囲は 840divisions32(40 - 8) = 32 段階)です。スライダーを中央に動かすと _fontSize24.0 になり、_fontSize.round()24 と表示されます。

すべてのパターンに共通する考え方は以下の通りです。

  1. 状態変数を State クラスに定義する
  2. Widget の value(または同等のプロパティ)に状態変数を渡す
  3. onChanged コールバックの中で setState() を使って状態変数を更新する

8. 実践例1 ― Todo リストアプリ

追加・削除機能付きの Todo リストアプリを作ります。

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(
      title: 'Todo リスト',
      theme: ThemeData(
        colorSchemeSeed: Colors.teal,
        useMaterial3: true,
      ),
      home: const TodoPage(),
    );
  }
}

class TodoItem {
  String title;
  bool isDone;

  TodoItem({required this.title, this.isDone = false});
}

class TodoPage extends StatefulWidget {
  const TodoPage({super.key});

  @override
  State<TodoPage> createState() => _TodoPageState();
}

class _TodoPageState extends State<TodoPage> {
  final List<TodoItem> _todos = [];
  final TextEditingController _textController = TextEditingController();

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  void _addTodo() {
    final text = _textController.text.trim();
    if (text.isEmpty) return;

    setState(() {
      _todos.add(TodoItem(title: text));
    });
    _textController.clear();
  }

  void _toggleTodo(int index) {
    setState(() {
      _todos[index].isDone = !_todos[index].isDone;
    });
  }

  void _deleteTodo(int index) {
    setState(() {
      _todos.removeAt(index);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Todo リスト'),
        actions: [
          Center(
            child: Padding(
              padding: const EdgeInsets.only(right: 16),
              child: Text(
                '${_todos.where((t) => t.isDone).length}/${_todos.length} 完了',
                style: const TextStyle(fontSize: 14),
              ),
            ),
          ),
        ],
      ),
      body: Column(
        children: [
          // 入力エリア
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _textController,
                    decoration: const InputDecoration(
                      hintText: 'タスクを入力...',
                      border: OutlineInputBorder(),
                    ),
                    onSubmitted: (_) => _addTodo(),
                  ),
                ),
                const SizedBox(width: 8),
                ElevatedButton(
                  onPressed: _addTodo,
                  child: const Text('追加'),
                ),
              ],
            ),
          ),
          // リスト表示
          Expanded(
            child: _todos.isEmpty
                ? const Center(child: Text('タスクがありません'))
                : ListView.builder(
                    itemCount: _todos.length,
                    itemBuilder: (context, index) {
                      final todo = _todos[index];
                      return ListTile(
                        leading: Checkbox(
                          value: todo.isDone,
                          onChanged: (_) => _toggleTodo(index),
                        ),
                        title: Text(
                          todo.title,
                          style: TextStyle(
                            decoration: todo.isDone
                                ? TextDecoration.lineThrough
                                : TextDecoration.none,
                            color: todo.isDone ? Colors.grey : Colors.black,
                          ),
                        ),
                        trailing: IconButton(
                          icon: const Icon(Icons.delete, color: Colors.red),
                          onPressed: () => _deleteTodo(index),
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

動作の流れ

  1. テキストフィールドにタスク名を入力して「追加」ボタンを押す
  2. _addTodo() が呼ばれ、setState() 内で _todos にアイテムが追加される
  3. build() が再実行され、ListView.builder が新しいリストを描画する
  4. チェックボックスをタップすると _toggleTodo()isDone が反転し、取り消し線が付く
  5. ゴミ箱アイコンをタップすると _deleteTodo() でアイテムが削除される

AppBar には完了数と総数が表示されます。例えば、3 つのタスクを追加して 2 つにチェックを入れると「2/3 完了」と表示されます。


9. 実践例2 ― BMI 計算アプリ

身長と体重を入力して BMI を計算するアプリです。

BMI の計算式: BMI = 体重(kg) / (身長(m))^2

例: 身長 170cm、体重 65kg の場合、BMI = 65 / (1.70)^2 = 65 / 2.89 ≒ 22.5(アプリでは小数第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(
      title: 'BMI 計算',
      theme: ThemeData(
        colorSchemeSeed: Colors.green,
        useMaterial3: true,
      ),
      home: const BmiPage(),
    );
  }
}

class BmiPage extends StatefulWidget {
  const BmiPage({super.key});

  @override
  State<BmiPage> createState() => _BmiPageState();
}

class _BmiPageState extends State<BmiPage> {
  final TextEditingController _heightController = TextEditingController();
  final TextEditingController _weightController = TextEditingController();
  double? _bmi;
  String _category = '';

  @override
  void dispose() {
    _heightController.dispose();
    _weightController.dispose();
    super.dispose();
  }

  void _calculateBmi() {
    final heightCm = double.tryParse(_heightController.text);
    final weight = double.tryParse(_weightController.text);

    if (heightCm == null || weight == null || heightCm <= 0 || weight <= 0) {
      setState(() {
        _bmi = null;
        _category = '正しい値を入力してください';
      });
      return;
    }

    final heightM = heightCm / 100; // cm を m に変換
    final bmi = weight / (heightM * heightM);

    String category;
    if (bmi < 18.5) {
      category = '低体重(やせ型)';
    } else if (bmi < 25.0) {
      category = '普通体重';
    } else if (bmi < 30.0) {
      category = '肥満(1度)';
    } else if (bmi < 35.0) {
      category = '肥満(2度)';
    } else if (bmi < 40.0) {
      category = '肥満(3度)';
    } else {
      category = '肥満(4度)';
    }

    setState(() {
      _bmi = bmi;
      _category = category;
    });
  }

  Color _getBmiColor() {
    if (_bmi == null) return Colors.grey;
    if (_bmi! < 18.5) return Colors.blue;
    if (_bmi! < 25.0) return Colors.green;
    if (_bmi! < 30.0) return Colors.orange;
    return Colors.red;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('BMI 計算アプリ')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const Text(
              'BMI を計算しましょう',
              style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 24),
            TextField(
              controller: _heightController,
              keyboardType: TextInputType.number,
              decoration: const InputDecoration(
                labelText: '身長 (cm)',
                hintText: '例: 170',
                border: OutlineInputBorder(),
                prefixIcon: Icon(Icons.height),
              ),
            ),
            const SizedBox(height: 16),
            TextField(
              controller: _weightController,
              keyboardType: TextInputType.number,
              decoration: const InputDecoration(
                labelText: '体重 (kg)',
                hintText: '例: 65',
                border: OutlineInputBorder(),
                prefixIcon: Icon(Icons.monitor_weight),
              ),
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: _calculateBmi,
              style: ElevatedButton.styleFrom(
                padding: const EdgeInsets.symmetric(vertical: 16),
              ),
              child: const Text('計算する', style: TextStyle(fontSize: 18)),
            ),
            const SizedBox(height: 32),
            if (_bmi != null) ...[
              Container(
                padding: const EdgeInsets.all(24),
                decoration: BoxDecoration(
                  color: _getBmiColor().withOpacity(0.1),
                  borderRadius: BorderRadius.circular(16),
                  border: Border.all(color: _getBmiColor(), width: 2),
                ),
                child: Column(
                  children: [
                    const Text('あなたの BMI', style: TextStyle(fontSize: 16)),
                    const SizedBox(height: 8),
                    Text(
                      _bmi!.toStringAsFixed(1),
                      style: TextStyle(
                        fontSize: 48,
                        fontWeight: FontWeight.bold,
                        color: _getBmiColor(),
                      ),
                    ),
                    const SizedBox(height: 8),
                    Text(
                      _category,
                      style: TextStyle(
                        fontSize: 20,
                        fontWeight: FontWeight.w500,
                        color: _getBmiColor(),
                      ),
                    ),
                  ],
                ),
              ),
            ] else if (_category.isNotEmpty) ...[
              Text(
                _category,
                style: const TextStyle(color: Colors.red, fontSize: 16),
                textAlign: TextAlign.center,
              ),
            ],
          ],
        ),
      ),
    );
  }
}

動作の検証

身長 170 cm、体重 65 kg を入力して計算ボタンを押した場合:

  1. heightCm = 170.0, weight = 65.0
  2. heightM = 170.0 / 100 = 1.7
  3. bmi = 65.0 / (1.7 * 1.7) = 65.0 / 2.89 = 22.4913...
  4. 22.4913...18.5 <= bmi < 25.0 の範囲なので category = '普通体重'
  5. _bmi!.toStringAsFixed(1)"22.5" と表示される

BMI の判定基準は日本肥満学会の分類に基づいています。


10. 状態管理の課題予告

ここまでの setState() による状態管理は、1 つの Widget 内で完結する状態には十分です。しかし、アプリが大きくなると以下の課題が出てきます。

  • 複数の画面で同じ状態を共有したい(例: ログインユーザー情報)
  • 深い Widget ツリーの末端に状態を渡したい(props のバケツリレー問題)
  • 状態のロジックが複雑になり build メソッドが肥大化する

これらの課題を解決するために、Flutter には以下のような状態管理ソリューションがあります。

パッケージ 特徴
Provider Google 推奨。InheritedWidget をラップした使いやすい API
Riverpod Provider の改良版。コンパイル時安全性が高い
Bloc イベント駆動型。大規模アプリ向け

今後の記事でこれらについて詳しく解説する予定です。まずは setState() での状態管理をしっかり理解しておきましょう。


練習問題

問題 1

「お気に入り」ボタンをタップするとハートアイコンの色が切り替わるアプリを作成してください。

  • ハートアイコン(Icons.favorite)を画面中央に大きく表示
  • 初期状態ではグレー(Colors.grey
  • タップするたびに赤(Colors.red)とグレーの間で切り替わる
  • アイコンの下に「お気に入り」または「お気に入り解除」とテキストを表示
模範解答
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: const FavoritePage(),
    );
  }
}

class FavoritePage extends StatefulWidget {
  const FavoritePage({super.key});

  @override
  State<FavoritePage> createState() => _FavoritePageState();
}

class _FavoritePageState extends State<FavoritePage> {
  bool _isFavorite = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('お気に入り')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            IconButton(
              iconSize: 100,
              icon: Icon(
                Icons.favorite,
                color: _isFavorite ? Colors.red : Colors.grey,
              ),
              onPressed: () {
                setState(() {
                  _isFavorite = !_isFavorite;
                });
              },
            ),
            const SizedBox(height: 16),
            Text(
              _isFavorite ? 'お気に入り登録済み' : '未登録',
              style: const TextStyle(fontSize: 20),
            ),
          ],
        ),
      ),
    );
  }
}

_isFavorite の初期値は false なのでアイコンはグレーで「未登録」と表示されます。タップすると _isFavoritetrue になりアイコンは赤に、テキストは「お気に入り登録済み」に変わります。

問題 2

数値入力用の TextField を 2 つ配置し、ボタンを押すと 2 つの数値の合計を表示するアプリを作成してください。

  • 入力には TextEditingController を使用する
  • dispose() でコントローラーを破棄する
  • 数値以外が入力された場合はエラーメッセージを表示する
模範解答
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: const AdditionPage(),
    );
  }
}

class AdditionPage extends StatefulWidget {
  const AdditionPage({super.key});

  @override
  State<AdditionPage> createState() => _AdditionPageState();
}

class _AdditionPageState extends State<AdditionPage> {
  final TextEditingController _num1Controller = TextEditingController();
  final TextEditingController _num2Controller = TextEditingController();
  String _result = '';

  @override
  void dispose() {
    _num1Controller.dispose();
    _num2Controller.dispose();
    super.dispose();
  }

  void _calculate() {
    final num1 = double.tryParse(_num1Controller.text);
    final num2 = double.tryParse(_num2Controller.text);

    if (num1 == null || num2 == null) {
      setState(() {
        _result = 'エラー: 正しい数値を入力してください';
      });
      return;
    }

    final sum = num1 + num2;
    setState(() {
      // 整数として表示できる場合は小数点を省く
      if (sum == sum.roundToDouble()) {
        _result = '合計: ${sum.toInt()}';
      } else {
        _result = '合計: $sum';
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('足し算アプリ')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            TextField(
              controller: _num1Controller,
              keyboardType: TextInputType.number,
              decoration: const InputDecoration(
                labelText: '数値 1',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 16),
            TextField(
              controller: _num2Controller,
              keyboardType: TextInputType.number,
              decoration: const InputDecoration(
                labelText: '数値 2',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: _calculate,
              child: const Text('計算する'),
            ),
            const SizedBox(height: 24),
            Text(
              _result,
              style: TextStyle(
                fontSize: 24,
                color: _result.startsWith('エラー') ? Colors.red : Colors.black,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

数値 1 に 10、数値 2 に 25 を入力して計算ボタンを押すと:

  • num1 = 10.0, num2 = 25.0
  • sum = 35.0
  • 35.0 == 35.0.roundToDouble()true なので sum.toInt()35
  • 画面には「合計: 35」と表示されます

数値 1 に abc を入力した場合:

  • double.tryParse('abc')null を返す
  • 画面には赤字で「エラー: 正しい数値を入力してください」と表示されます

まとめ

概念 説明
StatefulWidget 状態を持つ Widget。Widget クラスと State クラスのペアで構成
setState() 状態変数を変更し、build() を再実行して UI を更新する
initState() State 初期化時に 1 回だけ呼ばれる(コントローラー作成など)
dispose() State 破棄時に 1 回だけ呼ばれる(リソース解放)
TextEditingController TextField の値をプログラムから管理する
onChanged 入力値がリアルタイムで変わるたびに呼ばれるコールバック

setState() は Flutter の状態管理の出発点です。小〜中規模のアプリではこれだけで十分に対応できます。次のステップとして Provider や Riverpod を学ぶと、より大規模なアプリにも対応できるようになります。


参考


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

0
3
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
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?