【Flutter】TextField のカーソル位置がズレるバグ、原因はカスタム Controller の改行処理だった話

こんにちは!YOUTRUSTのアプリエンジニアの葉(YOUTRUST)です。

今回はYOUTRUSTアプリ内のTextFieldで発生した改行バグの調査と修正についてご紹介します。

TL;DR

自社カスタムのTextEditingController内のbuildTextSpan()\r\n\n に変換していたことが原因で、カーソル位置がずれる問題が発生しました。TextFieldの内部実装である表示用のinlineSpanと実際のvalue.textの文字数が一致しないと、カーソルの位置情報(Selection)が壊れるということを学びました。

何が起きたのか?

こちらは墨(YOUTRUST)が不具合チャンネルに投稿した動画の切り抜きです。

弊社では、アプリの動作で気になる点や不具合を見つけた場合、Slack内の「不具合チャンネル」にて報告する文化があります。リアルタイムに情報を把握し、対応できる体制を整っていて、助かっています。

TextFieldで文字を入力・削除すると、カーソルの1文字前のところに文字が入力・削除されるような不自然な挙動が発生しています 。

本来の期待する動きは、カーソルの位置に対して入力・削除されることですよね。

原因の調査

🐛 デバッグ編

  1. WebAPIのレスポンスを確認 – JSON文字列に \r\n\n が混在していることを発見しました。
  2. DevToolsでカーソルの位置(selection)をウォッチselection.baseOffset*1 が1文字ずれていることを確認できました。

🔍 ソースコード編

不具合が発生していたのは、社内でカスタマイズされた TextEditingController です。このコントローラーは標準のTextEditingControllerを継承し、buildTextSpan()をオーバーライドしています。

なぜカスタマイズしていたのか?

弊社独自のURLやメンションの表示方法を実現するために、カスタマイズが必要でした。例えば、#3月の出来事を振り返る などのタグに独自の色を付けるなど、アプリ全体のUI/UXの統一感を持たせる目的がありました。

そのbuildTextSpan()内で、以下のような関数(smartify)を呼び出しています。

List<SmartTextElement> smartify(String text) {
  // ① LineSplitter.split(text)は\r・ \n・ \r\n のいずれかの改行で文字列を分割し、
  // リストとして返します。
  final sentences = LineSplitter.split(text);
  final elements = [];

  // ② 各行(分割された文字列)をループ処理。
  for (final sentence in sentences) {
    // ③ 行ごとに、UserTag や URL を検出し、要素ごとに分類。
    ...
    // ④ 改行を追加
    elements.add('\n');
  }
  return elements;
}

①で改行を分割するため、④で改行コードが\nに統一されてしまい、実際のテキストと表示用のテキストに差が生じました。

もしかして、これが不具合の原因ではないか?🤔 という仮説を立てました。

補足:実際の動作は TextFieldEditableText_Editableという流れで処理されます。このとき、表示用のinlineSpanEditableTextState.buildTextSpan()を経てController.buildTextSpan()で生成されます。一方、実際の値はController.textです。表示と保存内容が一致しないと、今回のようなバグが発生しますが、複雑になるため今回は詳細を割愛します。

🔧 修正

List<SmartTextElement> smartify(String text) {
  final sentences = LineSplitter.split(text);
  // 改行コードを1つずつ保持するためにQueueに格納
  final newlineMatches = Queue<RegExpMatch>.from(
    RegExp(r'\r\n|\n|\r').allMatches(text),
  );

  final elements = [];
  for (final sentence in sentences) {
    ....
    // 改行コードをQueueから取り出す
    if (newlineMatches.isNotEmpty) {
      final newline = newlineMatches.removeFirst().group(0);
      if (newline != null && newline.isNotEmpty) {
        elements.add(newline);
      }
    }
  }
  return elements;
}

改行コードは変換せず、テキスト中に含まれるもの(\r\n\n など)をそのまま扱うようにしました。

テストで確認

修正後の挙動を確認しましょう。

入力、削除がカーソルのところになって、正しくなりました!🥳🎉

おわり

「表示用のテキスト(inlineSpan)と実際に保存されているテキスト(value.text)がズレると、予想外のバグが発生する」ということを具体的に理解できました。

今後もこのような調査過程を積極的に共有していきたいと思います!

Flutter LT会でも登壇しました

今回紹介した内容は、先日の YOUTRUST 主催の Flutter LT会でも登壇しました!
登壇資料は Flutter 専用のスライドツール「FlutterDeck」で作成しました。FlutterDeckによって、この不具合を登壇時操作できるので、より分かりやすくなりました。

YOUTRUST では定期的に Flutter のイベントを開催しているので、
興味のある方はぜひXの開発アカウントをフォローしてチェックしてみてください

We are hiring!

YOUTRUSTではスタートアップならではのスピードと裁量で自分から動くことが奨励されています。
もし今回の内容が少しでも参考になれば、ぜひ以下もチェックしてみてください!

youtrust.jp

herp.careers

*1:※カーソルの現在位置を表すインデックス