Flutter TextEditingController×Riverpodの改善

## はじめに
突然ですがFlutterの`TextEditingController`ってぶっちゃけ中途半端じゃないですか！？例として簡単なテキストフィールドを用いたページを示します。
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final textControllerProvider = StateProvider((ref) => TextEditingController());
final textProvider = StateProvider<String>((ref) => '');

class SamplePage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final textController = ref.watch(textControllerProvider);
    final text = ref.watch(textProvider);

    return Scaffold(
      body: Column(
        children: [
          TextField(
            controller: textController,
            onSubmitted: (value) {
              ref.read(textProvider.notifier).state = value;
            },
          ),
          Text('You typed: $text'),
        ],
      ),
    );
  }
}
```
個人的にせっかく`TextEditingController`を使ったのに、もう一個テキストの状態を用意して送信(`onSubmitted`)のタイミングで更新するのはバカらしいじゃないですかと思うわけです。せいぜいテキストフィールドとしての機能の半分ぐらいしかコントロールできてないじゃん、と。
なら直接`TextEditingController.text`を見ればいいじゃないかと思うわけですが、
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final textControllerProvider = StateProvider((ref) => TextEditingController());

class SamplePage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final textController = ref.watch(textControllerProvider);

    return Scaffold(
      body: Column(
        children: [
          TextField(
            controller: textController,
          ),
          Text('You typed: ${textController.text}'),
        ],
      ),
    );
  }
}
```
これでは反応しないんですよね。`StateProvider`から見た時に"状態"は`TextEditingController`自体であってその内部の`.text`ではないからだと思います。
`final`をつけた時そのオブジェクトを再代入することはできませんがメソッド経由で値を書き換えることができるのと同じかな。

## 改善策
自作ライブラリみたいなノリで作ってみました。正式なライブラリにするつもりは今のところないのでコピペして使ってください。
```dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class TextEditingNotifier extends StateNotifier<String> {
  TextEditingNotifier([super.value = ''])
      : _controller = TextEditingController(text: value);

  final TextEditingController _controller;
  TextEditingController get controller => _controller;

  void update() {
    state = _controller.text;
  }

  void clear() {
    _controller.clear();
  }
}

enum TextEditingUpdateType { onSubmitted, onChanged, never }

class NotifierTextField extends TextField {
  NotifierTextField({
    required this.notifier,
    TextEditingUpdateType updateType = TextEditingUpdateType.onSubmitted,
    super.key,
    super.focusNode,
    super.decoration,
    super.keyboardType,
    super.textInputAction,
    super.textCapitalization,
    super.style,
    super.strutStyle,
    super.textAlign,
    super.textAlignVertical,
    super.textDirection,
    super.readOnly,
    super.showCursor,
    super.autofocus,
    super.obscureText,
    super.autocorrect,
    super.smartDashesType,
    super.smartQuotesType,
    super.enableSuggestions,
    super.maxLines,
    super.minLines,
    super.expands,
    super.maxLength,
    super.maxLengthEnforcement,
    super.onEditingComplete,
    void Function(String)? onSubmitted,
    super.inputFormatters,
    super.enabled,
    super.cursorWidth,
    super.cursorHeight,
    super.cursorRadius,
    super.cursorColor,
    super.selectionHeightStyle,
    super.selectionWidthStyle,
    super.keyboardAppearance,
    super.scrollPadding,
    super.dragStartBehavior,
    super.enableInteractiveSelection = true,
    super.selectionControls,
    super.onTap,
    void Function(String)? onChanged,
    super.onTapOutside,
    super.mouseCursor,
    super.buildCounter,
    super.scrollController,
    super.scrollPhysics,
    super.autofillHints = null,
    super.contentInsertionConfiguration,
    super.clipBehavior,
    super.restorationId,
    super.stylusHandwritingEnabled,
    super.enableIMEPersonalizedLearning,
  }) : super(
          controller: notifier.controller,
          onSubmitted: (text) {
            if (updateType == TextEditingUpdateType.onSubmitted) {
              notifier.update();
            }
            if (onSubmitted != null) {
              onSubmitted(text);
            }
          },
          onChanged: (text) {
            if (updateType == TextEditingUpdateType.onChanged) {
              notifier.update();
            }
            if (onChanged != null) {
              onChanged(text);
            }
          },
        );
  final TextEditingNotifier notifier;
}

class NotifierSubmitButton extends ElevatedButton {
  NotifierSubmitButton({
    required this.notifier,
    void Function()? onPressed,
    super.onLongPress,
    super.onHover,
    super.onFocusChange,
    super.style,
    super.focusNode,
    super.autofocus = false,
    super.clipBehavior,
    super.statesController,
    super.child,
  }) : super(
          onPressed: () {
            notifier.update();
            if (onPressed != null) {
              onPressed();
            }
          },
        );

  final TextEditingNotifier notifier;
}
```
### TextEditingNotifier
`Controller`に相当します。状態を`String`にして内部に`TextEditingController`を持たせます。これにより内部で`state = _controller.text`を行うことでアプリ全体に状態の変更が通知されます。`StateNotifierProvider`でラップして使うことになります。(後述)

### TextEditingUpdateType
後述のテキストフィールドで使う更新のタイミングを制御するための列挙型です。変更時、送信時、無効の3種類が選べます。別で送信ボタンがあるときなどは無効にします。

### NotifierTextField
今回専用のテキストフィールドです。`TextField`を継承しますが、`controller`の代わりに`notifier`を指定し、`updateType`に前述の列挙型を渡して更新のタイミングを指定します。`onSubmitted`と`onChanged`では指定された処理の前に更新を行います。なるべく元の`TextField`の属性も引き継げるようにしましたが、もし引数が足りてなかったら自分で足してください。
### NotifierSubmitButton
送信ボタン用です。`notifier`を指定することで押下時に勝手に更新してくれます。

## 使い方
送信ボタンの有無で2つ作ったのでぱっと見改善前よりコードが増えてるように見えますがそんなことないです。`TextEditingProvider`はNotifierでラップして`ref.watch`で参照します。
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_sample/shared/custom_widgets.dart';

//送信ボタンなし用Provider
final textEditingNotifierProvider1 =
    StateNotifierProvider<TextEditingNotifier, String>(
  (ref) => TextEditingNotifier(),
);
//送信ボタンあり用Provider
final textEditingNotifierProvider2 =
    StateNotifierProvider<TextEditingNotifier, String>(
  (ref) => TextEditingNotifier(),
);

class SamplePage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notifier1 = ref.watch(textEditingNotifierProvider1.notifier);
    final notifier2 = ref.watch(textEditingNotifierProvider2.notifier);
    return Scaffold(
      body: Column(
        children: [
          //送信ボタンなし
          NotifierTextField(
            notifier: notifier1,
          ),
          Text('You typed: ${ref.watch(textEditingNotifierProvider1)}'),
          //送信ボタンあり
          NotifierTextField(
            notifier: notifier2,
            updateType: TextEditingUpdateType.never,
          ),
          NotifierSubmitButton(
            notifier: notifier2,
            child: const Text('送信'),
          ),
          Text('You typed: ${ref.watch(textEditingNotifierProvider2)}'),
        ],
      ),
    );
  }
}
```
唯一の問題は状態(`String`)が欲しい時と`Notifier`が欲しい時で`ref.watch()`から重複するところでしょうか。Riverpodの使用上致し方ないところだと思いますが改善策があればコメントください。

## まとめ
今回は`TextEditingController`をより使いやすくする方法について紹介しました。とはいえ`TextField`と`StateNotifier`が密結合になって自由度は多少下がります。お好きに使ってください。