C# のコードでデータ バインディング (4)

C# のコードでデータ バインディング (1) の中で Binding Source の条件について書きましたが、
INotifyPropertyChanged でも DependencyObject でもないオブジェクトの場合は少し難解な動作をします。
というのも、プロパティ変更通知ができないはずのオブジェクトに対して、
内部で PropertyDescriptor を経由して双方向のバインディングができるように試みるためです。

POCO である Person0 クラスを利用したコードを以下に示します。

using System;
using System.ComponentModel;
using System.Windows.Controls;
using System.Windows.Data;
namespace BindingConsole
{
class Program
{
[STAThread]
static void Main(string[] args)
{
// Binding Source (Any object).
var person = new Person0 { Id = 123, Name = "Taro" };
// Binding Target (DependencyObject).
var textBox = new TextBox { Text = "Default" };
Console.WriteLine(textBox.Text); // Default
// Binds target to source.
var binding = new Binding(nameof(person.Name)) { Source = person, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged };
textBox.SetBinding(TextBox.TextProperty, binding);
Console.WriteLine(textBox.Text); // Taro
// Changes source value.
// Notification does not work in usual property setting.
//person.Name = "Jiro";
var properties = TypeDescriptor.GetProperties(person);
properties[nameof(person.Name)].SetValue(person, "Jiro");
Console.WriteLine(textBox.Text); // Jiro
// Changes target value.
textBox.Text = "Saburo";
Console.WriteLine(person.Name); // Saburo
}
}
}
view raw Program.cs hosted with ❤ by GitHub

Binding Source 側を変更しようとするとき、通常の

person.Name = "Jiro";

というコードでは変更は通知されません。
変更を通知するには PropertyDescriptor.SetValue メソッドを使います。
なお、変更時のイベントハンドラーを登録するには PropertyDescriptor.AddValueChanged メソッドを使います。

Binding オブジェクトの内部では OneTime バインディングの場合を除き、
PropertyDescriptor.SetValue メソッドおよび PropertyDescriptor.AddValueChanged メソッドを利用して、
プロパティ変更通知を試みます。

このように、オブジェクトへの操作は PropertyDescriptor を経由しなければならなくなるため、
WPF などのアプリケーションでこの方法でコードを書くのは大変です。
また、アプリケーションのコードで person への参照を削除しても、
PropertyDescriptor の中で person への参照を持ち続けるため、メモリリークの原因となるようです。
したがって、Binding Source として使うオブジェクトには INotifyPropertyChanged インターフェイスを実装させるのがよいでしょう。

補足として、PropertyDescriptor の挙動を確認するためのコードを以下に示します。

using System;
using System.ComponentModel;
namespace BindingConsole
{
class Program
{
[STAThread]
static void Main(string[] args)
{
var person = new Person0 { Id = 123, Name = "Taro" };
var properties = TypeDescriptor.GetProperties(person);
var nameProp = properties[nameof(person.Name)];
nameProp.AddValueChanged(person, (o, e) => Console.WriteLine(person.Name));
nameProp.SetValue(person, "Jiro"); // 出力: Jiro
}
}
}
view raw Program.cs hosted with ❤ by GitHub

 

前回: C# のコードでデータ バインディング (3)

作成したサンプル
BindingConsole (GitHub)
PocoBindingWpf (GitHub)

バージョン情報
C# 6.0
.NET Framework 4.5

参照
【WPF】変更通知をサポートしないCLRプロパティの変更通知
【WPF】PropertyDescriptorを取得 / 利用する。

XAML マークアップ拡張を自作する

例えば XAML でデータ バインディングをするときに、

Text="{Binding Message}"

のような記法を用いますが、これは XAML マークアップ拡張と呼ばれ、
Binding クラスが MarkupExtension クラスを継承しているためにこの形式の記述ができるようになっています。

今回は例として、構成ファイル (.config) の <appSettings> の値を読み込む
XAML マークアップ拡張クラスを自作してみたいと思います。
先にコードを示します。

