none
カスタム依存関係プロパティの値検証はどこで実装したらいいのでしょうか? RRS feed

  • 質問

  • VS2010 で WPF + C# のアプリケーション開発をしています.

    カスタムコントロールで最小値と最大値を依存関係プロパティとして追加することを考えています.
    使う側のことを考えて各プロパティは TwoWay をデフォルトとするため,
    次のような初期化をおこなっています.

    public static readonly DependencyProperty MinProperty = DependencyProperty.Register("Min", typeof(double), typeof(CustomControl), new FrameworkPropertyMetadata(default(double), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnMinChanged));
    public static readonly DependencyProperty MaxProperty = DependencyProperty.Register("Max", typeof(double), typeof(CustomControl), new FrameworkPropertyMetadata(default(double), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnMaxChanged));
    

    このとき,
    Min は Max を超えてはならなず,その逆もまた然りということで,
    例えば Min のプロパティ変更イベントを利用して次のようなコードを書きました.

    private static void OnMinChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
    {
        var control = sender as CustomControl;
        if (control != null)
            control.OnMinChanged((double)e.OldValue, (double)e.NewValue);
    }
    
    private void OnMinChanged(double oldValue, double newValue)
    {
        if (Min >= Max)
        {
            Min = oldValue;
        }
    }
    

    ところが,
    このようにプロパティ変更イベントハンドラの中でプロパティを書き換えてしまうと,
    このカスタムコントロールを使う側の ViewModel では
    この変更 Min = oldValue が反映されませんでした.

    おそらくイベントハンドラ内でプロパティ値を変更させるのがまずいとは思うのですが,
    じゃあ一体どこに書けばいいのかと悩んでいます.

    何かいい方法がないでしょうか.

    2014年4月16日 2:17

回答

  • 依然として値の変更を反映させることができていませんが,
    とりあえず異常値を受け取ったユーザコントロール側は

    ・警告を表示する
    ・そのプロパティを使用しているコントロール部分の描画をしない

    という対処をおこなうこととしました.

    皆さんアドバイスありがとうございました.
    • 回答としてマーク Yujiro15 2014年10月16日 0:34
    2014年10月16日 0:33

