Flutter(Dart) TextFieldにundo(元に戻す)とredo(やり直す)を実装する方法

Flutter

40代からプログラミング(Flutter)を始めて、GooglePlayに初アプリを公開したhalzo appdevです。

作成したアプリはこちら↓

シンプルメモ帳「BasicMemo」 – 文字数カウント、ワンタッチ入力、タグ管理等の便利機能を搭載

Google Play で手に入れよう

 

FlutterのTextFieldを使ったメモアプリを作成している中で、一般的なテキストエディタには必ずある

  • 入力した文字を元に戻す(一般にundoと呼ばれる。Windowsで言うとCtrl+Z)
  • undoした処理をやり直す(一般にredoと呼ばれる。Windowsで言うとCtrl+Y)

の機能を実装しようとしたのですが、意外と既存のパッケージやネット上に参考となるコード情報がありませんでした。

結局イチから自作することとなり、なかなか大変だったので、作成したコードや考え方を共有したいと思います。

  • FlutterのTextFieldにundo(元に戻す)とredo(やり直す)を実装したいけど、やり方が分からない。
  • 言語によらず、undo(元に戻す)とredo(やり直す)ってどういう考え方で実装するのか知りたい。

という方のご参考になればと思います。

先に最終的なコード全体をご覧になりたい方は<こちら>をご覧ください。

スポンサーリンク

ネットで公開されているundo(元に戻す)とredo(やり直す)のコード例

まず自分で作る前に、ググってコードが公開されていないか調べました。

ボタン操作に対するundo/redo(Dartの公式パッケージ)

以下のパッケージがありました。

Floating Action Buttonを押すと、画面中央の数字がカウントアップされていき、undoボタンを押すことでそれを戻すことができる、という内容でした。

自分がやりたいのは、任意で入力した文字列の入力を戻したり、やり直したりがしたかったので、ニーズと合っていませんでした。。(もしかするとうまく実装すれば応用できたのかもしれないのですが、、)

SnackBarに用意されている機能

Flutterに用意されているウィジェットの1つである「SnackBar」には、undoボタンが設置されています。

ただ、上記Flutterの解説サイトでも、肝心のundoボタンの処理については、サンプルコードが、

onPressed: () {
      // Some code to undo the change.
    },

となってしまっていて、詳しく記載がありませんでした。

描画操作に対するundo/redo

「株式会社イーガオ」さんが、以下の情報を上げてくださっていました。

こちらは描画操作を戻したりやり直したりできるコードです。

これはスゴイなぁと思ったのですが、自分は文字入力を対象に実装したかったので、こちらもニーズと違っていました。

今回実装した機能(アウトプット)の仕様

結果、コードを自作することにしたのですが、実装した主な機能は以下のとおりです。

①TextFieldに文字を入力した後、undo(元に戻す)ボタンを押すと、一つ前の入力状態に戻る。

②かな漢字変換の過程も1つずつ戻る。

例えば、「あき」と入れて次に「秋」と変換したところで、undo(元に戻す)ボタンを押すと、「秋」が文字ごと消えるのではなく、変換前の「あき」に戻る。

③文字列の塊をコピーペーストした後でundo(元に戻す)ボタンを押した場合は、ペーストした文字列の塊ごと削除される。

④redo(やり直す)ボタンを押すと、上記①〜③のundo(元に戻す)処理が取り消される

①のケースのundoをredoでやり直した場合の例です。

⑤redo(やり直す)ボタンを押せる状態で、新しく文字を入力したら、redo(やり直す)ボタンは押せなくなる。

自作したコード(アルゴリズム)の大まかな流れ

