問題意識
ViewModelからViewへの値の反映、本記事では特に、ViewModelにあるコレクションをViewに反映させることを考えます。例えば、以下のような挙動を実現させることを問題とします。
- ViewModel内のコレクションの各要素がもつ文字列をViewの各TextBoxに反映させ、コレクションの要素が増えると、View上のTextBoxの数も増える
また、タイトルにもある通り、原則、Microsoftの公式から手に入る情報の解釈で、挙動の実現を探っています。本記事では、以下の方針で調査・実装を行います。
- クラス名やメソッド名など、重要なキーワードについてはどこから調べてもよい
- ただし、実装に関しては公式ドキュメントを根拠にした解釈によって行う
というのは、公式ドキュメントから情報を得て、実装できるようになることが私自身の目標としてあるからです。
問題のView
<Window x:Class="WPF_MVVM_20240104.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_MVVM_20240104"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="選択ファイル数"/>
<ComboBox SelectedValuePath="Content" SelectedValue="{Binding Selected}">
<ComboBoxItem Content="1"/>
<ComboBoxItem Content="2"/>
<ComboBoxItem Content="3"/>
<ComboBoxItem Content="4"/>
<ComboBoxItem Content="5"/>
<ComboBoxItem Content="6"/>
<ComboBoxItem Content="7"/>
<ComboBoxItem Content="8"/>
<ComboBoxItem Content="9"/>
<ComboBoxItem Content="10"/>
</ComboBox>
</StackPanel>
<StackPanel>
<TextBlock Text="ファイルを選択"/>
<StackPanel Orientation="Horizontal">
<TextBox Text="{Binding FilePath}" IsReadOnly="True" MinWidth="100"/>
<Button Content="選択"/>
</StackPanel>
</StackPanel>
</StackPanel>
</Window>
問題のViewModel
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
namespace WPF_MVVM_20240104
{
internal class MainWindowViewModel : INotifyPropertyChanged
{
private int _selected = 0;
private string _filePath = "";
public event PropertyChangedEventHandler PropertyChanged;
public int Selected
{
get { return _selected; }
set { _selected = value; }
}
public string FilePath
{
get { return _filePath; }
set
{
_filePath = value;
OnPropertyChanged();
}
}
protected void OnPropertyChanged([CallerMemberName] string name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
}
現状の外観
ViewのデータコンテキストにViewModelを割り当て実行すると、画像のように、ダイアログで選択したもののファイル名がテキストボックスに反映されていることが分かります。
また、コンボボックスでは1~10の値を選択できます(本記事では特に変更は加えません)。
それでは
- ViewModel内のコレクションの各要素がもつ文字列をViewの各TextBoxに反映させ、コレクションの要素が増えると、View上のTextBoxの数も増える
こちらの解決に向けて調査を進めていきます。
調査
公式以外
実をいうと、公式以外の情報源からで、実装の部分までほとんど参考になってしまいます。
まず、公式のWPFドキュメントに、WPFに関するトピックが体系的にまとまっています。
ここに、「データバインディング」に関する項目があり「どうやら、コレクションをバインドすることが重要になりそうだ」ということが分かります。
そこで、外部サイトを調べることにします。ItemTemplate
やDataTemplate
などのキーワードも得られ、実装までわかりやすい説明を得ることができます。
しかし、「公式ドキュメントから調べる」と題している以上、これを根拠とはせずに、同様の実装をできるように調査をさらに進めます。
公式
先ほど挙げた公式ドキュメントから、コレクションをバインドすることに関するヒントを集めてみます。まずは、以下の文章に注目できると思います。
コレクションの挿入または削除によって UI が自動的に更新されるように動的バインドを設定するには、コレクションは
INotifyCollectionChanged
インターフェイスを実装する必要があります。
このことから、
- ViewModel内のコレクションの各要素がもつ文字列をViewの各TextBoxに反映させ、コレクションの要素が増えると、View上のTextBoxの数も増える
という問題の解決としてINotifyCollectionChanged
インターフェースの実装が考えられます。
WPF には、
INotifyCollectionChanged
インターフェイスを公開するデータ コレクションの組み込みの実装であるObservableCollection<T>
クラスが用意されています。
独自のコレクションを実装する前に、
ObservableCollection<T>
または既存のコレクション クラス (List<T>
、Collection<T>
、BindingList<T>
など) のいずれかを使用することを検討してください。
また、これらを見るに、INotifyCollectionChanged
インターフェースを実装する手段として、
-
ObservableCollection<T>
クラスの使用
をすることが推奨されていることがわかります。
しかしながら、どのようにバインドを行うのか、という点で疑問が残ります。
コレクションをどのようにバインドするか
読み方が汚くて申し訳ないですが、少し前に戻ります。すると以下の記述から、バインドをどのように行うかが見えてきます。
この図に示すように、
ItemsControl
をコレクション オブジェクトにバインドするにはItemsControl.ItemsSource
プロパティを使用します。
コレクションのクラスのオブジェクトにバインドするときには、どうやら以下のことをする必要があるようです。
-
ItemsControl
をコレクションにバインドする - そのとき、
ItemsControl.ItemsSource
プロパティを使用してバインドする
では、ItemsControl
とは一体なんでしょうか。さらにページを少し遡ります。
一般的なシナリオでは、
ListBox
、ListView
、TreeView
などのItemsControl
を使用して、データ コレクションを表示します。
ItemsControl
の一例として、ListBox
、ListView
、TreeView
などが用意されていることが伺えます。
以下のページから定義もみてみましょう。
アイテムのコレクションの表現に使用できるコントロールを表します。
少し拍子抜けしてしまうような定義ですね。しかし、コレクションをどのようにバインドするかという問いについては、以下のように説明を考えることができます。
- コレクションをバインドするには、コレクションを表せるコントロール(=
ItemsControl
クラス)のプロパティにバインドする - たとえば、
ListBox
、ListView
、TreeView
のプロパティにバインドできる
コレクションの表現に使用できる、TextBoxの集合
上の箇条書きを踏まえて、改めて当初の課題を振り返ってみます。
- ViewModel内のコレクションの各要素がもつ文字列をViewの各TextBoxに反映させ、コレクションの要素が増えると、View上のTextBoxの数も増える
実のところ、先ほどの調査結果だけでは、この課題を解決することはできません。というのも、ItemsControl
というコントロールは、TextBox
のコレクションそのものではないからです。ListBox
、ListView
、TreeView
についても、もちろんTextBox
のコレクションではありません。
ここで、「コレクションの表現に使用できる、TextBox
の集合」としてのコントロールを新たに調査し、検討する必要があります。そこで、最初の公式ドキュメントに戻って、TextBox
の集合にコレクションを割り当てる方法がないか、再調査を進めます。
これら 2 つの DataTemplates を使用して、結果として得られた UI が、「データ バインディングとは」セクションに示されているものです。 スクリーン ショットからわかるように、DataTemplates を使用すると、データをコントロールに配置できるだけでなく、データの説得力のあるビジュアルを定義できます。
ドキュメントを読み進めると、このような注目すべき記述がありました。「DataTemplates を使用すると、データをコントロールに配置できる」とあります。また、「スクリーン ショットからわかるように」とあります。どのようなスクリーンショットでしょうか。
データ バインディングの例については、データ バインディング デモに関するページの次のアプリ UI を参照してください。ここでは、オークション品目の一覧が表示されています。
なるほど、TextBox
は見当たらないものの、☆マークやオレンジ色の枠がコレクションの要素のように表示されています。このあたりのコントロール配置を応用すれば、TextBox
の集合も実装可能であると考えられます。では、DataTemplatesをどのように実装するか。
データ テンプレートの詳細については、「データ テンプレートの概要 (.NET Framework)」を参照してください。
ということで、ここから、TextBox
の集合がどのように実装されるか調査を進めていきましょう。
データテンプレートを使用するにはどうするか
新たに調査を開始したドキュメントでは、データテンプレートがない場合とある場合を比較し、データテンプレートがどういうものかを説明しています。
解決策は、DataTemplate を定義することです。 そのための 1 つの方法として、
ListBox
のItemTemplate
プロパティを DataTemplate に設定します。 DataTemplate で指定したものが、データ オブジェクトの視覚的な構造になります。
ここからわかるのは、ListBox
を使うときには、
-
ItemTemplate
プロパティをDataTemplateに設定すること - DataTemplateに指定をすること
以上のことを守れば、オブジェクトの視覚的な構造を設定できるということです。
とすれば、DataTemplateにはどのように指定を行えばいいのでしょうか。続きを読みます。
次の DataTemplate はかなり単純です。 各項目を
StackPanel
内の 3 つのTextBlock
要素として表示するように指示しています。
<ListBox Width="400" Margin="10"
ItemsSource="{Binding Source={StaticResource myTodoList}}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding Path=TaskName}" />
<TextBlock Text="{Binding Path=Description}"/>
<TextBlock Text="{Binding Path=Priority}"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
DataTemplateの直下に、指定したい視覚的構造をそのまま記述すればよさそうです。
確かに、TextBlock
3つのStackPanel
の集合として、ListBox
の各要素が定義されています。
では、改めてデータテンプレートの実装方法についてまとめてみます。
-
ListBox
のItemTemplate
プロパティをDataTemplateに設定すること - DataTemplateに任意のコントロールをXAMLで記載すること
いよいよ、TextBox
の各要素にコレクションを割り当てることは不可能ではないと、説明できそうです。
しかし実装に入る前に、あと1点調査をしておくべき部分が考えられます。それは、ListBox
以外にはDataTemplateを設定できないのか、という点です。
ListBox
以外のDataTemplate実装方法
もともと、DataTemplateを実装するには、ItemTemplate
プロパティに指定をすればよいのでした。では、ListBox
以外のコントロールに、ItemTemplate
プロパティはあるのでしょうか。もし存在すれば、同様に実装できそうです。
まずは、ListView
クラス。
ItemTemplate
プロパティが確認されました。
ItemTemplate
各項目を表示するために使用される DataTemplate を取得または設定します。
(継承元ItemsControl
)
ItemsControl
の子要素はすべてItemTemplate
を持っているというのは重要な情報です。ItemsControl
クラスを継承しているすべてのクラスで、ItemTemplate
プロパティによる外観の規定が設定可能ではないだろうか。このような考え思い浮かびます。
もう一度、ItemsControl
クラスを調査しましょう。ここに例示されているソースコードから、興味深いことがわかります。ItemTemplate
プロパティが使われている部分を抜き出して確認しましょう。
機械翻訳が自然な翻訳をしていませんでしたので、以下の記述は、DeepLを使って英語版から日本語訳したものになります。
次の例は、
ItemsControl
が提供するさまざまなスタイリングおよびテンプレート関連のプロパティの使用方法を示しています。
<!--Use the ItemTemplate to set a DataTemplate to define
the visualization of the data objects. This DataTemplate
specifies that each data object appears with the Priority
and TaskName on top of a silver ellipse.-->
<ItemsControl.ItemTemplate>
<DataTemplate>
<DataTemplate.Resources>
<Style TargetType="TextBlock">
<Setter Property="FontSize" Value="18"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
</Style>
</DataTemplate.Resources>
<Grid>
<Ellipse Fill="Silver"/>
<StackPanel>
<TextBlock Margin="3,3,3,0"
Text="{Binding Path=Priority}"/>
<TextBlock Margin="3,0,3,7"
Text="{Binding Path=TaskName}"/>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
コメント文も翻訳してみました。
ItemTemplate を使用して、データ・オブジェクトの視覚化を定義する DataTemplate を設定します。このDataTemplateは、各データオブジェクトが銀色の楕円の上にPriorityとTaskNameとともに表示されることを指定します。
この記述から、ListBox
やListView
のプロパティというより、親クラスであるItemsControl
のプロパティの機能として、データテンプレートによる外観の指定があったのだとわかります。つまり、ItemsControl
を継承しているクラスであれば、先ほど調査した方法で問題なく実装ができるということです。
また、以下の記述も見ておきましょう。
<ItemsControl Margin="10"
ItemsSource="{Binding Source={StaticResource myTodoList}}">
<!--The ItemsControl has no default visual appearance.
Use the Template property to specify a ControlTemplate to define
the appearance of an ItemsControl. The ItemsPresenter uses the specified
ItemsPanelTemplate (see below) to layout the items. If an
ItemsPanelTemplate is not specified, the default is used. (For ItemsControl,
the default is an ItemsPanelTemplate that specifies a StackPanel.-->
ItemsControlには、デフォルトの外観はありません。Template プロパティを使用して ControlTemplate を指定し、ItemsControl の外観を定義します。ItemsPresenterは、指定されたItemsPanelTemplate(後述)を使用してアイテムをレイアウトします。ItemsPanelTemplateが指定されていない場合は、デフォルトが使用さ れます。(ItemsControlの場合、デフォルトはStackPanelを指定するItemsPanelTemplateです。
ItemsControlにデフォルトの外観がないというのも重要な情報です。今回のケースのように、ListBox
やListView
ではなく単に「TextBox
の集合」を目標としている場合には、ゼロから外観を組み立てるItemsControl
の方がよいと考えることもできるからです。
調査のまとめ
これで、実装に必要な調査が概ね完了したと言えます。ここまでの調査で、以下のことがわかりました。(上の記述から一部、日本語の表現を変えています。)
- バインドしたいコレクションは、
ObservableCollection<T>
クラスとして実装する - コレクションをバインドするには、コレクションを表せるコントロール(=
ItemsControl
クラス)のプロパティにバインドする -
ItemTemplate
プロパティにはDataTemplateを設定すること - DataTemplateに要素のコントロールをXAMLで記載すること
これらの調査結果から、当初設定した課題を解決していきます。
実装
ViewModel内のコレクションの各要素がもつ文字列をViewの各TextBoxに反映させ、コレクションの要素が増えると、View上のTextBoxの数も増える
これが当初設定した課題です。また、既に以下のような外観をもっています。
では、現状の実装を踏まえて、順番に実装を追加していきます。
バインドしたいコレクションは、ObservableCollection<T>
クラスとして実装する
ViewModelはファイルパスの情報をもっておきたいので、ObservableCollection<string>
クラスを実装します。
public ObservableCollection<string> Paths { get; set; } = new ObservableCollection<string>();
ItemsControl
クラスのプロパティにバインドする
現在、Viewのファイル選択部分は以下のようになっています。
<StackPanel>
<TextBlock Text="ファイルを選択"/>
<StackPanel Orientation="Horizontal">
<TextBox Text="{Binding FilePath}" IsReadOnly="True" MinWidth="100"/>
<Button Content="選択" Command="{Binding SelectFile}"/>
</StackPanel>
</StackPanel>
ここに、ItemsControl
を実装して、そのプロパティに先ほどのObservableCollection<string>
をバインドします。
<ItemsControl ItemsSource="{Binding Paths}"/>
ItemsControl
のItemTemplate
にDataTemplateを設定する
ItemControl
にさらに実装を加えます。
<ItemsControl ItemsSource="{Binding Paths}">
<ItemsControl.ItemTemplate>
<DataTemplate>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
DataTemplateに要素のコントロールをXAMLで記載する
このDataTemplateに最初のXAMLで記述していた、コントロールを追加します。
これによって、ObservableCollectionの要素と、テンプレートがひとつずつ対応するようになります。
<ItemsControl ItemsSource="{Binding Paths}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="ファイルを選択"/>
<StackPanel Orientation="Horizontal">
<TextBox Text="{Binding .}" IsReadOnly="True" MinWidth="100"/>
<Button Content="選択"/>
</StackPanel>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
検討
現段階で実行すると、以下のような表示になります
この実装で、課題が解決したか検討してみます。このままでは、「コレクションの要素が増えると、View上のTextBoxの数も増える」という部分が解決されていない状況です。
そこで、「ComboBox」で選択された値に応じて、要素の数を変更する処理を加えてみます。
ViewModelに変更を加えます。
public int Selected
{
get { return _selected; }
set
{
_selected = value;
Paths.Clear();
for (int i = 0; i < value; i++)
{
Paths.Add("not selected");
}
}
}
こうすることで、選択した値に応じて、TextBox
の数も変わることが確認できます。
以上で、ViewModel内のコレクションの各要素がもつ文字列をViewの各TextBoxに反映させ、コレクションの要素が増えると、View上のTextBoxの数も増えることが確認できました。
実装後のView
<Window x:Class="WPF_MVVM_20240104.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="選択ファイル数"/>
<ComboBox SelectedValuePath="Content" SelectedValue="{Binding Selected}">
<ComboBoxItem Content="1"/>
<ComboBoxItem Content="2"/>
<ComboBoxItem Content="3"/>
<ComboBoxItem Content="4"/>
<ComboBoxItem Content="5"/>
<ComboBoxItem Content="6"/>
<ComboBoxItem Content="7"/>
<ComboBoxItem Content="8"/>
<ComboBoxItem Content="9"/>
<ComboBoxItem Content="10"/>
</ComboBox>
</StackPanel>
<ItemsControl ItemsSource="{Binding Paths}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="ファイルを選択"/>
<StackPanel Orientation="Horizontal">
<TextBox Text="{Binding .}" IsReadOnly="True" MinWidth="100"/>
<Button Content="選択"/>
</StackPanel>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Window>
実装後のViewModel
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace WPF_MVVM_20240104
{
internal class MainWindowViewModel : INotifyPropertyChanged
{
private int _selected = 0;
private string _filePath = "";
public event PropertyChangedEventHandler PropertyChanged;
public ObservableCollection<string> Paths { get; set; } = new ObservableCollection<string>();
public int Selected
{
get { return _selected; }
set
{
_selected = value;
Paths.Clear();
for (int i = 0; i < value; i++)
{
Paths.Add("not selected");
}
}
}
protected void OnPropertyChanged([CallerMemberName] string name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
}