🖋️

【Flutter】TextEditingValue のことは知ってた方がいいかも

2024/04/14に公開

環境

# Flutter
3.19.5

TextEditingValue って誰?

TextEditingValue というクラスを見て、ピンとくる人はいるかもしれませんが、あまり意識しなくてもいいものなので、知らない人も多いと思っています(意識しなくてもいいように作られている)。

なので、どこに登場するものなのか少し説明します。

https://api.flutter.dev/flutter/services/TextEditingValue-class.html

こいつは、馴染み深い TextEditingController で使われています。

まず、TextEditingController は、ValueNotifier の継承です。

ValueNotifierとは、ざっくりいうと監視可能な単一の値を保持したコントローラーです。
flutter_hooks の useState で生成されるのも ValueNotifier です(あれも単一の値を持ってますよね)。

TextEditingController でいうと、この単一の値は String だけで良さそうなもんですが、実際にはそうではなく、TextEditingValue というクラスを保持しています。

引用元

editable_text.dart
class TextEditingController extends ValueNotifier<TextEditingValue> {
 TextEditingController({ String? text })
   : super(text == null ? TextEditingValue.empty : TextEditingValue(text: text));

TextEditingValue のメンバ変数

変数名 ざっくり説明
text String 入力されている文字列
selection TextSelection カーソルの位置、および選択中
composing TextRange 「入力が暫定的である」テキストの範囲

text は文字でしかないのでスキップします。

composing 変数

https://api.flutter.dev/flutter/services/TextEditingValue/composing.html

「入力が暫定的である」テキストの範囲を示す変数です。
日本語を日常的に使う我々にとっては馴染み深い下線が入るやつですね。

start, end の2つの整数を持ったTextRange クラスで表現されています。
「範囲が選択されていない」場合は、TextRange(start: -1, end: -1) で表現されます。

text.dart
// 抜粋
class TextRange {
  const TextRange({
    required this.start,
    required this.end,
  }) : assert(start >= -1),
       assert(end >= -1);

  static const TextRange empty = TextRange(start: -1, end: -1);

  final int start;
  final int end;
}

TextEditingController でソースコード上で text を set する際は、TextRange.empty で値がセットされます。

editable_text.dart
class TextEditingController {
  set text(String newText) {
    value = value.copyWith(
      text: newText,
      selection: const TextSelection.collapsed(offset: -1),
      composing: TextRange.empty,
    );
  }
}
my_widget.dart
  _textEditingController.text = 'hoge';
  print(_textEditingController.value.composing);
  // TextRange(start: -1, end: -1)

selection 変数

ここで表現されるのは、カーソルの位置もしくは、選択中の範囲です。
カーソルと範囲選択は必ず共存しないので、1つの変数で表現されています。

この selection 変数は、TextSelection というクラスで表現されており、これは、composing 変数で使っている、TextRange の継承です。
なので、本質は start, end の2つの整数を持っている点です。

では、TextRange(範囲) でありながらどうやって「カーソルの位置」というひとつの地点を表すのでしょうか。

カーソルの位置

さっき TextRange で解説しなかった1つの状態があります。
それが collapsed という状態です。

text.dart
class TextRange {
  const TextRange({
    required this.start,
    required this.end,
  }) : assert(start >= -1),
       assert(end >= -1);

  static const TextRange empty = TextRange(start: -1, end: -1);

  /**
   👇 これ 👇
   */
  const TextRange.collapsed(int offset)
    : assert(offset >= -1),
      start = offset,
      end = offset;
  /**
   👆 これ 👆
   */