作成したコードは、主に以下の要素から成り立っています。

  1. AppBarにundo(元に戻す)ボタンとredo(やり直す)ボタンを設置
  2. 画面中央にTextFieldを改行可能な仕様で設定
  3. undo(元に戻す)できるようにするため、文字入力でTextFieldが変化するたびに、入力されている全文字列を保管(配列変数として蓄積していく)
  4. redo(やり直す)できるようにするため、undo(元に戻す)ボタンが押されるたびに、undo(元に戻す)実行前の全文字列を保管(undo用とは別の配列変数に蓄積していく)
  5. 押しても意味がない(戻せない、やり直せない)状況では、undo(元に戻す)ボタン、redo(やり直す)ボタンを押せない状態に設定
  6. undo(元に戻す)ボタンとredo(やり直す)ボタンを押すと、文字列を丸ごと一つ前の状態に書き直すように設定
  7. undo(元に戻す)ボタンとredo(やり直す)ボタンを押した時に、カーソルの位置が適切な位置(1つ前の状態の位置)に戻るように設定

なぜ7.をやるかというと、6.だけだと、文字列を丸ごと置き換えることで、カーソル位置が文字列全体の先頭に移動してしまい、挙動としては不自然になってしまうためです。

そのため、カーソル位置の履歴も常にストックし、文字列の書き換えと伴に、カーソル位置も一つ前の状態の位置に戻すようにしました。

具体的には、undoボタンを押す前と後の全文字数を把握して、その文字数の差分だけカーソルの位置を移動させる、という方法にしました。 ※ここが結構大変でした。。

コードの全体像

結果、作成したコードは以下のとおりです。

初心者ゆえ、かなりローテクを組み合わせて作っていますので、「もっといい方法がある!」という場合は是非ご指摘ください。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "Undo Redo Test",
      theme: ThemeData.light(),
      home: HomeScreen(),
    );
  }
}

class HomeScreen extends StatefulWidget {
  @override
  _HomeScreenState createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  List _undoTextHistory = List<String>();
  List _redoTextHistory = List<String>();

  TextEditingController _textController = TextEditingController();

  List _undoCursorHistory = List<int>();
  List _redoCursorHistory = List<int>();

  int _undoPreNumber = 0;
  int _undoPostNumber = 0;

  @override
  void initState() {
    super.initState();

    _undoTextHistory = [""];
    _redoTextHistory = [""];

    _undoCursorHistory = [0];
    _redoCursorHistory = [0];
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        actions: <Widget>[
          IconButton(
              icon: Icon(Icons.undo),
              onPressed: (_undoTextHistory.length <= 1)
                  ? null
                  : () {
                      if (_undoTextHistory.length <= 1) {
                      } else {
                        _undoPreNumber = _textController.text.length;
                        _textController.text = "";
                        _textController.text = _undoTextHistory[_undoTextHistory.length - 2];
                        _undoPostNumber = _textController.text.length;

                        _textController.selection = TextSelection.fromPosition(
                            TextPosition(
                                offset: _undoCursorHistory[
                                        _undoCursorHistory.length - 1] -
                                    (_undoPreNumber - _undoPostNumber)));

                        _redoTextHistory.add(_undoTextHistory[_undoTextHistory.length - 1]);
                        _redoCursorHistory.add(_undoCursorHistory[_undoCursorHistory.length - 1]);

                        _undoTextHistory.removeAt(_undoTextHistory.length - 1);
                        _undoCursorHistory.removeAt(_undoCursorHistory.length - 1);

                        setState(() {});
                      }
                    }),
          IconButton(
            icon: Icon(Icons.redo),
            onPressed: (_redoTextHistory.length <= 1)
                ? null
                : () {
                    if (_redoTextHistory.length <= 1) {
                    } else {
                      _textController.text = "";
                      _textController.text = _redoTextHistory[_redoTextHistory.length - 1];

                      _textController.selection = TextSelection.fromPosition(
                          TextPosition(
                              offset: _redoCursorHistory[
                                  _redoCursorHistory.length - 1]));

                      _undoTextHistory.add(_redoTextHistory[_redoTextHistory.length - 1]);
                      _undoCursorHistory.add(_redoCursorHistory[_redoCursorHistory.length - 1]);

                      _redoTextHistory.removeAt(_redoTextHistory.length - 1);
                      _redoCursorHistory.removeAt(_redoCursorHistory.length - 1);

                      setState(() {});
                    }
                  },
          ),
        ],
      ),
      body: Center(
        child: SingleChildScrollView(
          child: TextField(
            maxLines: null,
            controller: _textController,
            keyboardType: TextInputType.multiline,
            onChanged: (value) {

              _undoTextHistory.add(_textController.text);
              _undoCursorHistory.add(_textController.selection.start);
              _redoTextHistory = [""];

              setState(() {});
            },
          ),
        ),
      ),
    );
  }
}

