MVVMでUndo, Redoの実装 後編

前回はUndo機能を持つCaretakerを作成しました。

hollyhockberry.hatenablog.com

今回はRedo機能を追加します。

Redo

UndoのスタックはPropertyChanged発火のタイミングで変更前のプロパティから作成したMementoを順にスタックしていきました。

Redoはその逆で、Undo実行時に現在のプロパティから作成したMementoをスタックします。
図にするとこんなイメージ。

まずは混乱しないようにUndo用のスタックを MementosからUndoMementosにリネームした上でRedo用のStackも追加します。

private Stack<Memento> UndoMementos { get; } = new Stack<Memento>();
private Stack<Memento> RedoMementos { get; } = new Stack<Memento>();

Redoの実装はUndoの実装と似ていますが、対象のオブジェクトとプロパティをハンドラの引数ではなくRedoスタックのMementoから取得する点が異なります。

public void Redo() {
  try
  {
      Active = false;
      var memento = RedoMementos.Pop();
      var target = memento.Target;
      var name = memento.PropertyName;
      UndoMementos.Push(new Memento(target, name, Values[target][name]));
      Values[target][name] = memento.Data;

      var property = target.GetType().GetProperty(name);
      property?.SetValue(target, memento.Data);
  }
  finally
  {
      Active = true;
  }
}

また、Undo, Redo以外の手段でプロパティが変更された場合はRedoを破棄する必要があります。 PropertyChangedハンドラ内で

RedoMementos.Clear();

すればOKです。

最後に、Command用にCanRedoプロパティを追加します。
更新はCanUndoプロパティと同様、スタックへのPush, Popが発生したタイミングで行います。

CanRedo = RedoMementos.Count > 0;

さらに、Viewに対してCommandも提供します。こちらもUndoとほぼ同じですね。

class RedoCommand : ICommand {
  public event EventHandler? CanExecuteChanged;

  public RedoCommand() {
    Caretaker.Instance.PropertyChanged += (s, e)
        => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
   }
   public bool CanExecute(object? parameter) => Caretaker.Instance.CanRedo;
   public void Execute(object? parameter) => Caretaker.Instance.Redo();
}

Undoと同様にButtonへCommandをBindして動作確認してみましょう。

ちゃんと動作してます。成功ですかね。
当初の目論見通りViewModelはそのままに機能を追加することができました。

ここまでの実装は下記のコミットで参照できます。

github.com

再検討

・・とはいえなんか回りくどい実装な気がしてきました。
特にプロパティの値をバックアップしてるあたり。

ViewModelをOriginatorと捉えて実装してきましたが、ModelをOriginatorと考えるとViewModelでCaretakerの処理を実装するのも悪くない気がしてきました。
ちょっと検討してみましょう。

Caretaker

CaratekerはViewModelの登録とプロパティのバッファリングを削除し、代わりにMementoのAdd機能を追加します。

public void Add(Memento memento) {
  if (Active) {
      UndoMementos.Push(memento);
      RedoMementos.Clear();
      CanUndo = UndoMementos.Count > 0;
      CanRedo = RedoMementos.Count > 0;
  }
}

UndoとRedoも変更します。
Valuesがなくなったのでプロパティの値をGetValue()で実現しています。

public void Undo() {
  if (!CanUndo) return;

  try {
    Active = false;
    var memento = UndoMementos.Pop();
    var target = memento.Target;
    var name = memento.PropertyName;
    var property = target.GetType().GetProperty(name);
    RedoMementos.Push(new Memento(target, name, property?.GetValue(target)!));
    property?.SetValue(target, memento.Data);
  }
  finally
  {
    CanUndo = UndoMementos.Count > 0;
    CanRedo = RedoMementos.Count > 0;
    Active = true;
  }
}

Redoは大体同じなので省略します)

ViewModel

CaretakerがPropertyChangedで実装していたMemento作成を実装します。
プロパティの変更はSetPropertyで行っているのでそこに差し込みます。
プロパティ変更前に実行できるのでスッキリした実装になりました。

protected void SetProperty<T>(ref T target, T value, [CallerMemberName] string? propertyName = null) {
  if (!Equals(target, value)) {
    Caretaker.Instance.Add(new Memento(this, propertyName!, target!));
    target = value;
    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
  }
}

Command

CommandはViewModelから提供します。
UndoもRedoも似た実装になるので基本クラスを準備しておきます。
コンストラクタにActionとFuncを渡して段取りしてくれるクラスです。

class Command : ICommand {
  private readonly Action<object?> execute;
  private readonly Func<object?, bool>? canExecute;

  public event EventHandler? CanExecuteChanged;
  public Command(Action<object?> execute, Func<object?, bool>? canExecute = null)
  {
    this.execute = execute;
    this.canExecute = canExecute;
  }

  public void RaiseCanExecute() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
  public bool CanExecute(object? parameter) => canExecute is null ? true : canExecute(parameter);
  public void Execute(object? parameter) => execute(parameter);
}

UndoCommandとRedoCommandをViewModelに実装します。結局Caretakerへの移譲です。

private Command? _UndoCommand;
public ICommand UndoCommand => _UndoCommand ??= new Command(
  (_) => Caretaker.Instance.Undo(),
  (_) => Caretaker.Instance.CanUndo);

private Command? _RedoCommand;
public ICommand RedoCommand => _RedoCommand ??= new Command(
  (_) => Caretaker.Instance.Redo(),
  (_) => Caretaker.Instance.CanRedo);

CommandのCanExecuteの更新をRaiseしてあげる必要があるのでCaretakerのPropertyChangedを購読します。

Caretaker.Instance.PropertyChanged += (s, e) => {
  switch (e.PropertyName ?? "")
  {
    case "CanUndo":
      (UndoCommand as Command)?.RaiseCanExecute();
      break;
    case "CanRedo":
      (RedoCommand as Command)?.RaiseCanExecute();
      break;
    default:
      break;
  }
};

これで構造をリファクタリングできました。いかがでしょうか?

まとめ

ViewModel外に機能を実装する方法、ViewModelに機能を実装する方法、どちらの方法でも機能を満たすことはできました。

好みで言えばViewModelに実装する方が分かりやすくて好きですが、ViewModelが大量にあった場合は機能をアペンドできる方が嬉しいですね。
どちらで実装するのかは設計次第で柔軟に選択すればいいのかなと思います。

一番いいのは有料の高機能FWを使うことだったりするのかもしれません(使ったことないからわからないけど)

最終的なプロジェクトはこちらで参照できます。

github.com