すべての返信

  • ViewModel にカスタムコントロールのMin・Maxプロパティとバインドするプロパティを用意し、ViewModel 側のプロパティのセッター内で検証させてはいかがでしょうか?

    私の場合、Min・Max で実装したことはありませんが、開始日・終了日を設定する DatePicker を用意した場合、期間の検証はすべて ViewModel 内で行わせています。

    #Viewの状態・整合性を取るために用意するのが ViewModel だと思ってます。

    ひらぽん http://d.hatena.ne.jp/hilapon/


    2014年4月16日 4:10
    モデレータ
  • 確かにそれはひとつの解決策だと思います.
    しかし,今回はカスタムコントロールを作っており,
    ViewModel が登場するのはあくまでもこのカスタムコントロールを使うユーザ側になります.

    できればカスタムコントロール側で Min と Max がひっくり返らないような仕組みを提供して,
    ユーザ側に負担がかからないようにしたいと思っています.

    値検証の話がなかったとしても,
    単純に TwoWay な依存関係プロパティの作り方を知りたいです.
    カスタムコントロール側でのプロパティ値変更がユーザ側の ViewModel まで伝わるにはどうすればいいのでしょうか.

    • 編集済み Yujiro15 2014年4月16日 4:24
    2014年4月16日 4:16
  • 値検証の話がなかったとしても,
    単純に TwoWay な依存関係プロパティの作り方を知りたいです.
    カスタムコントロール側でのプロパティ値変更がユーザ側の ViewModel まで伝わるにはどうすればいいのでしょうか.

    以下で変更が反映されませんか?

    Max="{Binding Max, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"

    試しに実装したら、値検証の実装があるままで、View 側の変更が ViewModel に通知されることが確認できました。


    ひらぽん http://d.hatena.ne.jp/hilapon/



    2014年4月16日 5:36
    モデレータ
  • カスタムコントロール側のコードを次のようにしています.

    public static readonly DependencyProperty MinProperty = DependencyProperty.Register("Min", typeof(double), typeof(CustomControl), new FrameworkPropertyMetadata(default(double), FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnMinChanged));
    
    public double Min
    {
        get { return (double)GetValue(MinProperty); }
        set { SetValue(MinProperty, value); }
    }
    
    private static void OnMinChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
    {
        var control = sender as CustomControl;
        if (control != null)
            control.OnMinChanged((double)e.OldValue, (double)e.NewValue);
    }
    
    private void OnMinChanged(double oldValue, double newValue)
    {
        if (Min > Max)
            Min = oldValue;
    }
    

    ViewModel のコードを以下のようにして試しています.

    private double min;
    public double Min
    {
        get { return min; }
        set
        {
            System.Diagnostics.Trace.WriteLine(string.Format("set : value={0}", value));
            if (min == value)
                return;
            min = value;
            RaisePropertyChanged("Min");
        }
    }
    
    private double max;
    public double Max
    {
        get { return max; }
        set
        {
            if (max == value)
                return;
            max = value;
            RaisePropertyChanged("Max");
        }
    }
    
    private DelegateCommand testCommand;
    public DelegateCommand TestCommand
    {
        get
        {
            if (testCommand == null)
                testCommand = new DelegateCommand(p =>
                    {
                        System.Diagnostics.Trace.WriteLine(string.Format("Min={0}", Min));
                        Min = 100.0;
                    });
            return testCommand;
        }
    }
    View 側は次のようにしています.

    <custom:CustomControl Min="{Binding Min, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Max="{Binding Max}" />
    <Button Content="click me" Command="{Binding TestCommand}" />

    ボタンを押すと ViewModel 側から Min プロパティが変更されますが,
    Max より大きいので Min = 0 に戻るというコードになっていると思います.
    が,View 上では Min の値は 0 になっているようですが,
    ボタンを押したときに出力している Trase.WriteLine での Min の値は 100 のままのようです.
    この値が 0 になると思っているんですが….
    • 編集済み Yujiro15 2014年4月16日 6:11
    2014年4月16日 6:09
  • WPFならこんなのがあります。

    http://blogs.wankuma.com/kazuki/archive/2008/01/31/120275.aspx

    電車の中なのでこんな回答ですいません。


    かずき Blog:http://d.hatena.ne.jp/okazuki/

    2014年4月16日 11:00
  • Modelの値を変えていいのは作業者とModelだけです。Viewが勝手にModelの値を変えるのはダメです。
    (ModelViewはViewとModelの間で値を変換するだけです。)
    Min>MaxとなってはいけないというのはModelの都合であるならば、Model側がそういう制限処理をするべきでしょう。

    ViewはModel側から通知した値を受け取ります。受け取ったうえで、Viewが扱えない値であるならば無視して表示しないとか、値が異常だと表示したりするのはViewの自由です。Viewに制限があるのであれば、View側で加工するのは自由ですが、Model側にまでその制限が影響するのは間違いです。

    そもそもViewのプロパティが許可されない値を受け取らないように値を制限したいなと思った時はCoerceValueCallbackという検証機構で制限をすることが可能です。
    #ただしこれも、値を受け取った状態で変換して、元の値がなくなるわけではないので、微妙に使いにくかったり

    <ResourceDictionary
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfApplication1">
    <Style TargetType="{x:Type local:CustomControl}">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type local:CustomControl}">
                    <Border Background="{TemplateBinding Background}"
                            BorderBrush="{TemplateBinding BorderBrush}"
                            BorderThickness="{TemplateBinding BorderThickness}">
                        <StackPanel>
                            <Slider Value="{Binding Path=Min,Mode=TwoWay, RelativeSource={RelativeSource Mode=TemplatedParent}}" Maximum="300"/>
                            <TextBlock Text="{Binding Path=Min,Mode=OneWay, RelativeSource={RelativeSource Mode=TemplatedParent}}" />
                            <Slider Value="{Binding Path=Max,Mode=TwoWay, RelativeSource={RelativeSource Mode=TemplatedParent}}" Maximum="300"/>
                            <TextBlock Text="{Binding Path=Max,Mode=OneWay, RelativeSource={RelativeSource Mode=TemplatedParent}}" />
                        </StackPanel>
                    </Border>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
    </ResourceDictionary>
    
    using System.Windows;
    using System.Windows.Controls;
    using System.Windows.Data;
    
    namespace WpfApplication1
    {
    
        public class CustomControl : Control
        {
            static CustomControl()
            {
                DefaultStyleKeyProperty.OverrideMetadata(typeof(CustomControl), new FrameworkPropertyMetadata(typeof(CustomControl)));
            }
    
            public double Min { get { return (double)GetValue(MinProperty); } set { SetValue(MinProperty, value); } }
            public double Max { get { return (double)GetValue(MaxProperty); } set { SetValue(MaxProperty, value); } }
    
            public static readonly DependencyProperty MaxProperty
                = DependencyProperty.Register("Max", typeof(double), typeof(CustomControl)
                , new FrameworkPropertyMetadata
                    (default(double)
                    , FrameworkPropertyMetadataOptions.BindsTwoWayByDefault
                    , new PropertyChangedCallback(OnMaxChanged)
                    , new CoerceValueCallback(Max_CoerceValue)));
    
            public static readonly DependencyProperty MinProperty
                = DependencyProperty.Register("Min", typeof(double), typeof(CustomControl)
                , new FrameworkPropertyMetadata
                    (default(double)
                    , FrameworkPropertyMetadataOptions.BindsTwoWayByDefault
                    , new PropertyChangedCallback(OnMinChanged)
                    , new CoerceValueCallback(Min_CoerceValue)));
    
            /// <summary>Minが受け取れる値になるように検証して制限する</summary>
            /// <returns>制限を適用した値</returns>
            public static object Min_CoerceValue(DependencyObject d, object baseValue)
            {
                //MinはMaxより大きい値を受け付けない
                double max = ((CustomControl)d).Max;
                double min = (double)baseValue;
                if (max < (double)baseValue)
                {
                    return max;
                }
                return min;
            }
            private static void OnMinChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 
            {
                //MaxがMinで下限を制限されていたのを新しいMinを下限にするために値を読み出す
                var bnd =BindingOperations.GetBindingExpression(d, MaxProperty);
                bnd.UpdateTarget();
            }
    
            /// <summary>Maxが受け取れる値になるように検証して制限する</summary>
            /// <returns>制限を適用した値</returns>
            public static object Max_CoerceValue(DependencyObject d, object baseValue)
            {
                //MaxはMin未満の値を受け付けない
                double min = ((CustomControl)d).Min;
                double max = (double)baseValue;
                if (min > (double)baseValue)
                {
                    return min;
                }
                return max;
            }
            private static void OnMaxChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) 
            {
                //MinがMaxで上限を制限されていたのを新しいMaxを上限にするために値を読み出す
                var bnd = BindingOperations.GetBindingExpression(d, MinProperty);
                bnd.UpdateTarget();
            }
    
        }
    }
    <Window x:Class="WpfApplication1.MainWindow"
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
            xmlns:custom="clr-namespace:WpfApplication1"
            Title="MainWindow" Height="350" Width="525">
        <StackPanel >
            <custom:CustomControl Min="{Binding Min, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Max="{Binding Max}" Height="100"/>
            <Button Content="click me" Command="{Binding TestCommand}"  Height="100"/>
        </StackPanel>
    </Window>
    
    using System;
    using System.Windows;
    using System.Windows.Input;
    
    namespace WpfApplication1
    {
        public partial class MainWindow : Window
        {
            public MainWindow()
            {
                InitializeComponent();
    
                this.DataContext = new ViewModel();
            }
        }
    
        class ViewModel : System.ComponentModel.INotifyPropertyChanged 
        {
           
            private double min;
            public double Min
            {
                get { return min; }
                set
                {
                    System.Diagnostics.Trace.WriteLine(string.Format("set : value={0}", value));
                    if (min == value)
                        return;
                    min = value;
                    RaisePropertyChanged("Min");
                }
            }
    
            private double max;
            public double Max
            {
                get { return max; }
                set
                {
                    if (max == value)
                        return;
                    max = value;
                    RaisePropertyChanged("Max");
                }
            }
    
            private DelegateCommand testCommand;
            public DelegateCommand TestCommand
            {
                get
                {
                    if (testCommand == null)
                        testCommand = new DelegateCommand(p =>
                        {
                            System.Diagnostics.Trace.WriteLine(string.Format("Min={0}", Min));
                            Min = 100.0;
                        });
                    return testCommand;
                }
            }
    
            #region INotifyPropertyChanged メンバー
    
            public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;
            private void RaisePropertyChanged(string name)
            {
                var pc = PropertyChanged;
                if (pc != null)
                {
                    pc(this, new System.ComponentModel.PropertyChangedEventArgs(name));
                }
            }
            #endregion
        }
    
        class DelegateCommand : ICommand
        {
            public DelegateCommand(Action<object> act)
            {
                this.act = act;
            }
    
            private Action<object> act;
    
            #region ICommand メンバー
    
            public bool CanExecute(object parameter)
            {
                return true;
            }
    
            public event EventHandler CanExecuteChanged;
    
            public void Execute(object parameter)
            {
                act(parameter );
            }
    
            #endregion
        }
    }
    

    個別に明示されていない限りgekkaがフォーラムに投稿したコードにはフォーラム使用条件に基づき「MICROSOFT LIMITED PUBLIC LICENSE」が適用されます。(かなり自由に使ってOK!)

    2014年4月16日 12:01
  • CoerceValueCallback では元の値に戻すことができない…
    と思っていたんですが,
    下記のコードで元の値に戻すことはできました.
    しかし,依然として Trase.WriteLine で出力される Min の値は 100 のままのようです.

    private static object OnCoerceMinValue(DependencyObject sender, object value)
    {
        var control = sender as CustomControl;
        if (control == null)
            return value;
    
        double result = (double)value;
    
        if (result > control.Max)
            result = control.Min;
    
        return result;
    }
    
    ひらぽんさんも確認できたとおっしゃってますし,
    もしかして値の変更方法は妥当で,
    自分の確認方法が間違ってるんでしょうか….
    2014年4月16日 23:03
  • スライダーが付いててとてもわかりやすいサンプルでした.
    ありがとうございます.

    おっしゃるように View 側から Model の値を強制的に変更するのはマナー違反かもしれません.
    今は思うようなことができていないため,
    Min > Max になったときは例外を投げて終了するようにしています.

    > Min>MaxとなってはいけないというのはModelの都合であるならば、Model側がそういう制限処理をするべきでしょう。

    普段なら私も Model の責務であると思うんですが,
    今回はカスタムコントロールの都合なので,カスタムコントロール内でなんとかしようと思いました.

    > Viewが扱えない値であるならば無視して表示しないとか、値が異常だと表示したりする

    値を変更するか,できないなら例外を投げることしか頭になかったのですが,
    "表示しない" という対応はいいアイデアですね.

    それはさておき,
    いただいたサンプルで動作確認をしました.
    Min=0.0,Max=50.0 にした状態でボタンを 2 回押したときの Trace 出力は次のようになりました.
    View 上では Min のスライダーの値は 50.0 になりますが,
    依然として Trace 出力の,つまりは ViewModel の Min の値が変更された値にならないようです.

    Min=0
    set : value=100
    Min=100               <- ここが 50.0 になるはず
    set : value=100


    試しに以下のようにマウスクリックで Min がインクリメントされるようにするコードを追加しました.

            protected override void OnMouseLeftButtonDown(System.Windows.Input.MouseButtonEventArgs e)
            {
                base.OnMouseLeftButtonDown(e);
    
                Min++;
            }
    

    そして,Min=0.0,Max=0.0 の状態で
    カスタムコントロールを一度クリックしてからボタンを 1 回押したときの Trace 出力は次のようになりました.

    set : value=0        <- Min のスライダで 0 にセットしたときの Trace 出力.
    set : value=1        <- コントロールをクリックしたのでインクリメントされたが,View の値は 0 のまま.
    Min=1                    <- ボタンをクリックしたときの Trace 出力.View の値は 0 なのに…
    set : value=100    <- ViewModel 側で 100 にセットされた.Min_CoerceValue() メソッドで 0 にしたはず.
    Min=100                <- もう一度ボタンをクリックしたときの Trace 出力.View の値は 0 なのに…
    set : value=100

    この出力の 3 行目も 5 行目も本当は 0 になって欲しいところです.
    カスタムコントロール側からプロパティ値を変更すると,
    ViewModel 側の値と整合性が取れなくなってしまうんでしょうか.
    2014年4月16日 23:44
  • 個人的には、カスタムコントロールの場合は積極的にユーザーが変な値を入れないための支援ってするべきだと思います。

    スライダーとかカレンダーとか、MaskedTextBoxとかは最たる例ですし。


    かずき Blog:http://d.hatena.ne.jp/okazuki/

    2014年4月19日 2:48
  • はい,私もそう思います.

    しかし今回のようにプロパティ変更イベントハンドラの中で
    対象となっているプロパティを変更すると
    ViewModel 側と整合性が取れないようなので(今のところ),
    gekka さんもおっしゃっているように,
    View 側の対応として異常を表示したり表示更新をおこなわないようにしたりして,
    異常値を修正させることを促すようなものを考えたほうがいいのでしょうか.
    2014年4月22日 1:10
  • 依然として値の変更を反映させることができていませんが,
    とりあえず異常値を受け取ったユーザコントロール側は

    ・警告を表示する
    ・そのプロパティを使用しているコントロール部分の描画をしない

    という対処をおこなうこととしました.

    皆さんアドバイスありがとうございました.
    • 回答としてマーク Yujiro15 2014年10月16日 0:34
    2014年10月16日 0:33