はじめに
WPF/MVVM で ViewModel にデータバインディングを実装する時、多くの方は次のようなコードを書いていると思います。
private bool _isChecked;
public bool IsChecked
{
get => _isChecked;
set => SetProperty(ref _isChecked, value);
}
そして、こんなふうに感じたことはないでしょうか。
private bool _isChecked;
「このフィールド変数、無ければいいのに」
この変数はプロパティと UI のバインディングのためだけに存在しているのに、自分以外の誰かがここを弄る時、意図しない使われ方をされるかもしれない。
今回は、純粋な WPF だけで ViewModel にフィールド変数を一切書かずにバインディングプロパティを実装する方法を紹介します。
結論: ViewModel にはこう書くだけでよい
public bool IsChecked
{
get => this.GetPropertyData<bool>();
set => this.SetPropertyData(value);
}
これだけで UI とのバインディングが成立します。
どうでしょうか。シンプルではないでしょうか。
仕組みはどうなっているか
実はこの ViewModel は、ある基底クラスを継承しています。
本来プロパティごとに必要だったフィールド変数は、すべて基底クラス側に隠蔽されています。
つまり、ViewModel 側ではフィールド変数を一切宣言せず、
プロパティの get/set だけを書けばよい構造になっています。
では、基底クラスとはどうなっているのか。次の章で解説していきます。
基底クラスの仕組み
INotifyPropertyChanged インターフェースを継承したクラス
まず、基底クラス実装用にファイルを作り、下記のコードを書きます。
/// <summary>
/// バインディングの基底クラス
/// </summary>
public class BindingBase : System.ComponentModel.INotifyPropertyChanged
{
/// <summary>
/// プロパティの変更をUIに通知するイベント
/// </summary>
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// プロパティの変更をUIに通知するためのメソッド
/// </summary>
/// <param name="propertyName">変更が発生したプロパティ名</param>
protected void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));
}
}
おなじみ、INotifyPropertyChanged インターフェースです。
これを継承した BindingBase というクラスを作ります。
フィールド変数の隠蔽
次に書くクラスが重要です。
この BindingBase を継承して ViewModelBase というクラスを作ります。
まず、コンストラクタです。
/// <summary>
/// ViewModelの基底クラス
/// </summary>
public class ViewModelBase : BindingBase
{
/// <summary>
/// プロパティ名とフィールド変数を関連付けして保存するディクショナリ
/// </summary>
private readonly Dictionary<string, PropertyData> _propList;
/// <summary>
/// コンストラクタ
/// </summary>
/// <remarks>
/// 派生クラスのプロパティ名と値を関連付けしてディクショナリに保存。
/// これによりViewModelのフィールド変数を隠蔽します。
/// </remarks>
protected ViewModelBase()
{
this._propList = new Dictionary<string, PropertyData>();
// 派生クラスのプロパティをリフレクションで列挙し、プロパティ名とフィールド変数を関連付けてディクショナリに保存
var properties = this.GetType().GetProperties();// 既定で System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance
foreach (var prop in properties)
{
this._propList[prop.Name] = new PropertyData(prop.PropertyType);
}
}
}
このコンストラクタは、派生 ViewModel の公開プロパティをすべて自動的に検出し、
そのプロパティごとに内部ストレージ(PropertyData)を生成して登録する役割を持っています。これにより、本来 ViewModel 側で必要なフィールド変数を _propList の中に隠蔽します。
フィールド変数を隠蔽するためのストレージクラス
ディクショナリ _propList の値である PropertyData はまさにプロパティのフィールド変数を保持するためのものです。Activator.CreateInstance() により、与えられたプロパティの型に応じて動的に初期値を生成します。
/// <summary>
/// プロパティデータを保持するクラス
/// </summary>
public class PropertyData
{
public object? Data { get; set; }
public PropertyData(Type type)
{
this.Data = type.IsValueType ? Activator.CreateInstance(type) : null;
}
}
隠蔽されたフィールド変数の操作
次に、ViewModel のプロパティが 隠蔽されたフィールド変数(=PropertyData) をどのように読み書きしているのかを説明します。
GetPropertyData の動作
これは プロパティの値を取得するためのメソッドです。
ViewModelBase に下記のコードを書きます。
protected TYPE GetPropertyData<TYPE>([CallerMemberName] string? property = null)
{
if (property != null && this._propList.TryGetValue(property, out var data) && data.Data is TYPE value)
{
return value;
}
return default(TYPE);
}
ポイントは [CallerMemberName] です。これは、引数 property の設定を省略すると呼び出し元のプロパティ名が自動で入ります。これにより、プロパティ名が解決され、_propList から値を取り出しています。
SetPropertyData の動作
こちらは プロパティの値を保存し、UI に変更を通知するメソッドです。
ViewModelBase に下記のコードを書きます。
protected void SetPropertyData<TYPE>(TYPE value, [CallerMemberName] string? property = null)
{
if (property != null)
{
this._propList[property].Data = value;
this.OnPropertyChanged(property);
}
}
ここでもやはりポイントは [CallerMemberName] です。プロパティ名の解決方法は、GetPropertyData と同じですが、こちらはさらに INotifyPropertyChanged の メンバー OnPropertyChanged にもプロパティ名を渡すことで UI に変更を通知しています。
副作用付き SetPropertyData
プロパティ変更時の追加ハンドラーを書きたい場面は多々あると思います。
その場合、下記のように拡張します。
protected void SetPropertyData<TYPE>(TYPE value, Action<TYPE>? action = null, [CallerMemberName] string? property = null)
{
if (property != null)
{
this._propList[property].Data = value;
this.OnPropertyChanged(property);
// 追加処理の実行
action?.Invoke(value);
}
}
SetPropertyData のバリエーション
この SetPropertyData ですが、工夫次第でいろいろとアレンジが可能です。
例えば下記は、値が変わった時だけ更新する というものです。
protected void SetPropertyData<TYPE>(TYPE value, Action<TYPE>? action = null, [CallerMemberName] string? property = null)
{
if (property != null)
{
// 値が前回と異なる時
var old = this._propList[property].Data;
if(!old.Equals(value))
{
this._propList[property].Data = value;
this.OnPropertyChanged(property);
// 追加処理の実行
action?.Invoke(value);
}
}
}
下記は、ハンドラー処理を先に実行して、その結果をみてプロパティ値を更新します。
protected void SetPropertyData<TYPE>(TYPE value, Func<TYPE, bool>? action = null, [CallerMemberName] string? property = null)
{
if(property != null)
{
// ハンドラー処理の結果を見てプロパティを更新
if(action != null && action(value))
{
this._propList[property].Data = value;
this.OnPropertyChanged(property);
}
}
}
下記は、現状の値で UI の更新だけを行う というものです。
これだと UpdatePropertyData のようなメソッド名の方が良いですね。
protected void SetPropertyData<TYPE>([CallerMemberName] string? property = null)
{
// UIに反映
if (property != null)
{
this.OnPropertyChanged(property);
}
}
最後に : 使い方の実例
今回はだいぶ長くなってしまったので、今回紹介した内容だけを使用したサンプルを載せておきます。ボタン押下時の処理 ICommand を使用したバインディングの方法なども次回以降に紹介できればと思います。
internal class MainWindowViewModel : ViewModelBase
{
/// <summary>
/// ウィンドウタイトル
/// </summary>
public string WindowTitle
{
get => this.GetPropertyData<string>();
set => this.SetPropertyData(value);
}
/// <summary>
/// クレジットカード支払いの選択状態
/// </summary>
public bool OptionCredit
{
get => this.GetPropertyData<bool>();
set => this.SetPropertyData(value, this._OptionCreditHandler);
}
/// <summary>
/// クレジットカード支払いの選択時のハンドラー
/// </summary>
private void _OptionCreditHandler(bool value)
{
if (value)
{
this.PayCommandText = "クレジットで支払い";
}
this.PayCommandEnabled = this.OptionCash || this.OptionCredit;
}
/// <summary>
/// 現金支払いの選択状態
/// </summary>
public bool OptionCash
{
get => this.GetPropertyData<bool>();
set => this.SetPropertyData(value, this._OptionCashHandler);
}
/// <summary>
/// 現金支払いの選択時のハンドラー
/// </summary>
private void _OptionCashHandler(bool value)
{
if (value)
{
this.PayCommandText = "現金で支払い";
}
this.PayCommandEnabled = this.OptionCash || this.OptionCredit;
}
/// <summary>
/// コンストラクタ
/// </summary>
public MainWindowViewModel()
{
// 商品購入アプリ
this.WindowTitle = "商品購入アプリ";
}
}
<Window x:Class="WpfApp1.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:WpfApp1"
mc:Ignorable="d"
Title="{Binding WindowTitle}" Height="128" Width="512">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="8"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="お支払い" FontWeight="Bold" VerticalAlignment="Bottom" FontSize="16" Margin="0,32,0,0"/>
<Separator Grid.Row="1"/>
<Grid Grid.Row="2" Height="32" Margin="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<RadioButton IsChecked="{Binding OptionCredit}" Content="クレジットカード" Margin="0,0,16,0" VerticalAlignment="Center"/>
<RadioButton IsChecked="{Binding OptionCash}" Content="現金" Margin="0,0,16,0" VerticalAlignment="Center"/>
</StackPanel>
<Button Grid.Column="1" Content="{Binding PayCommandText}" Width="192" IsEnabled="{Binding PayCommandEnabled}" HorizontalAlignment="Right"/>
</Grid>
</Grid>
</Window>
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
this.DataContext = new MainWindowViewModel();
}
}
/// <summary>
/// ViewModelの基底クラス
/// </summary>
public class ViewModelBase : BindingBase
{
/// <summary>
/// プロパティ名とフィールド変数を関連付けして保存するディクショナリ
/// </summary>
private readonly Dictionary<string, PropertyData> _propList;
/// <summary>
/// コンストラクタ
/// </summary>
/// <remarks>
/// 派生クラスのプロパティ名とフィールド変数を関連付けしてディクショナリに保存。
/// これによりViewModelのフィールド変数を隠蔽します。
/// </remarks>
protected ViewModelBase()
{
// プロパティ名とフィールド変数を関連付けして保存するディクショナリ
this._propList = new Dictionary<string, PropertyData>();
// 派生クラスのプロパティをリフレクションで取得し、プロパティ名とフィールド変数を関連付けてディクショナリに保存
var properties = this.GetType().GetProperties();// 既定で System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance
foreach (var prop in properties)
{
_propList[prop.Name] = new PropertyData(prop.PropertyType);
}
}
protected TYPE GetPropertyData<TYPE>([CallerMemberName] string? property = null)
{
if (property != null && this._propList.TryGetValue(property, out var data) && data.Data is TYPE value)
{
return value;
}
return default(TYPE);
}
protected void SetPropertyData<TYPE>(TYPE value, Action<TYPE>? action = null, [CallerMemberName] string? property = null)
{
// UIに反映
if (property != null)
{
this._propList[property].Data = value;
this.OnPropertyChanged(property);
action?.Invoke(value);
}
}
protected void UpdatePropertyData([CallerMemberName] string? property = null)
{
if (property != null)
{
this.OnPropertyChanged(property);
}
}
}
/// <summary>
/// プロパティデータを保持するクラス
/// </summary>
public class PropertyData
{
public object? Data { get; set; }
public PropertyData(Type type)
{
this.Data = type.IsValueType ? Activator.CreateInstance(type) : null;
}
}
/// <summary>
/// バインディングの基底クラス
/// </summary>
public class BindingBase : System.ComponentModel.INotifyPropertyChanged
{
/// <summary>
/// プロパティの変更をUIに通知するイベント
/// </summary>
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// プロパティの変更をUIに通知するためのメソッド
/// </summary>
/// <param name="propertyName">変更が発生したプロパティ名</param>
protected void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));
}
}