【Flutter】TextFieldの入力状況をリアルタイムに受け取ってボタンの活性化状態を切り替えたい

## はじめに

表題の件で少々手こずったので記事に残したいと思います。
前提条件として、flutter_hooksというパッケージの`useTextEditingController`を使用することを前提としています。

https://pub.dev/packages/flutter_hooks

### 記事を執筆時点での筆者の環境

```

[✓] Flutter (Channel stable, 3.24.1, on macOS 14.5 23F79 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.3)
[✓] VS Code (version 1.92.2)

```

## うまくできていないコード

```dart
class HomeScreen extends HookWidget {
  const HomeScreen({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    // テキストフィールドのコントローラーを作成
    final textController = useTextEditingController(text: '');

    // テキストフィールドのテキストが空でないかどうかを判定
    final isNotEmpty = textController.text.isNotEmpty;

    return Scaffold(
      body: SafeArea(
                // 省略
                TextField(
                  controller: textController,
                  decoration: const InputDecoration(
                    border: OutlineInputBorder(),
                    labelText: '文字を入力すると登録できます',
                  ),
                ),
                const Gap(50),
                ElevatedButton(
                  // テキストフィールドのテキストが空でない場合のみ登録ボタンを有効にする
                  onPressed: isNotEmpty
                      ? () {
                          final saveText = textController.text;
                          textController.clear();
                          FocusScope.of(context).unfocus();
                          ScaffoldMessenger.of(context).showSnackBar(
                            SnackBar(
                              content: Text('「$saveText」を登録しました'),
                            ),
                          );
                        }
                      : null,
                  child: const Text('登録'),
                ),
                // 省略
```

<details><summary>うまくいかないコード全体</summary>


```main.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';

void main() {
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: HomeScreen(),
    );
  }
}

class HomeScreen extends HookWidget {
  const HomeScreen({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    // テキストフィールドのコントローラーを作成
    final textController = useTextEditingController(text: '');

    // テキストフィールドのテキストが空でないかどうかを判定
    final isNotEmpty = textController.text.isNotEmpty;

    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20),
          child: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                TextField(
                  controller: textController,
                  decoration: const InputDecoration(
                    border: OutlineInputBorder(),
                    labelText: '文字を入力すると登録できます',
                  ),
                ),
                const Gap(50),
                ElevatedButton(
                  // テキストフィールドのテキストが空でない場合のみ登録ボタンを有効にする
                  onPressed: isNotEmpty
                      ? () {
                          final saveText = textController.text;
                          textController.clear();
                          FocusScope.of(context).unfocus();
                          ScaffoldMessenger.of(context).showSnackBar(
                            SnackBar(
                              content: Text('「$saveText」を登録しました'),
                            ),
                          );
                        }
                      : null,
                  style: ElevatedButton.styleFrom(
                    backgroundColor: isNotEmpty ? Colors.blue : Colors.grey,
                  ),
                  child: const Text('登録'),
                ),
                const Gap(80),
                ElevatedButton(
                  onPressed: textController.clear,
                  child: const Text('クリア'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
```
</details>
useTextEditingControllerのtextが空文字かを判定にしてボタンが活性化するようにしています。

しかし、いくら文字を入力してもこのままだとボタンが活性化になりません。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/02262dee-6b6c-1a9e-adf4-7ecdf2b09804.png
">

ここでエンター（レチェックのボタン）をタップすると活性化になります

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/b4bb6344-d32b-e558-2833-a1563146d384.png">

できれば一文字目から変換が終わってなくても活性化して欲しいところです。
百歩譲ってこれで良かったとしても、よくない点があります。

一度上記の活性化状態後に全部文字を削除ボタンで消して

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/2e68f949-b75d-db25-4ec9-4ffd0320655d.png">

何も文字がない状態で登録ボタンが押せてしまうのです😇

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/f8053d64-f826-b4f0-9aa7-190850349b1a.png
">

スクリーンショットの一番下に表示されているスナックバーにもあるとおり、から文字を登録できてしまいました。
## 解決策： `useListenableSelector`を使おう

https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useListenableSelector.html

```dart
@override
  Widget build(BuildContext context) {
    // テキストフィールドのコントローラーを作成
    final textController = useTextEditingController(text: '');

    // ❌ これをやめて
    // final isNotEmpty = textController.text.isNotEmpty;

    // テキストフィールドのテキストが空でないかどうかを判定
    // ⭕️ これを使う
    final isNotEmpty = useListenableSelector(
      textController,
      () => textController.text.isNotEmpty,
    );

    return Scaffold(
```

上記に差し替えるだけで
**TextFieldの入力状況をリアルタイムに受け取ってボタンの活性状態を切り替える**
機能を実現できます！

## 考察

`useTextEditingController`単体ではリアルタイムの変換に対応していないようです。
文字を入力した時点ではWidgetがリビルドされていないのです。
なぜかレチェックのボタンを押すとリビルドされる謎仕様。

そこで`useListenableSelector`を使って状況の変化を受け取ったらリビルドするようにしています。
第一引数に観察対象、第二引数にその観察する変化内容を定義します。
今回で行けば観察対象が`useTextEditingController`で、
観察する変化内容が`useTextEditingController`の`text`プロパティが空文字か否かです。

## 終わりに

今回の問題はWidgetTestを書いている時に発覚しました。
いくらテストで文字を入れてもボタンが活性化されなかったのです。
この問題の解決に数時間とかしました🥲

同じ問題を抱える方のお役に立てれば幸いです。