各コードの説明

ここでは、コードの各パーツについて説明します。

※自分のような初心者向けに、細かくコメント説明を入れています。

冒頭の決まり文句部分

import 'package:flutter/material.dart'; //標準のmaterial.dartのみ。特別なインポートはなし

void main() => runApp(MyApp()); // コード序盤の決まり文句。MyAppクラスの呼出し

class MyApp extends StatelessWidget { // コード序盤の決まり文句。MyAppクラスのStatelessWidget(状態変化なし)としての定義
  @override
  Widget build(BuildContext context) { // コード序盤の決まり文句。ウィジェット(アプリ内の要素)を作るbuildメソッドの実行
    return MaterialApp(                // Android風にするためMaterialAppを選択
      title: "Undo Redo Test",         // アプリのタイトル名
      theme: ThemeData.light(),        // アプリ画面は明るいモードを使用
      home: HomeScreen(),              // HomeScreenクラスの呼出し
    );
  }
}

class HomeScreen extends StatefulWidget { // コード序盤の決まり文句。HomeScreenクラスのStatefulWidget(状態変化あり)としての定義
  @override
  _HomeScreenState createState() => _HomeScreenState(); // _HomeScreenStateクラスの呼出し
}

Flutterのコードの書き出し部分の決まり文句です。material.dart以外に特別なパッケージのインポートはありません。

変数(プロパティ)の定義部分

class _HomeScreenState extends State<HomeScreen> {
// ↑コード序盤の決り文句。_HomeScreenStateクラスをStateクラス(パッケージで用意済。新規作成不要)の拡張として定義

  List _undoTextHistory = List<String>();    // undoのために文字列履歴を保管する配列変数を定義
  List _redoTextHistory = List<String>();    // redoのために文字列履歴を保管する配列変数を定義

  TextEditingController _textController = TextEditingController();
  // ↑TextFieldの中身を操作するためのTextEditingControllerクラス(パッケージで用意済。新規作成不要)のインスタンスを作成

  List _undoCursorHistory = List<int>();    // undoのためにカーソル位置の履歴を保管する配列変数を定義
  List _redoCursorHistory = List<int>();    // redoのためにカーソル位置の履歴を保管する配列変数を定義

  int _undoPreNumber = 0;    // undo実行前の全体の文字数を把握するための変数を定義。初期値は0 ※redo用には不要
  int _undoPostNumber = 0;   // undo実行後の全体の文字数を把握するための変数を定義。初期値は0 ※redo用には不要

  @override
  void initState() {    // 初回起動時(ウィジェット生成時)に1回だけ実行されるinitStateメソッド(パッケージで用意済。新規作成不要)の実行
    super.initState();   // 決まり文句

    _undoTextHistory = [""];   // 1つ目のデータは空白。1文字入力後にundoしたときは空白に戻すため
    _redoTextHistory = [""];   // redoの場合はnullにならないよう、ダミー的に1つ目のデータを空白で設定。実際は2つ目のデータからしか使わない

    _undoCursorHistory = [0];   // 1つ目のカーソル位置は0を設定
    _redoCursorHistory = [0];   // 1つ目のカーソル位置は0を設定
  }

  @override
  void dispose() {    // ウィジェットを破棄するdisposeメソッド(パッケージで用意済。新規作成不要)を実行
    _textController.dispose();   // TextEditingControllerを設定した場合、ウィジェットと一緒に破棄することが推奨されているため設定
    super.dispose();    // 決まり文句
  }

ここでは、

