はじめに
テキストエディタや、ExcelやWordなどのアプリケーションに採用されてる「元に戻す」、「やり直し」操作の機能、便利ですよね。
MVVMアプリケーションでは、バインディング通知の処理を一部書き換えることにより、Undo(元に戻す)、やり直し(Redo)の機能を組み込むことが比較的簡単にできたりします。
当記事では、Infragistics社製のUndo/Redo フレームワークを利用して、Undo(元に戻す)、やり直し(Redo)の実装事例をご紹介したいと思います。
アプリケーションサンプル
入力系のコントロールを配置し、画面操作の履歴を保持、元に戻す。やり直すボタンをクリックすると入力内容の復元などを行っています。
サンプルコード
GitHub
https://github.com/furugen/wpf-samples-undoredo/tree/master/wpf-samples-undoredo
<Window xmlns:ig="http://schemas.infragistics.com/xaml" x:Class="wpf_samples_undoredo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:wpf_samples_undoredo"
xmlns:igWPF="http://schemas.infragistics.com/xaml/wpf"
mc:Ignorable="d"
Title="MainWindow" Width="800">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100" x:Name="leftMenuArea"></ColumnDefinition>
<ColumnDefinition Width="10"></ColumnDefinition>
<ColumnDefinition Width="80*"></ColumnDefinition>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="10"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
</Grid.RowDefinitions>
<ListView x:Name="listView" Grid.Row="0" Grid.Column="0" SelectedIndex="{Binding SelectedIndexOfListView}">
<ListViewItem>
<TextBlock>AAA</TextBlock>
</ListViewItem>
<ListViewItem>
<TextBlock>BBB</TextBlock>
</ListViewItem>
<ListViewItem>
<TextBlock>CCC</TextBlock>
</ListViewItem>
</ListView>
<GridSplitter Grid.Column="1" Width="10" HorizontalAlignment="Center" />
<Grid Grid.Row="0" Grid.Column="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"></ColumnDefinition>
<ColumnDefinition Width="Auto"></ColumnDefinition>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<StackPanel Orientation="Horizontal" Margin="10">
<TextBlock Width="100">コンボボックス</TextBlock>
<ComboBox x:Name="comboBox" Width="200" SelectedIndex="{Binding SelectedIndexOfComboBox}">
<ComboBoxItem>C1</ComboBoxItem>
<ComboBoxItem>C2</ComboBoxItem>
<ComboBoxItem>C3</ComboBoxItem>
</ComboBox>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="10">
<TextBlock Width="100">テキストボックス</TextBlock>
<TextBox x:Name="textBox" Width="200" Text="{Binding TextBoxValue}">
</TextBox>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="10">
<TextBlock Width="100">デートピッカー</TextBlock>
<DatePicker Width="200" x:Name="datePicker" SelectedDate="{Binding DatePickerData}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="10">
<TextBlock Width="100">ラジオボタン</TextBlock>
<RadioButton x:Name="radioOnAttached" IsChecked="{Binding IsCheckedOfRadioOnAttached}" GroupName="TestRaadio" Content="添付あり"/>
<RadioButton x:Name="radioNonAttached" IsChecked="{Binding IsCheckedOfRadioNonAttached}" GroupName="TestRaadio" Content="添付なし"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="10">
<TextBlock Width="100">チェックボックス</TextBlock>
<CheckBox x:Name="checkBox" Content="完了済" IsChecked="{Binding IsCheckedOfCheckBox}"/>
</StackPanel>
</StackPanel>
</Grid>
<Grid Grid.Row="2" Grid.ColumnSpan="3" >
<Grid.ColumnDefinitions>
<ColumnDefinition></ColumnDefinition>
<ColumnDefinition></ColumnDefinition>
</Grid.ColumnDefinitions>
<Grid Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
</Grid.RowDefinitions>
<Button Margin="5" Grid.Row="0">
<ig:Commanding.Command>
<ig:UndoManagerCommandSource CommandType="Undo" EventName="Click" ParameterBinding="{Binding UndoManager}"></ig:UndoManagerCommandSource>
</ig:Commanding.Command>
元に戻す
</Button>
<TextBox Text="{Binding UndoHistory}" AcceptsReturn="True" Grid.Row="1" />
</Grid>
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition>
</Grid.RowDefinitions>
<Button Margin="5" Grid.Row="0">先に進む
<ig:Commanding.Command>
<ig:UndoManagerCommandSource CommandType="Redo" EventName="Click" ParameterBinding="{Binding UndoManager}"></ig:UndoManagerCommandSource>
</ig:Commanding.Command>
</Button>
<TextBox Text="{Binding RedoHistory}" AcceptsReturn="True" Grid.Row="1" />
</Grid>
</Grid>
</Grid>
</Window>
public class MainWindowViewModel : INotifyPropertyChanged
{
public UndoManager UndoManager { get; set; }
public MainWindowViewModel()
{
// 編集履歴を記録する対象に当ViewModelを指定します。
this.UndoManager = new UndoManager();
this.UndoManager.RegisterReference(this);
}
public void ShowHistory()
{
StringBuilder sb = new StringBuilder();
foreach (var historyItem in this.UndoManager.UndoHistory)
{
sb.AppendLine(historyItem.ShortDescription);
}
this.UndoHistory = sb.ToString();
sb.Clear();
foreach (var historyItem in this.UndoManager.RedoHistory)
{
sb.AppendLine(historyItem.ShortDescription);
}
this.RedoHistory = sb.ToString();
}
#region 各プロパティ
private string textBoxValue = "";
public string TextBoxValue
{
get
{
return this.textBoxValue;
}
set
{
this.SetProperty(ref textBoxValue, value);
}
}
private DateTime? datePickerData;
public DateTime? DatePickerData
{
get
{
return this.datePickerData;
}
set
{
this.SetProperty(ref datePickerData, value);
}
}
private string redoHistory = "";
public string RedoHistory
{
get
{
return this.redoHistory;
}
set
{
if(redoHistory != value)
{
redoHistory = value;
NotifyPropertyChanged();
}
}
}
private string undoHistory = "";
public string UndoHistory
{
get
{
return this.undoHistory;
}
set
{
if (undoHistory != value)
{
undoHistory = value;
NotifyPropertyChanged();
}
}
}
private int selectedIndexOfListView;
public int SelectedIndexOfListView
{
get
{
return this.selectedIndexOfListView;
}
set
{
this.SetProperty(ref selectedIndexOfListView, value);
}
}
private int selectedIndexOfComboBox;
public int SelectedIndexOfComboBox
{
get
{
return this.selectedIndexOfComboBox;
}
set
{
this.SetProperty(ref selectedIndexOfComboBox, value);
}
}
private bool? isCheckedOfCheckBox = true;
public bool? IsCheckedOfCheckBox
{
get
{
return this.isCheckedOfCheckBox;
}
set
{
this.SetProperty(ref isCheckedOfCheckBox, value);
}
}
private bool? isCheckedOfRadioOnAttached = false;
public bool? IsCheckedOfRadioOnAttached
{
get
{
return this.isCheckedOfRadioOnAttached;
}
set
{
this.SetProperty(ref isCheckedOfRadioOnAttached, value);
}
}
private bool? isCheckedOfRadioNonAttached = true;
public bool? IsCheckedOfRadioNonAttached
{
get
{
return this.isCheckedOfRadioNonAttached;
}
set
{
this.SetProperty(ref isCheckedOfRadioNonAttached, value);
}
}
#endregion
protected bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(storage, value))
return false;
// UndoManagerに変更値を登録
UndoManager.FromReference(this).AddPropertyChange(this, propertyName, storage, value);
// UndoRedoの履歴を表示
this.ShowHistory();
storage = value;
this.NotifyPropertyChanged(propertyName);
return true;
}
public event PropertyChangedEventHandler PropertyChanged;
private void NotifyPropertyChanged([CallerMemberName] String propertyName = "")
{
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
}
サンプルコードの解説
ソースコードが長いですが、プロパティの宣言によるものが多いです。
UndoMangagerを利用するうえでのポイントを紹介します。
対象のViewModelを登録
Undo/Redoの対象となるViewModelを登録します。
csharp
this.UndoManager.RegisterReference(this);
編集履歴を登録
Undoに記憶させる履歴はPropetyChangeの通知時を行う際に、UndoManagerにも履歴を登録します。
共通的なメソッドにて、UndoManagerに登録することで各プロパティは意識せずに履歴管理を行うことができます。
protected bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(storage, value))
return false;
// UndoManagerに変更値を登録
UndoManager.FromReference(this).AddPropertyChange(this, propertyName, storage, value);
// UndoRedoの履歴を表示
this.ShowHistory();
storage = value;
this.NotifyPropertyChanged(propertyName);
return true;
}
例えば、テキストボックスにバインドしているTextBoxValueは、SetPropertyメソッドをコールし、バインド通知および履歴登録をしています。
private string textBoxValue = "";
public string TextBoxValue
{
get
{
return this.textBoxValue;
}
set
{
this.SetProperty(ref textBoxValue, value);
}
}
元に戻す、やり直しのコマンド
Undo/Redo用のコマンドが用意されているのでUndoManagerをパラメータで指定してあげればよしなに管理してくれます。
<Button >
<ig:Commanding.Command>
<ig:UndoManagerCommandSource CommandType="Undo" EventName="Click" ParameterBinding="{Binding UndoManager}"></ig:UndoManagerCommandSource>
</ig:Commanding.Command>
元に戻す
</Button>
<Button Margin="5" Grid.Row="0">やり直し
<ig:Commanding.Command>
<ig:UndoManagerCommandSource CommandType="Redo" EventName="Click" ParameterBinding="{Binding UndoManager}"></ig:UndoManagerCommandSource>
</ig:Commanding.Command>
</Button>
まとめ
Undo/Redoの管理を実現してくれるUndo/Redo フレームワークの紹介でしたが如何でしょうか。
今回、ご紹介しきれませんでしたが、一連の操作の流れをまとめて履歴登録したり、記録の一時停止/再開する機能なども提供しているコンポーネントなので痒い所にもよく手が届きますよ。
入力画面でUndo/Redoを実装したい場合はぜひご検討くださいませ!