かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

コントロールのDependencyPropertyじゃないプロパティとViewModelのプロパティをBindingする

先日の私の書いたViewのコントロールの非DependencyPropertyのプロパティの値をViewModelのプロパティに設定する方法に対してid:wave1008さんが早速レスポンスを返してくれました。

そちらからの引用になりますが、私の方法の問題点というか普通のBindingと比べて機能不足な点を指摘してくれています。

一方、カスタムコントロール方式だと、DependencyPropertyを一つ一つ愚直に作ることになるが、正真正銘のデータバインドを利用することができる。つまり、2wayデータバインドもできるし、Converterもかませることができる。コントロール作るの面倒だが、そこがメリットかな。

確かに、先日のアプローチは純粋にView側のプロパティの値をViewModel側にコピーしてるだけの超劣化版Bindingもいいところみたいな感じなので、今度は本物のBindingができるものにチャレンジです。

アイデアとしては、仲介役となるDependencyPropertyをもつTriggerActionを作成して、そいつとコントロールのプロパティを同期してやるという感じです。早速TriggerActionを作ってみました。

namespace DataBindTest
{
    using System;
    using System.Reflection;
    using System.Windows;
    using System.Windows.Interactivity;

    /// <summary>
    /// DependencyPropertyではないプロパティへの疑似的なバインドをサポートするためのアクション
    /// </summary>
    public class DependencyPropertyProxyAction : TriggerAction<DependencyObject>
    {
        /// <summary>
        /// コントロール側からの値の設定中かどうかのフラグ
        /// </summary>
        private bool isSetting;

        /// <summary>
        /// コントロールのプロパティ
        /// </summary>
        private PropertyInfo propertyInfo;

        public static DependencyProperty ValueProperty = DependencyProperty.Register(
            "Value",
            typeof(object),
            typeof(DependencyPropertyProxyAction),
            new PropertyMetadata(
                (sender, e) =>
                {
                    ((DependencyPropertyProxyAction)sender).ValueChanged(e);
                }));

        public static DependencyProperty PropertyNameProperty = DependencyProperty.Register(
            "PropertyName",
            typeof(string),
            typeof(DependencyPropertyProxyAction),
            new PropertyMetadata(
                (sender, e) =>
                {
                    ((DependencyPropertyProxyAction)sender).UpdatePropertyInfo();
                }));

        /// <summary>
        /// 依存プロパティではないプロパティへの仲介役のためのプロパティ
        /// </summary>
        public object Value
        {
            get
            {
                return this.GetValue(ValueProperty);
            }

            set
            {
                this.SetValue(ValueProperty, value);
            }
        }

        /// <summary>
        /// コントロールのプロパティ名
        /// </summary>
        public string PropertyName
        {
            get
            {
                return (string)this.GetValue(PropertyNameProperty);
            }

            set
            {
                this.SetValue(PropertyNameProperty, value);
            }
        }

        protected override void OnAttached()
        {
            base.OnAttached();
            this.UpdatePropertyInfo();
        }

        private void UpdatePropertyInfo()
        {
            if (this.AssociatedObject == null)
            {
                return;
            }
            if (string.IsNullOrEmpty(this.PropertyName))
            {
                return;
            }
            this.propertyInfo = this.AssociatedObject.GetType().GetProperty(this.PropertyName);
        }

        protected override void Invoke(object parameter)
        {
            if (this.AssociatedObject == null)
            {
                return;
            }

            // コントロールからの値の設定中
            this.isSetting = true;
            try
            {
                // コントロールから値を取得してValueプロパティへ設定
                this.Value = GetTargetValue();
            }
            finally
            {
                this.isSetting = false;
            }

        }

        /// <summary>
        /// コントロールへ値を設定する。
        /// </summary>
        /// <param name="value">設定する値</param>
        private void SetTargetValue(object value)
        {
            if (this.propertyInfo == null)
            {
                throw new InvalidOperationException("PropertyNameを指定してください");
            }

            if (!this.propertyInfo.CanWrite)
            {
                throw new InvalidOperationException(this.PropertyName + "は書き込みできません");
            }

            this.propertyInfo.SetValue(this.AssociatedObject, value, null);
        }

        /// <summary>
        /// コントロールから値を取得する
        /// </summary>
        /// <returns></returns>
        protected object GetTargetValue()
        {
            if (this.propertyInfo == null)
            {
                throw new InvalidOperationException("PropertyNameを指定してください");
            }

            if (!this.propertyInfo.CanRead)
            {
                throw new InvalidOperationException(this.PropertyName + "は読み込めません");
            }

            return this.propertyInfo.GetValue(this.AssociatedObject, null);
        }

