WPFのGrid内に動的に要素を配置したいと思ったことはありませんか?
WPFのGridは、基本的な使い方では静的になります。RowDefinitionsプロパティとColumnDefinitionsプロパティはバインディングできず、XAMLからは静的にしか設定することができません。そのため、実行時まで行や列の数が定まらないようなものはそのままでは作ることができません。
これを動的に作れるように作っていきましょう。
没案:ItemsContorl in ItemsControl
Gridは2次元に要素を並べるものなので、配列の配列を作って2次元のデータを用意し、例えば、行ごとのItemsControlをさらにItemsControlで縦に並べればそもそもGridなんていらないのでは?と思うかもしれません。
実際はそれでも何とかなりますが、行や列数が増えるとめちゃめちゃ重たくなります。そもそもItemsControlがそこそこ重たいコントロールなので、それを何百個も作ったら重くなるのは想像に難くないでしょう。
今回の記事では、そのような構造は取らず、あくまでもGridコントロールを動的に制御するというアプローチで2次元の要素を並べるようにしていきたいと考えています。
ViewModelの準備
まずは、セル一つ分を表現するViewModelを準備していきます。
public class CellViewModel : ViewModel
{
#region Text
public string Text
{
get;
set {
if(field == value)
return;
field = value;
RaisePropertyChanged();
}
} = "";
#endregion
#region Row
public int Row
{
get;
set {
if(field == value)
return;
field = value;
RaisePropertyChanged();
}
}
#endregion
#region Column
public int Column
{
get;
set {
if(field == value)
return;
field = value;
RaisePropertyChanged();
}
}
#endregion
}
シンプルなViewModelです。Row / Columnは、このセルを配置するRow / Columnのインデックスを示したものです。Textは今回の本質とは関係ありませんが、何も表示されないと味気ないので表示内容のテキストを保持するものになります。
余談ですが、このコードはC#14のfieldキーワードを使用することで、バッキングフィールドに直接アクセスするようにしてコードを最小化しています。まさに変更通知プロパティのための機能ですね。
続いて、MainWindowViewModelです。少しややこしく見えるかもしれませんが、行数 / 列数を示すRows / ColumnsプロパティをCellsの内容変更に自動で追従するようにしたためのコードが多いだけでそんなに変なことはやっていませんので、よく読んでいただければ内容がすぐにわかるでしょう。
public class MainWindowViewModel : ViewModel
{
public MainWindowViewModel()
{
_Cells = new ObservableCollection<CellViewModel>();
Cells = new ReadOnlyObservableCollection<CellViewModel>(_Cells);
((INotifyCollectionChanged)Cells).CollectionChanged += Cells_CollectionChanged;
}
public void Initialize()
{
_Cells.Add(new CellViewModel() { Row = 0, Column = 0, Text = "A1" });
_Cells.Add(new CellViewModel() { Row = 1, Column = 1, Text = "B2" });
_Cells.Add(new CellViewModel() { Row = 2, Column = 0, Text = "A3" });
}
readonly ObservableCollection<CellViewModel> _Cells;
public ReadOnlyObservableCollection<CellViewModel> Cells { get; }
#region Cells_CollectionChanged
private void Cells_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
switch(e.Action) {
case NotifyCollectionChangedAction.Add:
HandleAdd(e.NewItems!);
break;
case NotifyCollectionChangedAction.Remove:
HandleRemove(e.OldItems!);
break;
case NotifyCollectionChangedAction.Replace:
HandleRemove(e.OldItems!);
HandleAdd(e.NewItems!);
break;
case NotifyCollectionChangedAction.Reset:
RecalculateRows();
RecalculateColumns();
break;
}
}
private void HandleAdd(IList newItems)
{
foreach(CellViewModel cell in newItems) {
if(cell.Row + 1 > Rows)
Rows = cell.Row + 1;
if(cell.Column + 1 > Columns)
Columns = cell.Column + 1;
}
}
private void HandleRemove(IList oldItems)
{
bool needRecalcRows = false;
bool needRecalcColumns = false;
foreach(CellViewModel cell in oldItems) {
if(cell.Row + 1 == Rows)
needRecalcRows = true;
if(cell.Column + 1 == Columns)
needRecalcColumns = true;
if(needRecalcRows && needRecalcColumns)
break;
}
if(needRecalcRows)
RecalculateRows();
if(needRecalcColumns)
RecalculateColumns();
}
private void RecalculateRows()
{
int maxRow = 0;
foreach(var cell in Cells) {
if(cell.Row + 1 > maxRow)
maxRow = cell.Row + 1;
}
Rows = maxRow;
}
private void RecalculateColumns()
{
int maxColumn = 0;
foreach(var cell in Cells) {
if(cell.Column + 1 > maxColumn)
maxColumn = cell.Column + 1;
}
Columns = maxColumn;
}
#endregion
#region Rows
public int Rows
{
get;
set {
if(field == value)
return;
field = value;
RaisePropertyChanged();
}
}
#endregion
#region Columns
public int Columns
{
get;
set {
if(field == value)
return;
field = value;
RaisePropertyChanged();
}
}
#endregion
}
Behaviorの準備
さて、ここで用意したViewModelからの情報をGridに伝搬してあげたいのですが、最初に説明した通りRowDefinitionsプロパティとColumnDefinitionsプロパティは直接バインディングすることができずXAML経由で扱えません。そのような場合はそう、Behaviorですね。
public class DynamicGridBehavior : Behavior<Grid>
{
protected override void OnAttached()
{
OnRowChanged(Rows);
OnColumnChanged(Columns);
base.OnAttached();
}
#region Columns
public int Columns
{
get { return (int)GetValue(ColumnsProperty); }
set { SetValue(ColumnsProperty, value); }
}
public static readonly DependencyProperty ColumnsProperty =
DependencyProperty.Register(nameof(Columns), typeof(int), typeof(DynamicGridBehavior), new PropertyMetadata(default(int), (sender, e) => {
if(sender is DynamicGridBehavior me && e.NewValue is int newValue)
me.OnColumnChanged(newValue);
}));
void OnColumnChanged(int newValue)
{
if(AssociatedObject == null)
return;
if(AssociatedObject.ColumnDefinitions.Count > newValue) {
AssociatedObject.ColumnDefinitions.RemoveRange(newValue, AssociatedObject.ColumnDefinitions.Count - newValue);
} else if(AssociatedObject.ColumnDefinitions.Count < newValue) {
for(int i = AssociatedObject.ColumnDefinitions.Count; i < newValue; i++)
AssociatedObject.ColumnDefinitions.Add(new ColumnDefinition() { Width = ColumnWidth });
}
}
#endregion
#region ColumnWidth
public GridLength ColumnWidth
{
get { return (GridLength)GetValue(ColumnWidthProperty); }
set { SetValue(ColumnWidthProperty, value); }
}
public static readonly DependencyProperty ColumnWidthProperty =
DependencyProperty.Register(nameof(ColumnWidth), typeof(GridLength), typeof(DynamicGridBehavior), new PropertyMetadata(default(GridLength), (sender, e) => {
if(sender is DynamicGridBehavior me && e.NewValue is GridLength newValue)
me.OnColumnWidthChanged(newValue);
}));
void OnColumnWidthChanged(GridLength newValue)
{
if(AssociatedObject == null)
return;
foreach(var column in AssociatedObject.ColumnDefinitions)
column.Width = newValue;
}
#endregion
#region Rows
public int Rows
{
get { return (int)GetValue(RowsProperty); }
set { SetValue(RowsProperty, value); }
}
public static readonly DependencyProperty RowsProperty =
DependencyProperty.Register(nameof(Rows), typeof(int), typeof(DynamicGridBehavior), new PropertyMetadata(default(int), (sender, e) => {
if(sender is DynamicGridBehavior me && e.NewValue is int newValue)
me.OnRowChanged(newValue);
}));
void OnRowChanged(int newValue)
{
if(AssociatedObject == null)
return;
if(AssociatedObject.RowDefinitions.Count > newValue) {
AssociatedObject.RowDefinitions.RemoveRange(newValue, AssociatedObject.RowDefinitions.Count - newValue);
} else if(AssociatedObject.RowDefinitions.Count < newValue) {
for(int i = AssociatedObject.RowDefinitions.Count; i < newValue; i++)
AssociatedObject.RowDefinitions.Add(new RowDefinition() { Height = RowHeight });
}
}
#endregion
#region RowHeight
public GridLength RowHeight
{
get { return (GridLength)GetValue(RowHeightProperty); }
set { SetValue(RowHeightProperty, value); }
}
public static readonly DependencyProperty RowHeightProperty =
DependencyProperty.Register(nameof(RowHeight), typeof(GridLength), typeof(DynamicGridBehavior), new PropertyMetadata(default(GridLength), (sender, e) => {
if(sender is DynamicGridBehavior me && e.NewValue is GridLength newValue)
me.OnRowHeightChanged(newValue);
}));
void OnRowHeightChanged(GridLength newValue)
{
if(AssociatedObject == null)
return;
foreach(var column in AssociatedObject.RowDefinitions)
column.Height = newValue;
}
#endregion
}
Columns / ColumnWidth / Rows / RowHeightプロパティを実装しました。こちらもまた依存関係プロパティのせいでコード量が多くなっていますが、やっていることとしては、Columns / Rowsプロパティで列数 / 行数を設定して、それに対応するようにGrid.ColumnDefinitionsとGrid.RowDefinitionsの中身を調整しているにすぎません。新たに行や列を作る場合は、その行幅 / 列幅を共通で設定できるようColumnWidth / RowHeightプロパティも用意しています。
XAMLの実装
さて、最後にXAMLを実装していきます。
<Window x:Class="GridTest.Views.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:l="http://schemas.livet-mvvm.net/2011/wpf"
xmlns:v="clr-namespace:GridTest.Views"
xmlns:vb="clr-namespace:GridTest.Views.Behaviors"
xmlns:vm="clr-namespace:GridTest.ViewModels"
Title="MainWindow" Width="640" Height="480">
<Window.DataContext>
<vm:MainWindowViewModel />
</Window.DataContext>
<i:Interaction.Triggers>
<!-- Dispose method is called, when Window closing. -->
<i:EventTrigger EventName="Closed">
<l:DataContextDisposeAction />
</i:EventTrigger>
<i:EventTrigger EventName="ContentRendered">
<l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="Initialize" />
</i:EventTrigger>
<!-- If you make user choose 'OK or Cancel' closing Window, then please use Window Close cancel Behavior. -->
</i:Interaction.Triggers>
<ItemsControl ItemsSource="{Binding Cells}" >
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Grid>
<i:Interaction.Behaviors>
<vb:DynamicGridBehavior Rows="{Binding Rows}" Columns="{Binding Columns}" RowHeight="50" ColumnWidth="200" />
</i:Interaction.Behaviors>
</Grid>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:CellViewModel}">
<Border BorderBrush="Gray" BorderThickness="1" >
<TextBlock Text="{Binding Text}" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Grid.Row" Value="{Binding Row}" />
<Setter Property="Grid.Column" Value="{Binding Column}" />
</Style>
</ItemsControl.ItemContainerStyle>
</ItemsControl>
</Window>
一つひとつ説明していきます。
まず、動的な要素をパネルにマッピングする場合はItemsControlを使います。具体的なパネルはItemsControl.ItemsPanelで指定しますので、ここでGridを指定してあげています。Gridの内容を動的に設定する必要があるので、先ほど作ったDynamicGridBehaviorを設定しています。
DataTemplateは特段説明する必要はないと思います。なお、静的に配置する場合は各要素にGrid.Row / Grid.Column添付プロパティを追加することになりますが、ここでは指定しません。なぜならば、BorderはさらにContentPresenterでラッピングされるため、Borderで指定したところで正しく反映されないのです。
ContentPresenterにどうやって添付プロパティを設定するかと言えば、ItemContainerStyleになります。これによってGrid.Row / Grid.Column添付プロパティを設定することができるわけですね。
これによって、このように動的に正しく配置されたGridができました。
まとめ
ポイントをまとめると以下の通りとなります。
- Grid.RowDefinitions / Grid.ColumnDefinitionsはバインディングできないため、Behaviorを作ってC#のコードから操作をする
- パネルに動的に要素を配置するためにItemsControlを使用する
- ItemsControlを使った場合、Grid直下の要素はContentPresenterになるため、ItemContainerStyleを使用してGrid.Row / Grid.Column添付プロパティを設定する

0 件のコメント:
コメントを投稿