OITA: Oika's Information Technological Activities

@oika 情報技術的活動日誌。

WPF 自作ValidationRuleのプロパティにバインディング

どうも、僕です。
ザムルってもうワクワクしますよね。
ということで、Qiita XAML Advent Calendar 2014 21日目のエントリーになります。

WPFではコントロールのプロパティにバインドされた値が不正値でないかどうか
評価する仕組みとして、Validationというのを持っています。

例えば、TextBoxのTextプロパティにint型のプロパティをTwoWayでバインドして、
画面から整数でない文字を入れると赤い枠が表示されます。
これが標準装備のValidation。

Validationのエラー表示

このルールをもっと細かくしたい、たとえば、
入力できる数値の範囲を制限したいとします。0~100とか。
そういうときはValidationRuleを自作する。

public class IntRangeRule : ValidationRule  
{  
    public int MinValue { get; set; }  
    public int MaxValue { get; set; }  
  
    public IntRangeRule()  
    {  
        //既定値  
        MinValue = int.MinValue;  
        MaxValue = int.MaxValue;  
    }  
  
    public override ValidationResult Validate(object value, CultureInfo cultureInfo)  
    {  
        if (value == null) return new ValidationResult(false, "値がNullです");  
  
        int inputNum;  
        if (!int.TryParse(value.ToString(), out inputNum))  
        {  
            return new ValidationResult(false, "値の形式が不正です");  
        }  
  
        if (inputNum < MinValue || MaxValue < inputNum)  
        {  
            return new ValidationResult(false, "値が範囲外です");  
        }  
  
        return ValidationResult.ValidResult;  
    }  
}  

こんなクラス。まあ直感的にわかりやすいですね。
これを以下のように使用します。

<Window x:Class="BindingValidationRuleSample.MainWindow"  
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"  
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"  
        xmlns:my="clr-namespace:BindingValidationRuleSample"  
        Title="MainWindow" Height="240" Width="360">  
    <Window.DataContext>  
        <my:MainWindowViewModel />  
    </Window.DataContext>  
  
    <Grid>  
        <TextBox HorizontalAlignment="Left" Height="23" Margin="47,0,0,0" VerticalAlignment="Center" Width="120">  
            <TextBox.Text>  
                <Binding Path="InputNum" Mode="TwoWay" UpdateSourceTrigger="PropertyChanged">  
                    <Binding.ValidationRules>  
                        <my:IntRangeRule MinValue="0" MaxValue="100" />  
                    </Binding.ValidationRules>  
                </Binding>  
            </TextBox.Text>  
        </TextBox>  
    </Grid>  
</Window>  
public class MainWindowViewModel  
{  
    public int InputNum { get; set; }  
}  

問題はここからだ。
xamlで設定してる、MinValue="0", MaxValue="100"っていうのを
Bindingでコンテクスト側から指定したいとする。
↓こんなイメージで。

<TextBox HorizontalAlignment="Left" Height="23" Margin="47,0,0,0" VerticalAlignment="Center" Width="120">  
    <TextBox.Text>  
        <Binding Path="InputNum" Mode="TwoWay" UpdateSourceTrigger="PropertyChanged">  
            <Binding.ValidationRules>  
                <my:IntRangeRule MinValue="{Binding MinInputValue}" MaxValue="{Binding MaxInputValue}" />  
            </Binding.ValidationRules>  
        </Binding>  
    </TextBox.Text>  
</TextBox>  

ところが、これは書けないですよね。
BindingはDependencyProperty(依存関係プロパティ)に対してしか使えないのです。

それならば、MinValueとMaxValueをDependencyPropertyにしてやるぞと思う。
ところがところが、DependencyPropertyを持つクラスは
DependencyObjectのサブクラスでないといけないのだが、
このIntRangeRuleはValidationRuleのサブクラスですよね!
こいつはDependencyObjectではない!

さてどうするかというと、DependencyPropertyにしたいプロパティを
ラップするだけのDependencyObjectサブクラスを作ります。

public class DependencyInt : DependencyObject  
{  
    //依存関係プロパティ  
    public static readonly DependencyProperty ValueProperty  
        = DependencyProperty.Register("Value", typeof(int), typeof(DependencyInt));  
  
    //CLRプロパティ  
    public int Value  
    {  
        get  
        {  
            return (int)GetValue(ValueProperty);  
        }  
        set  
        {  
            SetValue(ValueProperty, value);  
        }  
    }  
}  

MinValueとMaxValueを一緒にひとつのクラスに入れちゃってもOKですが
今回は本当にIntのプロパティ1つだけのクラスにしました。
これを使ってIntRangeRuleを書き換える。