  • undo(元に戻す)、redo(やり直す)用の文字列履歴を保管する配列変数を定義
  • undo(元に戻す)、redo(やり直す)時のカーソル位置履歴を保管する配列変数を定義
  • TextFieldの中身を操作するためのTextEditingControllerクラスのインスタンスを作成

などをしています。

変数名やインスタンス名は、_HomeScreenStateクラスの外からは使用できないよう、最初にアンダースコア”_”をつけてあります。

各ウェジェットの配置と機能設定:undoボタン

  @override
  Widget build(BuildContext context) {           // ウィジェットを作るbuildメソッドの定義
    return Scaffold(                             // Scaffoldという予め用意されているウィジェット(上部にバー、右下にボタンがある仕様)を返り値として設定
      appBar: AppBar(                            // 上部のバー(AppBar)を設定
        actions: <Widget>[                 // actions属性でバー右側のボタンを設定
          IconButton(                           // ボタンの設置   
              icon: Icon(Icons.undo),           // ボタンのアイコンとしてundoのマークを選択
              onPressed: (_undoTextHistory.length <= 1)  ? null        // undoのための文字列履歴の配列データが1個以下なら(つまり""だったら)undoボタンを押せない状態する(これ以上戻れないため)
                  : () {                                             // 2個以上だったらundoボタンを押せる状態にする
                      if (_undoTextHistory.length <= 1) {            // もう一度undoのための文字列履歴の配列が1個以下でないか確認し、その場合は特に処理をさせない。
                      } else {                                       // 2個以上だったときの処理を以下に書く

                        _undoPreNumber = _textController.text.length;
                        // ↑undoでTextFieldの文字列を書き換える前の文字数を保存

                        _textController.text = "";
                        // ↑TextFieldの文字列をいったんクリアする。こうしないと、 書き換えても書き換え前の文字列が残ってしまうため

                        _textController.text = _undoTextHistory[_undoTextHistory.length -2];
                       // ↑TextFieldの文字列を、undo用の文字列履歴の最新から1つ前の文字列に置き換える
                       // (配列の番号は0番から始まるため、配列の長さ-2の位置が最新から1つ前のデータ)

                        _undoPostNumber = _textController.text.length; // 書き換え後の文字数を保存

                        _textController.selection = TextSelection.fromPosition(
                            TextPosition(
                                offset: _undoCursorHistory[_undoCursorHistory.length - 1] - (_undoPreNumber - _undoPostNumber)
                                         )); 
                       // ↑TextSelectionクラス(パッケージで用意済。新規作成不要)を使い、undo実行前のカーソル位置から、undoによる文字列書き換え前後の文字数差分を引いた位置にカーソル位置を変更

                        _redoTextHistory.add(_undoTextHistory[ _undoTextHistory.length - 1]);
                       // ↑undo用の最新(書き換え前)の文字列履歴を、redoで使えるようにredo用の文字列履歴に格納

                        _redoCursorHistory.add(_undoCursorHistory[ _undoCursorHistory.length - 1]);
                     // ↑undo用の最新(変更前)のカーソル位置履歴を、redoで使えるようにredo用のカーソル位置履歴に格納

                        _undoTextHistory.removeAt(_undoTextHistory.length - 1);
                     // ↑undo用の最新(書き換え前)の文字列履歴を削除

                        _undoCursorHistory.removeAt(_undoCursorHistory.length - 1);
                     // ↑undo用の最新(変更前)のカーソル位置履歴を削除

                        setState(() {});
                     // ↑undoボタンの有効・無効(null)の条件が変わったら、undoボタンを描き直す必要があるためウィジェットを再描画するsetStateメソッドを実行
                      }
                    }),

undo(元に戻す)用の文字列履歴が1つ以下(配列のデータが1つ以下)だったら、undoボタンのonPressed属性をnullにして、ボタンを押せなくしています。

ただ、念のため文字列履歴が1つ以下でもundoボタンを押せてしまった時に備え、改めて文字列履歴が1つ以下の場合は何も処理をさせないようにしています。

※後述の課題のところでも記載していますが、エミュレーターでやるとundoボタンを連打するとonPressed属性のnull判定が効かずに通過してしまう場合があるので、念のためもう一回判定を入れました。ただ、この現象は実機では起きなかったです。

また、undo(元に戻す)実行後にカーソル位置を前の位置に戻すために、書き換え前後の文字数を把握し、その差分だけカーソル位置を動かすようにしました。

各ウェジェットの配置と機能設定:redoボタン

          IconButton(                                   // ボタンの設置
            icon: Icon(Icons.redo),                     // ボタンのアイコンとしてredoのマークを選択
            onPressed: (_redoTextHistory.length <= 1) ? null  // redoのための文字列履歴の配列データが1個以下なら(つまり""だったら)redoボタンを押せない状態する(これ以上戻れないため)
                : () {                                        // 2個以上だったらredoボタンを押せる状態にする
                    if (_redoTextHistory.length <= 1) {       // もう一度redoのための文字列履歴の配列が1個以下でないか確認し、その場合は特に処理をさせない。
                    } else {                                  // 2個以上だったときの処理を以下に書く

                      _textController.text = "";
                      // ↑TextFieldの文字列をいったんクリアする。こうしないと、 書き換えても書き換え前の文字列が残ってしまうため

                      _textController.text = _redoTextHistory[_redoTextHistory.length - 1];
                      // ↑TextFieldの文字列を、redo用の文字列履歴の最新の文字列に置き換える
                      // (配列の番号は0番から始まるため、配列の長さ-1の位置が最新のデータ)

                      _textController.selection = TextSelection.fromPosition(
                          TextPosition(
                              offset: _redoCursorHistory[_redoCursorHistory.length - 1]
                                      ));
                      // ↑TextSelectionクラス(パッケージで用意済。新規作成不要)を使い、カーソル位置を、redo用のカーソル位置履歴の最新データ(=undo実行前のカーソル位置)に変更

                      _undoTextHistory.add(_redoTextHistory[_redoTextHistory.length - 1]);
                      // ↑redo用の最新(書き換え前)の文字列履歴をundo用の文字列履歴に格納

                      _undoCursorHistory.add(_redoCursorHistory[_redoCursorHistory.length - 1]);
                      // ↑redo用の最新(変更前)のカーソル位置履歴をundo用のカーソル位置履歴に格納

                      _redoTextHistory.removeAt(_redoTextHistory.length - 1);
                      // ↑redo用の最新(書き換え前)の文字列履歴を削除

                      _redoCursorHistory.removeAt(_redoCursorHistory.length - 1);
                     // ↑redo用の最新(変更前)のカーソル位置履歴を削除

                      setState(() {});
                     // ↑redoボタンの有効・無効(null)の条件が変わったら、redoボタンを描き直す必要があるためウィジェットを再描画するsetStateメソッドを実行

                    }
                  },
          ),
        ],
      ),

基本的に実装方法はundoボタンと同じになります。

ただ、カーソルの位置は、単純にundo(元に戻す)実行前の位置に戻せばよく、それはredo(やり直し)用のカーソル位置履歴の配列変数(_redoCursorHistory)に保存済なので、undoのときのように書き換え前後の文字数差分を取得する必要はありません。

各ウェジェットの配置と機能設定:TextField

      body: Center(                          // body属性で本体部分のウィジェットを設定。中央寄せで表示するためCenterクラスを使用
        child: SingleChildScrollView(        // TextFiledを使う時の決り文句。入力時にキーボードが出てきても上にズレてくれて画面はみ出しのエラーにならない
          child: TextField(                  // TextFieldクラス(ウィジェット)の設置
            maxLines: null,                  // 無限に改行可能にする
            controller: _textController,     // コントローラーを設置。これでTextFieldの入力文字列を取得したり、強制的に表示文字列を書き換えたりできる

            keyboardType: TextInputType.multiline,  // 改行可能にするため、キーボードのリターンキーを改行キー表示にする

            onChanged: (value) {
            // ↑onChanged属性を設定し、TextFieldに変化があったら発動する処理を書く。ただし、コントローラーで強制的に書き換えた場合は発動しない

              _undoTextHistory.add(_textController.text);
            // ↑TextField上で何か入力したら、その入力後の文字列全体をundoで使えるようにundo用の文字列履歴に格納

              _undoCursorHistory.add(_textController.selection.start);
            // ↑TextField上で何か入力したら、その入力後のカーソル位置をundoで使えるようにundo用のカーソル位置履歴に格納

              _redoTextHistory = [""];
            // ↑TextField上で何か入力したら、常にredo用の文字列履歴は初期化(redoできなくする)

              setState(() {});
            // ↑TextField上で何か入力したら、undoボタンを有効化・redoボタンを無効化表示するために、ウィジェットを描き直す

            },
          ),
        ),
      ),
    );
  }
}