        private void ValueChanged(DependencyPropertyChangedEventArgs e)
        {
            if (this.AssociatedObject == null)
            {
                return;
            }

            // 現在コントロールからの値の設定中である場合は何もしない。
            if (this.isSetting)
            {
                return;
            }

            // バインドのソースからの値の設定の場合はコントロールへ新しい値を設定する
            this.SetTargetValue(e.NewValue);
        }
    }
}

長いですが、上記で説明したことをコードにおこすとこんな感じになります。ポイントはInvokeからのValueプロパティの更新は、コントロールからの値の設定になるので、そこではコントロールに値の反映処理をしないというところです。逆にViewModelからの値の変更に対してはコントロールに変更後の値を設定するようにしています。

使ってみよう

まずは、ViewModelから作っていきます。前回と同じような感じですが、今回はViewModel側からSelectedTextの値をセットしたいので、その処理を実行するためのCommandを追加しています。

namespace DataBindTest
{
    using Microsoft.Practices.Prism.Commands;
    using Microsoft.Practices.Prism.ViewModel;

    public class MainPageViewModel : NotificationObject
    {
        private string selectedText;

        private DelegateCommand setSelectedTextCommand;

        /// <summary>
        /// 今回は双方向に同期するため、ViewModel側からプロパティを更新するためのコマンドを用意
        /// </summary>
        public DelegateCommand SetSelectedTextCommand
        {
            get
            {
                return this.setSelectedTextCommand = this.setSelectedTextCommand ??
                    new DelegateCommand(
                        () =>
                        {
                            this.SelectedText = "[コマンドから設定したテキスト]";
                        });
            }
        }

        /// <summary>
        /// TextBoxのSelectedTextと同期させるプロパティ
        /// </summary>
        public string SelectedText
        {
            get
            {
                return this.selectedText;
            }

            set
            {
                this.selectedText = value;
                base.RaisePropertyChanged(() => SelectedText);
            }
        }
    }
}

次にViewです。前回と違うのは、ViewModelのCommandに紐づくButtonが追加されたくらいです。

<UserControl 
    x:Class="DataBindTest.MainPage"
    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:DataBindTest"
    mc:Ignorable="d"
    d:DesignHeight="300" 
    d:DesignWidth="400" 
    xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity">
    <UserControl.DataContext>
        <local:MainPageViewModel />
    </UserControl.DataContext>
    <StackPanel x:Name="LayoutRoot" Background="White">
        <TextBox>
            <i:Interaction.Triggers>
                <!-- SelectionChangedイベントをトリガーにして -->
                <i:EventTrigger EventName="SelectionChanged">
                    <!-- 
                    TextBoxのSelectedTextからValueプロパティへ値を設定する。
                    ViewModelのSelectedTextとValueプロパティを双方向Bindingすることで
                    内部でViewModelからのValueプロパティの更新に対してTextBoxのSelectedTextへ
                    値を設定する。
                    -->
                    <local:DependencyPropertyProxyAction PropertyName="SelectedText" Value="{Binding Path=SelectedText, Mode=TwoWay}" />
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </TextBox>
        <!-- ViewModelのSelectedTextプロパティの値確認用 -->
        <TextBlock Text="{Binding Path=SelectedText}" />
        <!-- ViewModelのSelectedTextを書き換える処理を実行するボタン -->
        <Button Content="Set SelectedText" Name="button1" Command="{Binding Path=SetSelectedTextCommand}" />
    </StackPanel>
</UserControl>

動かしてみよう

実行してTextBoxに適当な文字を入力して、適当に選択するとちゃんとTextBoxのSelectedTextとViewModelのSelectedTextが同期とれていることがわかります。

この状態でボタンを押して、ViewModel側からSelectedTextの値を書き換えてみると、ちゃんと選択部分のテキストが置き換わってるのがわかります。

まとめ

ということで、今回は仲介役のDependencyPropertyを持つTriggerActionを用意することで、非DependencyPropertyでもBindingっぽいことをさせることに成功しました。Converterとかも試してないけど使えると思います。
TriggerActionとして部品化しておくと、どんなコントロールに対しても機能を追加できるのが強みですね。ビバ再利用。あと、TriggerActionとして実装しているので、たとえばEventTriggerではなく、TimerTriggerとかに変更すると、イベントの発生ではなく定周期でView側からViewModelにプロパティを設定させることもできます。
そういった需要があるかどうかは謎ですが^^;

プロジェクトのダウンロード

このプロジェクトは以下からダウンロードできます。
非DependencyPropertyなコントロールのプロパティに双方向Bindingをする方法