using System;
using System.Configuration;
using System.Windows;
using System.Windows.Markup;
namespace MarkupExWpf
{
[MarkupExtensionReturnType(typeof(object))]
public class AppSettingsExtension : MarkupExtension
{
public string Key { get; set; }
public AppSettingsExtension()
{
}
public AppSettingsExtension(string key)
{
Key = key;
}
public override object ProvideValue(IServiceProvider serviceProvider)
{
if (serviceProvider == null) throw new ArgumentNullException(nameof(serviceProvider));
var type = GetTargetPropertyType(serviceProvider);
if (type == null) return DependencyProperty.UnsetValue;
var value = ConfigurationManager.AppSettings[Key];
if (type.IsValueType && value == null) return DependencyProperty.UnsetValue;
return type.IsEnum ?
Enum.Parse(type, value) :
Convert.ChangeType(value, type);
}
static Type GetTargetPropertyType(IServiceProvider serviceProvider)
{
var provider = serviceProvider.GetService(typeof(IProvideValueTarget)) as IProvideValueTarget;
if (provider == null) return null;
var property = provider.TargetProperty as DependencyProperty;
if (property == null) return null;
return property.PropertyType;
}
}
}

マークアップ拡張クラスでは、ProvideValue メソッドを実装して実際に設定する値を返します。
設定先となる依存関係プロパティを取得するには、
ProvideValue メソッドに渡される serviceProvider から IProvideValueTarget を取り出します。

さて、App.config の <appSettings> に設定値を追加して、
MainWindow.xaml 上の UI コントロールのプロパティにそれらの値を設定します。

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
</startup>
<appSettings>
<add key="WindowHeight" value="600" />
<add key="WindowWidth" value="400" />
<add key="ResizeMode" value="NoResize" />
<add key="Message" value="Hello, world." />
<add key="FontSize" value="36" />
</appSettings>
</configuration>
view raw App.config hosted with ❤ by GitHub
<Window x:Class="MarkupExWpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:MarkupExWpf"
Title="Markup Ex" Height="{local:AppSettings WindowHeight}" Width="{local:AppSettings WindowWidth}"
ResizeMode="{local:AppSettings ResizeMode}" FontSize="{local:AppSettings FontSize}">
<Grid>
<TextBlock Text="{local:AppSettings Message}"/>
</Grid>
</Window>
view raw MainWindow.xaml hosted with ❤ by GitHub

クラス名にサフィックスとして Extension を付けておくと、サフィックスを除いた名前で記述できます。
値を設定している部分は、

Height="{local:AppSettings Key=WindowHeight}"

と書いてもよいですが、Key を引数に取るコンストラクターが存在することで、

Height="{local:AppSettings WindowHeight}"

と書けるようになります。
なお、この部分は次のようにも書けます。

<Window>
    <Window.Height>
        <local:AppSettings Key="WindowHeight"/>
    </Window.Height>
</Window>

MarkupExWpf

 

前回: プロパティ変更をタイマーで同期して通知する

作成したサンプル
MarkupExWpf (GitHub)

バージョン情報
C# 6.0
.NET Framework 4.5

参照
MarkupExtension クラス
XAML のマークアップ拡張機能の概要
型コンバーターおよびマークアップ拡張機能で使用できるサービス コンテキスト

プロパティ変更をタイマーで同期して通知する

C# のコードでデータ バインディング (1) で書いた通り、
Binding Source は INotifyPropertyChanged か DependencyObject でなければ
プロパティ変更通知ができないため、永続的に値が反映される OneWay バインディングはできません。

そこで、プロパティ変更通知機能を持たない既存のオブジェクトをタイマーで監視して
プロパティ変更通知を発生させる DynamicSyncProxy<T> クラスというのを作ってみました。

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Dynamic;
using System.Linq;
using System.Reflection;
using System.Threading;
namespace DynamicBindingWpf
{
public static class DynamicSyncProxy
{
public static DynamicSyncProxy<T> ToDynamicSyncProxy<T>(this T target, int intervalInMilliseconds = 1000) =>
new DynamicSyncProxy<T>(target, intervalInMilliseconds);
public static bool IsIndexer(this PropertyInfo property) =>
property.Name == "Item" && property.GetIndexParameters().Length > 0;
}
public class DynamicSyncProxy<T> : DynamicObject, INotifyPropertyChanged
{
T Target;
Dictionary<string, PropertyInfo> Properties;
Dictionary<string, object> PropertyValuesCache;
Timer SyncTimer;
public DynamicSyncProxy(T target, int intervalInMilliseconds = 1000)
{
Target = target;
Properties = Target.GetType().GetProperties()
.Where(p => !p.IsIndexer())
.ToDictionary(p => p.Name);
PropertyValuesCache = Properties.Values
.ToDictionary(p => p.Name, p => p.GetValue(Target));
SyncTimer = new Timer(o => SyncPropertyValues(), null, intervalInMilliseconds, intervalInMilliseconds);
}
public T GetTargetObject() => Target;
#region DynamicObject
public override IEnumerable<string> GetDynamicMemberNames()
{
return Properties.Keys;
}
public override bool TryGetMember(GetMemberBinder binder, out object result)
{
result = Properties[binder.Name].GetValue(Target);
return true;
}
public override bool TrySetMember(SetMemberBinder binder, object value)
{
Properties[binder.Name].SetValue(Target, value);
return true;
}
#endregion
#region INotifyPropertyChanged
public event PropertyChangedEventHandler PropertyChanged;
public void NotifyPropertyChanged(string propertyName) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
#endregion
#region Sync
void SyncPropertyValues()
{
foreach (var name in Properties.Keys)
SyncPropertyValue(name);
}
void SyncPropertyValue(string propertyName)
{
var oldValue = PropertyValuesCache[propertyName];
var newValue = Properties[propertyName].GetValue(Target);
if (Equals(oldValue, newValue)) return;
PropertyValuesCache[propertyName] = newValue;
NotifyPropertyChanged(propertyName);
}
#endregion
}
}
view raw DynamicSyncProxy.cs hosted with ❤ by GitHub