やっとメインのテキスト入力部分(TextField)の設置です。

TextFieldクラスに用意されているonChanged属性を使い、何かキー入力があったら、後でundo(元に戻す)ボタンで戻れるように、その都度、TextField全体の文字列履歴を配列変数に格納していきます。

カーソル位置の情報も同様にその都度、配列変数に格納していきます。

試してわかったのですが、undo・redoボタンのところでコントローラーを使って強制的にTextFieldの文字列を書き換えても、onChanged属性は発動しないみたいです。

最後に、文字入力をしたらundo・redoボタンの有効化・無効化の状態が変わるので、setStateメソッドを回すことを忘れないようにします(Flutterでは、ウィジェットは基本的に一度描いたらそのままなので、変化させたいときはsetStateメソッドを回す必要があります)。

なお、コントローラーで文字列を書き換えた場合は、setStateメソッドは回さなくてもTextFieldに自動的に反映されました。

残った課題

①undo・redoの回数上限が未設定

メモリも考えると、undoとredoのストック回数に上限があったほうが良いと思いますが、まだ設定できていません。

一定数以上のデータを格納したら、順次古いものを捨てる処理を入れたほうが良いと思っています。

②確定入力の単位でundo・redoができない

本当は、かな漢字変換の確定など、確定入力の単位でundo・redoを実行させたいのですが、できませんでした。