public class IntRangeRule : ValidationRule  
{  
    public DependencyInt MinValue { get; set; }  
    public DependencyInt MaxValue { get; set; }  
  
    public IntRangeRule()  
    {  
        //既定値  
        MinValue = new DependencyInt { Value = int.MinValue };  
        MaxValue = new DependencyInt { Value = int.MaxValue };  
    }  
  
    public override ValidationResult Validate(object value, CultureInfo cultureInfo)  
    {  
        if (value == null) return new ValidationResult(false, "値がNullです");  
  
        int inputNum;  
        if (!int.TryParse(value.ToString(), out inputNum))  
        {  
            return new ValidationResult(false, "値の形式が不正です");  
        }  
  
        if (inputNum < MinValue.Value || MaxValue.Value < inputNum)  
        {  
            return new ValidationResult(false, "値が範囲外です");  
        }  
  
        return ValidationResult.ValidResult;  
    }  
}  

これでバインドできるさね。

<Window x:Class="BindingValidationRuleSample.MainWindow"  
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"  
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"  
        xmlns:my="clr-namespace:BindingValidationRuleSample"  
        Title="MainWindow" Height="240" Width="360">  
    <Window.DataContext>  
        <my:MainWindowViewModel />  
    </Window.DataContext>  
  
    <Grid>  
        <TextBox HorizontalAlignment="Left" Height="23" Margin="47,0,0,0" VerticalAlignment="Center" Width="120">  
            <TextBox.Text>  
                <Binding Path="InputNum" Mode="TwoWay" UpdateSourceTrigger="PropertyChanged">  
                    <Binding.ValidationRules>  
                        <my:IntRangeRule>  
                            <my:IntRangeRule.MinValue>  
                                <my:DependencyInt Value="{Binding MinInputValue}" />  
                            </my:IntRangeRule.MinValue>  
                            <my:IntRangeRule.MaxValue>  
                                <my:DependencyInt Value="{Binding MaxInputValue}" />  
                            </my:IntRangeRule.MaxValue>  
                        </my:IntRangeRule>  
                    </Binding.ValidationRules>  
                </Binding>  
            </TextBox.Text>  
        </TextBox>  
    </Grid>  
</Window>  
public class MainWindowViewModel  
{  
    public int InputNum { get; set; }  
  
    public int MinInputValue  
    {  
        get  
        {  
            return 0;  
        }  
    }  
  
    public int MaxInputValue  
    {  
        get  
        {  
            return 100;  
        }  
    }  
}  

これでうまくいくかと思いきや、いま一歩のところでこれは機能しません!

なにがだめかというと、ValidationRuleはVisualTreeで繋がってないので、
DataContextが共有されないんすね。
なので、先ほど作ったDependencyIntのバインドソースは、改めて指定してやる必要がある。
ここはもうゴリッとやってしまいますよ。ずばばば。

<Window x:Class="BindingValidationRuleSample.MainWindow"  
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"  
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"  
        xmlns:my="clr-namespace:BindingValidationRuleSample"  
        Title="MainWindow" Height="240" Width="360">  
    <Window.Resources>  
        <my:MainWindowViewModel x:Key="vm" />  
    </Window.Resources>  
  
    <Grid DataContext="{StaticResource vm}">  
        <TextBox HorizontalAlignment="Left" Height="23" Margin="47,0,0,0" VerticalAlignment="Center" Width="120">  
            <TextBox.Text>  
                <Binding Path="InputNum" Mode="TwoWay" UpdateSourceTrigger="PropertyChanged">  
                    <Binding.ValidationRules>  
                        <my:IntRangeRule>  
                            <my:IntRangeRule.MinValue>  
                                <my:DependencyInt Value="{Binding MinInputValue, Source={StaticResource vm}}" />  
                            </my:IntRangeRule.MinValue>  
                            <my:IntRangeRule.MaxValue>  
                                <my:DependencyInt Value="{Binding MaxInputValue, Source={StaticResource vm}}" />  
                            </my:IntRangeRule.MaxValue>  
                        </my:IntRangeRule>  
                    </Binding.ValidationRules>  
                </Binding>  
            </TextBox.Text>  
        </TextBox>  
    </Grid>  
</Window>  

上記のようにできないケースもあると思いますが、その場合はうまいことやってください。

以上、ValidationRuleなんてどの程度使う機会があるかわかんないですが、
そんなことより、とにかくBindingで解決できるとなんか嬉しくなりませんか!