  final int start;
  final int end;
}

colapsed の本質は start == end。つまり整数が1つとなります。
範囲(Range)でありながら、1地点を表すのに使われます。

例えば、こんなことができます。

my_widget.dart
onPressed: () {
  Timer.periodic(const Duration(seconds: 1), (timer) {
    if (timer.tick >= 10) {
      timer.cancel();
    } else {
      // Timer のカウント数の位置に、カーソルを動かす
      _textEditingController.value = _textEditingController.value.copyWith(
        selection: TextSelection.collapsed(offset: timer.tick),
      );
    }
  });
},

1秒ごとにカーソルが移動しましたね。

では、なぜ TextRange ではなく、その継承の TextSelection が使われているのでしょうか。
それは、TextSelection で追加された、Enum の TextAffinity である textAffinity という変数が関わっています。

https://api.flutter.dev/flutter/services/TextSelection/affinity.html

text.dart
enum TextAffinity {
  upstream,
  downstream,
}

TextAffinity が何かについては、ぶっちゃけそこまで重要じゃないです(多分)。
「上か下か」という2パターンがある程度に

TextSelectionaffinity変数の説明は以下です。

If the text range is collapsed and has more than one visual location (e.g., occurs at a line break), which of the two locations to use when painting the caret.

以下、DeepL 翻訳
テキスト範囲が折りたたまれていて、視覚的な位置が2つ以上ある場合(例えば、改行時に発生する)、キャレットを描くときに2つの位置のどちらを使うか。

視覚的な位置が2つ以上ある場合 を再現します。

たとえば、幅が小さい TextField があるとしましょう。
そして、maxLines: null を設定して、自動で改行するようにします。

my_widget.dart
SizedBox(
  width: 120,
  child: TextField(
    maxLines: null,
  ),
)

そこに、5文字で改行される文字を打ってみます。

そして、カーソルを右にし続けます。

動画

すると、5文字目と6文字目の間(offset: 5)の場合に下に移動します。
しかし、1行目の末尾にクリックでカーソルを合わせると、その1行目の末尾の地点にカーソルが合います。
これも 5文字目と6文字目の間(offset: 5)です。

この時、どちらも5文字目と6文字目の間(offset: 5)ですが、上と下で表示位置が違います。
これを、TextAffinity の2つの値で区別しています。

キーボードでカーソル移動 クリックで1行目末尾を選択
TextAffinity.downstream TextAffinity.upstream

選択範囲

次は選択範囲です。
これは collapsed とは違い start~end までですが、TextRange とは違い、base, extent という変数を持っています。
これによって、同じ範囲を選択していても、先頭から選択を始めた場合と、末尾から選択を始めた場合で持たせるデータを変えることができます。

0=>5 で選択した場合 5=>0 で選択した場合
同じ 0~5 だが、base が 5 で extent が 0 と逆転している

余談

TextField (ひいては、その内部の EditabelText)では、この selection の値に応じて、見た目を変更させる仕組みが備わっています。

活用例

composing に関しては、特に活用例は思いつきませんが、selection は実用的な側面が多いです。
たとえば、入力補助ボタンのような実装です。
「」を入れつつ、その間にカーソルを持ってくるといった機能を、selection 変数の役割を知っていれば、ちゃちゃっと実装することができます。

my_widget.dart
FilledButton(
    onPressed: () {
      _textEditingController.value =
          _textEditingController.value.copyWith(
        text: '${_textEditingController.text}「」',
        selection: TextSelection.collapsed(
          offset: _textEditingController.text.length + 1,
        ),
      );
    },
    child: const Text('「」'),
),

まとめ

  • TextEditingController は、単に文字列だけを持っているわけではなく、TextEditingValue というクラスで、文字列以外も持っている。
  • 持っている値は、以下の2つ
    • 暫定入力中の範囲を示す composing 変数。
    • カーソルの位置、またはテキスト選択範囲を示す selection 変数。
  • どちらも、TextRange クラスを使用している。
    • Range(範囲)という名称だが、始点と終点の数を同じにする(collapsed)ことで、単一の位置を示すこともできる。
  • selection 変数は、TextRange といっても継承された TextSelection という拡張されたクラス。
    • affinity 変数
      • 途中でテキスト折り返された時、上下どちらにカーソルを置くか
        キーボードでカーソル移動 クリックで1行目末尾を選択
        TextAffinity.downstream TextAffinity.upstream
    • isDirectional 変数
      • true になってるとこ見たことない。わからんから教えてほしい

活用例や役立つ場面は少ないですが、TextEditingController を扱う上での UX 向上に少しでも役に立てられれば嬉しいです。

Discussion