[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で解決できるとなんか嬉しくなりませんか!