暫定入力時点と、確定入力時点を区別して把握できないかと思い、onEditingComplete属性でできないか色々検証したのですが、これはテキスト入力フォーム全体を確定させた時にしか発動できず、確定入力の単位では情報を取得できませんでした。

そのため、RawKeyboardListenerクラスあたりを使って、もっと細かいキー入力単位での情報取得をして何とかするしかないのかなぁ、などと考えています。

③エミューレーターでundo・redoボタンを連打するとバグが発生する

前述した_undoTextHistoryと_redoTextHistoryの配列が1データ以下だったらundo・redoを発動しない、という判断を念のため2回続けてやっているのは、このバグのせいになります。

理由は不明ですが、エミューレーター上でundo・redoボタンを高速で連打すると、なぜか文字列の入力が行われ、onChanged属性が発動してしまいました。

これによりredo用の履歴がクリアされてしまい、配列データが1個以下になっているにもかかわらず、redoボタンが押せてしまう(恐らくredoボタンを無効化する判断をする前に、redoの処理に入れてしまう)という現象が起きました。

Androidの実機で試したところ、こうした現象は起こらないのですが、ちょっと気持ち悪いところです。

関連するか分からないのですが、今回やってみて、textEditingControllerを使ってTextFieldの文字列を強制的に書き換えても

  • onChanged属性は発動しない
  • setStateをしなくても文字列は書き直される

ことが分かったのですが、この辺の理解が今ひとつだからなのかもしれません。

最後に

以上、1つずつの文字入力をundo(元に戻す)、redo(やり直す)するコードは作成することができました。

引き続き研究して、より実現したい状態に近づけられたら、また公開したいと思います!

最後までお読みいただき、ありがとうございました。

コメント

タイトルとURLをコピーしました