普段アプリケーションはビューアとかデータコンバータとか単機能のちょっとしたツールしか作らないんですが、
込み入ったアプリケーションをWPFで作る必要が出てきちゃいました。
Undo機能があると便利だなーと思ったんですが意外に定番の方法が見つからない・・
普段なら諦めるところですが、珍しく前向きに取り組んだのでまとめておきます。
Mementoパターン
Undo,Redoに使われるデザインパターンとしてMementoというパターンがあります。
Mementoは「記念品」や「思い出の品」という意味らしいです。
メメントというとクリストファー・ノーラン監督の映画を思い出してしまいますが、単語の意味を知ると「なるほどなー」って合点が行きますね。
Wikipediaで例示されているコードをクラス図にするとこんな感じです。
Originatorは対象のオブジェクト、Mementoは状態オブジェクト、CaretakerはMementoを管理をしているオブジェクトです。
操作者であるmain関数が値のセットとMementoの取得、保存を実行している感じですかね。
MVVMではプロパティの変化を購読できるのでその辺りを利用してMementoの生成と管理をできればViewModelに手を入れずに機能を追加できそうです。
サンプル
まずはUndo, Redoを組み込む先のアプリケーションをでっち上げます。
string, int, bool型のプロパティを持つViewModelを作成します。
Viewはそれぞれのプロパティを操作できるコントロールをStackPanelで配置しています。
class ViewModel : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; private string _Text = "SampleText1"; public string Text { get => _Text; set => SetProperty(ref _Text, value); } private int _Integer = 100; public int Integer { get => _Integer; set => SetProperty(ref _Integer, value); } private bool _Boolean; public bool Boolean { get => _Boolean; set => SetProperty(ref _Boolean, value); } protected void SetProperty<T>(ref T target, T value, [CallerMemberName] string? propertyName = null) { if (!Equals(target, value)) { target = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } }
それ以外のコードは こちらで確認できます。
このアプリケーションにUndo機能を追加していきます。
Caretaker
まずはMementoの定義をしましょう。
ViewModelにはいろんな型があるのでプロパティ名とデータをメンバに用意します。データの操作はリフレクションで行えば良いでしょう。
WikipediaによるとMementoオブジェクトはCaretakerによって変更されてはいけないということなので、プロパティはgetのみとします。
class Memento { public object Target { get; } public string PropertyName { get; } public object Data { get; } public Memento(object target, string propertyName, object data) { Target = target; PropertyName = propertyName; Data = data; } }
ViewModelのプロパティ操作はViewが担うのでそちらに任せるとして、Mementoの管理を行うCaretakerを作成します。
Caretakerに必要な要素は下記の3つです。
- Mementoのコレクション
- ViewModelのプロパティ変化をキャッチしてMemento作成
- Mementoの書き戻し
Mementoのコレクション
Mementoのコレクションは後入れ先出し(LIFO)で操作したのでStackを利用します。
Stack<Memento> Mementos { get; } = new Stack<Memento>();
Memento作成
Mementoを作成するには変更前の値が必要となりますが、INotifyPropertyChanged.OnPropertyChanged()では変更後のプロパティしか取得できません。
ViewModel側で何か工夫するのも癪なので、CaretakerにViewModelを登録した段階でオブジェクトの初期値を取得します。
保存先としてプロパティ名をキーとしたDictionaryを用意します。ViewModelが複数登録されることを想定しているのでプロパティのDictionaryをViewModelのオブジェクトをキーにしたDictionary化します。
Dictionary<object, Dictionary<string, object>> Values { get; } = new Dictionary<object, Dictionary<string, object>>();
初期値の取得はLINQでCanWriteのプロパティを雑にGetValueで実現します。(実際はAttributeとかでやった方が良いんですかね?)
Values[target] = target.GetType() .GetProperties() .Where(p => p.CanWrite) .ToDictionary(p => p.Name, p => p.GetValue(nc)!);
PropertyChangedが発火した時はDictionaryに保存してあるプロパティからMementoを作成し、現在のプロパティをDictionaryに保存しなおします。
target.PropertyChanged += (s, e) => { var t = (object)s!; var n = (string)e.PropertyName!; Mementos.Push(new Memento(t, n, Values[t][n]!)); Values[t][n] = target.GetType().GetProperty(n)?.GetValue(target)!; };
実際はPropertyChangedのハンドラはこのままではうまく行きません。
後述するUndoでプロパティを書き戻した際にもPropertyChangedは発火するのでMementoを作成してしまいおかしなことになってしまいます。
これを防ぐためにUndo実行時はフラグなどでMementoの作成を行わないようにすれば良いでしょう。
Mementoの書き戻し
UndoはPropertyChangedと逆にMementoを取り出してプロパティにセットします。
public void Undo() { try { Active = false; var memento = Mementos.Pop(); Values[memento.Target][memento.PropertyName] = memento.Data; var property = memento.Target.GetType().GetProperty(memento.PropertyName); property?.SetValue(memento.Target, memento.Data); } finally { Active = true; } }
最初にfalseにしているフラグActive
はMementoを保存するか否かを示すフラグです。このフラグがfalseの場合、PropertyChangedが発火してもMementoを保存しない用にハンドラを実装しておきましょう。
また、Commandを提供したいのでUndo可能かを示すプロパティも用意します。 StackにMementoがあればUndo可能なので、StackへのPush,Popが生じたタイミングで更新します
CanUndo = Mementos.Count > 0;
Command
Commandはかなり単純に作成できます。CanExecute
もExecute
もCaretakerへの移譲で完了です。
CanExecuteChangedのコールはCaretakerのProperyChangedで行います。
インスタンスの注入が面倒なのでCaretekerはシングルトンにしちゃいます。
class UndoCommand : ICommand { public event EventHandler? CanExecuteChanged; public UndoCommand() { Caretaker.Instance.PropertyChanged += (s, e) => CanExecuteChanged?.Invoke(this, EventArgs.Empty); } public bool CanExecute(object? parameter) => Caretaker.Instance.CanUndo; public void Execute(object? parameter) => Caretaker.Instance.Undo(); }
ViewModelの登録
ViewModelをMainWindowのDataContextに設定する前にViewModelをCaretakerに登録します。
注入はApp.xamlのコードビハインドで行うのが一般的だと思うのでそれに倣います。
<Application x:Class="undo_sample.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:undo_sample" Startup="Application_Startup"> <Application.Resources> </Application.Resources> </Application>
public partial class App : Application { private void Application_Startup(object sender, StartupEventArgs e) { var vm = new ViewModel(); Caretaker.Instance.Register(vm); new MainWindow { DataContext = vm }.Show(); } }
UndoCommandのBinding
先程作成したCommandをMainWindowにBindします。
ButtonにBindしてみました。
<Button Content="Undo"> <Button.Command> <local:UndoCommand/> </Button.Command> </Button>
完成
以上でUndoが実装されました。
ViewModelは特にUndoのことを意識しないでできたので当初の思惑通りにできました。
続いてRedoですが、長くなってきたので今回はここまで。
今回のプロジェクトは以下のコミットで参照できます。