DynamicObject クラスを継承し、既存のオブジェクトを内部に持ちます。
そのオブジェクトのプロパティへのアクセスを透過するプロキシとして機能します。

さて、プロパティ変更通知機能を持たない TextModel クラスを用意して、
DynamicSyncProxy<T> を介してデータ バインディングを設定します (dynamic 型として扱います)。

using System;
namespace DynamicBindingWpf
{
public class AppModel
{
public dynamic TextModel { get; } = new TextModel().ToDynamicSyncProxy(300);
}
public class TextModel
{
string _Input;
public string Input
{
get { return _Input; }
set
{
_Input = value;
Output = _Input?.ToUpper();
}
}
public string Output { get; private set; }
}
}
view raw AppModel.cs hosted with ❤ by GitHub
<Window x:Class="DynamicBindingWpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:DynamicBindingWpf"
Title="Dynamic Binding" Height="400" Width="600" FontSize="36">
<Window.DataContext>
<local:AppModel/>
</Window.DataContext>
<Grid>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBox Text="{Binding TextModel.Input, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding TextModel.Output}" TextWrapping="Wrap" Grid.Row="1"/>
</Grid>
</Window>
view raw MainWindow.xaml hosted with ❤ by GitHub

この例では 0.3 秒ごとに値が同期されます。

DynamicBinding

 

前回: ExpandoObject を使ったデータ バインディング
次回: XAML マークアップ拡張を自作する

作成したサンプル
DynamicBindingWpf (GitHub)

バージョン情報
C# 6.0
.NET Framework 4.5

参照
データ バインディングの概要
C# のコードでデータ バインディング (1)

ExpandoObject を使ったデータ バインディング

C# のコードでデータ バインディング (1) では、
INotifyPropertyChanged インターフェイスを実装した Person1 クラスに
プロパティを定義してデータ バインディングを構成しました。

ところで、dynamic 型として扱うことで実行時に任意のプロパティを追加できる ExpandoObject クラスというものがあり、
しかも INotifyPropertyChanged インターフェイスを実装しています。
したがって ExpandoObject クラスを使えば、
プロパティを事前に定義しなくてもデータ バインディングができるようになります。

次のコードは、C# のコードでデータ バインディング (1) の TwoWay バインディングのコードを少し書き換えて、
ExpandoObject クラスを使うようにしたものです。
Person1 クラスを使ったときと同様にデータ バインディングが動作します。

using System;
using System.Dynamic;
using System.Windows.Controls;
using System.Windows.Data;
namespace BindingConsole
{
class Program
{
[STAThread]
static void Main(string[] args)
{
// Binding Source.
dynamic person = new ExpandoObject();
person.Id = 123;
person.Name = "Taro";
// Binding Target must be FrameworkElement.
var textBox = new TextBox { Text = "Default" };
Console.WriteLine(textBox.Text); // Default
// Binds target to source.
var binding = new Binding(nameof(person.Name)) { Source = person, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged };
textBox.SetBinding(TextBox.TextProperty, binding);
Console.WriteLine(textBox.Text); // Taro
// Changes source value.
person.Name = "Jiro";
Console.WriteLine(textBox.Text); // Jiro
// Changes target value.
textBox.Text = "Saburo";
Console.WriteLine(person.Name); // Saburo
}
}
}
view raw Program.cs hosted with ❤ by GitHub

 

