こんにちは、働くC#プログラマーのさんさめです。
DataGridを用いたアプリケーションを
開発しているとき、
次のようなことを言われたことがあるでしょうか?
複数セルにまとめて入力
できるようにして欲しいんだけど…
こう言いたくなる気持ちも分かりますが、
MVVMを心がけているアプリケーションで
これを実現しようとするとなかなか大変です。
ビューとビューモデルの
密結合は避けたい…!
そこで本記事では、
これを実現する2つの方法について解説します。
- 選択行をビューモデルにBindingで教えて
ビューモデル層で実現する - DataGrid自体に仕込みを入れて、
ビュー層で実現する
どちらの方法でも
複数セルにまとめて入力を実現できるので、
好みの実装を取り入れていただければ。
ビューモデル層で実現する方法
複数セルまとめて入力と言われると
どこから手を付けたものやら
と思ってしまうかもしれません。
でも、条件とすべき実装を分解すれば、
技術的な課題となるポイントが見えてきます。
ビューモデル層で実現するためには、
以下のように分解ができます。
- 条件
- DataGridにBindingしている要素の
プロパティ変更が通知されたとき
- DataGridにBindingしている要素の
- 実装
- 選択されている全ての要素の
同じプロパティに変更後の値を入れる
- 選択されている全ての要素の
DataGridにBindingしているリストの、
各要素のプロパティ変更を見張るという
条件部分の実装を掘り下げていきましょう。
リストの各子要素のプロパティ変更を見張る
リストの各子要素のPropertyChangedを見張るには、
初期化時のイベント購読や、
新要素追加時のイベント購読、
そして要素削除時のイベント購読解除が必要です。
すなわち、次のようなコードになります。
public ObservableCollection<Person> People { get; }
= new ObservableCollection<Person>();
public MainWindowViewModel()
{
foreach (var person in People)
{
person.PropertyChanged += OnPersonPropertyChanged;
}
People.CollectionChanged += OnPeopleCollectionChanged;
}
private void OnPeopleCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (var person in e.NewItems.OfType<Person>())
{
person.PropertyChanged += OnPersonPropertyChanged;
}
break;
case NotifyCollectionChangedAction.Remove:
foreach (var person in e.OldItems.OfType<Person>())
{
person.PropertyChanged -= OnPersonPropertyChanged;
}
break;
case NotifyCollectionChangedAction.Reset:
foreach (var person in e.OldItems.OfType<Person>())
{
person.PropertyChanged -= OnPersonPropertyChanged;
}
break;
default:
break;
}
}
この時点で面倒な印象がすごい
PersonのクラスにMainWindowViewModelの実体を
渡してしまえば楽ができますが、
Personという要素のクラスが、
「MainWindowViewModelに使われていることを前提に
設計されている」というのは、
少々密結合が過ぎますね。
個人開発など、小さいアプリケーションだったら
割り切っていいかもしれませんが…。
ともあれ、
これでプロパティの変更監視ができました。
選択されている全ての要素の同じプロパティに変更後の値を入れる
次にすべきことは、
DataGridで選択されている要素を
ViewModel上で把握することです。
これができれば、
あとは各選択要素に、
変更後の値を入れてしまえばよいということになります。
DataGrid上で選択されている複数要素を
ViewModel側で知る方法は、
DataGridのSelectedItemsをどんな時でも取得する方法
で紹介しています。
さて、上記の方法を用いたことにして、
実際のOnPersonPropertyChangedの中身は次のようになります。
private bool isMultiEditing = false;
private void OnPersonPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (isMultiEditing) { return; }
if (e.PropertyName == nameof(Person.Name))
{
if (sender is Person person)
{
isMultiEditing = true;
var newName = person.Name;
foreach (var otherPerson in SelectedPeople)
{
otherPerson.Name = newName;
}
isMultiEditing = false;
}
}
}
isMultiEditingプロパティは、
何度もこのメソッドの中に入ってこないように
用意しているプロパティです。
これが無いと選択している要素のNameプロパティに
値を入れるたびにまたこのメソッドが呼ばれてしまいます。
また、分かりやすさを優先して、
「プロパティ名からどのプロパティに
値を入れるべきなのか」をチェックしていますが、
汎用性を求めるなら、リフレクションを用いるべきでしょう。
(本記事では割愛します。
このコードでもごり押しで全パターン書けば
実現できないことはないので)
これでビューモデル層で実現する方法は完了です。
では最後に、動作確認をしてみましょう。
ビューモデル層で実現する方法の動作確認
次のようなxamlを用意します。
<Window
x:Class="DataGridMultiEditSample.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:local="clr-namespace:DataGridMultiEditSample"
xmlns:ts="clr-namespace:threesharkWpfLibrary.Behaviors;assembly=threesharkWpfLibrary"
Title="MainWindow"
Width="800"
Height="450">
<Window.DataContext>
<local:MainWindowViewModel />
</Window.DataContext>
<DockPanel>
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="選択中の要素" />
<ListBox ItemsSource="{Binding SelectedPeople}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<DataGrid
CanUserAddRows="False"
CanUserDeleteRows="False"
CanUserResizeRows="False"
ItemsSource="{Binding People}"
RowHeaderWidth="0"
SelectionUnit="CellOrRowHeader">
<i:Interaction.Behaviors>
<ts:DataGridSelectedItemsProxyBehavior SelectedItemsProxy="{Binding SelectedItems}" />
</i:Interaction.Behaviors>
</DataGrid>
</DockPanel>
</Window>
実行結果は次のようになります。
マウスドラッグで複数セル選択後、
そのまま「aaa」と入力して確定すると、
ちゃんと2つのセルに「aaa」という
文字が入っていることが確認できます。
ややコード量が多いですが、
無事にやりたいことが実現できました。
ビュー層で実現する方法
つぎに、ビュー層だけで実現する方法の紹介です。
こちらの方法では、DataGridを継承した
自前のDataGridクラスを作成し、
あるメソッドをオーバーライドすることで
実現します。
そのメソッドとは、
OnExecutedCommitEditです。
これは、セルの変更が確定され、
編集モードが解除されたときに呼ばれます。
残念ながら類似のイベントは無く、
Behaviorなどで代替することはできません。
どうしてもDataGridクラスを継承する必要があります。
さて、このOnExecutedCommitEdit内に
「同じ列内で他に
選択しているセルがあったら書き込む」
という処理を実装します。
具体的には次のようになります。
public class MyGrid : DataGrid
{
bool isMultiEditing = false;
protected override void OnExecutedCommitEdit(ExecutedRoutedEventArgs e)
{
base.OnExecutedCommitEdit(e);
if (!isMultiEditing
&& e.OriginalSource is DataGridCell editingCell
&& this.SelectedCells.Count >= 2)
{
isMultiEditing = true;
var content = editingCell.Column.OnCopyingCellClipboardContent(editingCell.DataContext);
foreach (var cell in this.SelectedCells.Where(x => x.IsValid && x.Column.Equals(editingCell.Column)))
{
cell.Column.OnPastingCellClipboardContent(cell.Item, content);
}
isMultiEditing = false;
}
}
}
セルに入力された値を取得するには、
引数のOriginalSourceに入っているDataGridCellと、
OnCopyingCellClipboardContentメソッドを使います。
そしてそれを、
SelectedCellsの各セルに反映していきます。
これで複数セルの編集が実現できます。
また、こちらの方法の場合は汎用性があるため、
基本的には一度実装してしまえば、
同じことを何度も実装する必要はありません。
ただし、継承した自作クラスを作らないといけない、
というのが難点です。
とはいえ、DataGridはこの他にも扱いづらい面があるため、
自作クラスを使うという選択肢も充分にあります。
そもそも自作クラスであれば、
DataGridを使う場面に会うたびに
Behaviorを頑張ってつけていく、
といった必要もなくなります。
しかし、そこはチームの方針なども絡むため、
認められない場合は、
前述のビューモデルで実装する方法を
活用することをおススメします。
まとめ
まとめです。
- DataGrid頻出のまとめて入力の方法を紹介
- ビューモデル層で実装する方法は
手間が多いが無難 - ビュー層で実装する方法は
手間は少ないが受け入れられにくい
最後までお読みいただきありがとうございました。
コメント