さて、ExpandoObject は、XAML で DataContext に設定した場合でもプロパティにアクセスできます。
次に示す XAML では、空の ExpandoObject オブジェクトに対して、
UI コントロールから Input プロパティと Output プロパティに直接バインドしています。

<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ExpandoBindingWpf"
xmlns:Dynamic="clr-namespace:System.Dynamic;assembly=System.Core"
x:Class="ExpandoBindingWpf.MainWindow"
Title="Expando Binding" Height="400" Width="600" FontSize="36">
<Window.DataContext>
<Dynamic:ExpandoObject/>
</Window.DataContext>
<Grid>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBox Text="{Binding Input, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Output}" TextWrapping="Wrap" Grid.Row="1"/>
</Grid>
</Window>
view raw MainWindow.xaml hosted with ❤ by GitHub
using System;
using System.ComponentModel;
using System.Windows;
namespace ExpandoBindingWpf
{
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
dynamic model = DataContext;
((INotifyPropertyChanged)model).PropertyChanged += (o, e) =>
{
if (e.PropertyName == "Input")
model.Output = model.Input?.ToUpper();
};
}
}
}
view raw MainWindow.xaml.cs hosted with ❤ by GitHub

コード側で、Input を大文字に変換して Output に反映させています。

ExpandoBinding

 

前回: C# のコードでデータ バインディング (3)
次回: プロパティ変更をタイマーで同期して通知する

作成したサンプル
BindingConsole (GitHub)
ExpandoBindingWpf (GitHub)

バージョン情報
C# 6.0
.NET Framework 4.5

参照
データ バインディングの概要
C# のコードでデータ バインディング (1)

C# のコードでデータ バインディング (3)

前回の C# のコードでデータ バインディング (2) では、インデクサーの変更通知について説明しました。
今回は、コレクションのデータ バインディングについてです。

コレクションのデータ バインディングをするには、
まず当然の条件として Binding Source がコレクションであることが必要です。
これがさらに INotifyCollectionChanged インターフェイスを実装していると、
コレクション内のアイテムの増減や変更を通知することができ、永続的な OneWay バインディングができます。
INotifyCollectionChanged インターフェイスを実装していない場合は OneTime バインディングとなります。

.NET Framework には、INotifyCollectionChanged インターフェイスを実装したコレクションとして
ObservableCollection<T> クラスが用意されています。
通常はこの ObservableCollection<T> を使えば十分でしょう。

また、プロパティのときの Binding クラスに相当する機能 (Source と Target をつなぐ役割) は、
たいていは ItemsControl などのコレクションを扱うコントロールに組み込まれています。

次のコードで、ItemsControl と ObservableCollection<T> を使った
データ バインディングの動作を確認できます。

using System;
using System.Collections.ObjectModel;
using System.Windows.Controls;
namespace BindingConsole
{
class Program
{
[STAThread]
static void Main(string[] args)
{
var taro = new Person1 { Id = 123, Name = "Taro" };
var jiro = new Person1 { Id = 234, Name = "Jiro" };
// Binding Source (collection).
var people = new ObservableCollection<Person1> { taro };
// Binding Target.
var itemsControl = new ItemsControl();
Console.WriteLine(itemsControl.Items.Count); // 0
// MEMO: Binding Source のオブジェクト自体が変更されないのであれば、
// ItemsSource プロパティのデータ バインディングは必須ではありません。
// したがって、ここでは ItemsSource プロパティに直接代入しています。
itemsControl.ItemsSource = people;
Console.WriteLine(itemsControl.Items.Count); // 1
// Changes source collection.
people.Add(jiro);
Console.WriteLine(itemsControl.Items.Count); // 2
people.RemoveAt(0);
Console.WriteLine(itemsControl.Items.Count); // 1
// MEMO: ItemsSource に値を設定している場合、Items を直接変更しようとすると例外が発生します。
//itemsControl.Items.Add(jiro);
}
}
}
view raw Program.cs hosted with ❤ by GitHub

ItemsControl.ItemsSource プロパティに Binding Source を設定するだけです。
ItemsControl.Items プロパティは ItemsSource に設定されたコレクションのビューとして機能するもので、
UI はこの Items プロパティの状態を元に構築されます。
Items プロパティを通して、フィルターやソートなどを設定できます。

 

前回: C# のコードでデータ バインディング (2)
次回: ExpandoObject を使ったデータ バインディング

作成したサンプル
BindingConsole (GitHub)

バージョン情報
C# 6.0
.NET Framework 4.5

参照
データ バインディングの概要