2011年8月6日土曜日

wpf 設定ダイアログの作り方

wpfで設定ダイアログを作る方法を書きます。 まだwpfに触れて時間が短いので足りないところもあるかもしれません。 一応動いているっていう程度のモノですが参考にしてください。

試した環境は次のとおり。

  • Windows7 Home Premium 64bit
  • Visual C# 2010 Express

設定項目を表すクラス

このサンプルではAppConfigというクラスにアプリケーションの設定データを保持します。 簡単なサンプルということで、設定項目はint型のValueAとValueBの2つだけです。 xamlでの双方向バインディングを使用するので2つの値はpublicプロパティにします。

// AppConfig.cs
namespace ConfigTest
{
    class AppConfig
    {
        public AppConfig()
        {
            _ValueA = 10;
            _ValueB = 20;
        }

        private int _ValueA;
        public int ValueA
        {
            get { return _ValueA; }
            set
            {
                _ValueA = value;
            }
        }

        private int _ValueB;
        public int ValueB
        {
            get { return _ValueB; }
            set
            {
                _ValueB = value;
            }
        }
    }
}

アプリケーション起動時にロードor無ければ初期化され、設定ダイアログが表示されるときには必ずAppConfigのインスタンスがあるという前提とします。 これに、説明用のコードや基本的なコードを追加します。

class AppConfig
{
    public static AppConfig Current { get; set; }

    public AppConfig()
    {
        _ValueA = 10;
        _ValueB = 20;
    }

    private int _ValueA;
    public int ValueA
    {
        get { return _ValueA; }
        set
        {
            _ValueA = value;
        }
    }

    private int _ValueB;
    public int ValueB
    {
        get { return _ValueB; }
        set
        {
            _ValueB = value;
        }
    }

    public void DuplationB()
    {
        ValueB *= 2;
    }

    public AppConfig Copy()
    {
        // 要ディープコピー
        return (AppConfig)this.MemberwiseClone();
    }

    public void Save()
    {
        // 適当に実装しよう
    }

    public void Load()
    {
        // 厳密に実装しよう
    }
}

このサンプルではstaticのCurrentプロパティに現在有効な設定値を保持します。 staticが嫌いな人は適宜お好みの方法でどうぞ。

DuplationBメソッドは双方向バインディング説明用です。

Currentプロパティには「現在有効な」設定値を入れるというふうに決めました。 というわけで、設定ダイアログにCurrentを直接操作させることはできません。 ユーザーがキャンセルするかもしれないから、一時的にエラー値が設定されるかもしれないからです。 設定ダイアログはコピーしたインスタンスを扱います。 そこで必要になるのがCopyメソッドです。 サンプルでは値型しか扱ってないのでobject.MemberwiseCloneでシャローコピーしていますが、値型以外のプロパティがある場合はディープコピーが必要になります。

セーブとロードについてはこのサンプルでは書きません。 適当にどうぞ。

設定項目を操作するダイアログ

次に、プロジェクトにAppConfigWindowという名前のwpfウィンドウを追加します。 これが設定ダイアログです。 外観はこんな感じで。

xamlのコードはこうなります。

<Window x:Class="ConfigTest.AppConfigWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="設定" Height="140" Width="300" WindowStyle="ToolWindow"
        Loaded="OnWindowLoaded">
    <DockPanel>
        <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right">
            <Button Click="OnOkButtonClick" Margin="0,0,6,0">OK</Button>
            <Button Click="OnResetButtonClick">Reset all</Button>
            <Button Click="OnCancelButtonClick">Cancel</Button>
        </StackPanel>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="24"/>
                <RowDefinition Height="24"/>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="100"/>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="70"/>
            </Grid.ColumnDefinitions>
            <Label Grid.Row="0" Grid.Column="0">ValueA</Label>
            <TextBox Name="textBoxA" Grid.Row="0" Grid.Column="1">
                <TextBox.Text>
                    <Binding Path="ValueA" Mode="TwoWay"/>
                </TextBox.Text>
            </TextBox>
            <Label Grid.Row="1" Grid.Column="0">ValueB</Label>
            <TextBox Name="textBoxB" Grid.Row="1" Grid.Column="1">
                <TextBox.Text>
                    <Binding Path="ValueB" Mode="TwoWay"/>
                </TextBox.Text>
            </TextBox>
            <Button Click="OnDuplationButtonClick" Grid.Row="1" Grid.Column="2">←を倍に</Button>
        </Grid>
    </DockPanel>
</Window>

TextBoxの値をAppConfigクラスのプロパティと双方向バインドさせています。 ホントはTextBoxはデフォルトでTwoWayモードなのでMode属性は書かなくても良いのですが、説明のために明記です。 ちなみに、どのコントロールがデフォルトでTwoWayなのかはmsdnライブラリにも書いていませんでした。 確か、プロパティかメソッドを使えばデフォルトのモードが調べられるとか? msdnライブラリがでかすぎて情報ソースを再発見できてないですが、使うコントロールのデフォルトバインディングモードが分からないときは明記しといた方がいいってことで...

気を取り直して、AppConfigWindow.xaml.csにロード時とボタンのイベントを追加します。

// AppConfigWindow.xaml.cs
using System.Windows;

namespace ConfigTest
{
    public partial class AppConfigWindow : Window
    {
        public AppConfigWindow()
        {
            InitializeComponent();
        }

        private void OnWindowLoaded(object sender, RoutedEventArgs e)
        {
            this.DataContext = AppConfig.Current.Copy();
        }

        private void OnOkButtonClick(object sender, RoutedEventArgs e)
        {
            AppConfig.Current = (AppConfig)this.DataContext;
            DialogResult = true;
        }

        private void OnResetButtonClick(object sender, RoutedEventArgs e)
        {
            this.DataContext = AppConfig.Current.Copy();
        }

        private void OnCancelButtonClick(object sender, RoutedEventArgs e)
        {
            DialogResult = false;
        }

        private void OnDuplationButtonClick(object sender, RoutedEventArgs e)
        {
            AppConfig conf = (AppConfig)this.DataContext;
            conf.DuplationB();
        }
    }
}

AppConfigWindow.Loaded(csソースではOnWindowLoadedメソッド)でDataContextにAppConfigのインスタンスを渡します。 こうすることでxamlに<Binding Path="プロパティ名"/>と書くだけでバインディングが有効になります。 渡すのはAppConfig.Currentのコピーです。 上で書いたように、AppConfig.Current自体はOKボタンが押されるまで触りません。

OKボタンとキャンセルボタンが押されたとき、AppConfigWindow.DialogResultに値を設定しています。 wpfのウィンドウはShowDialogで呼び出された場合はこれだけで閉じます。 Closeメソッドは必要ありません。 OKが押されたら設定値をAppConfig.Currentにコピーしてウィンドウを閉じます。 Cancelが押されたらAppConfig.Currentには触らず、ダイアログが持つ設定値は放っておきます。 これでウィンドウのインスタンスと一緒にガベージコレクト待ちになります。

Reset allが押されたときは、サンプルではダイアログが持つ設定値を捨ててAppConfig.Currentをコピーしなおしています。 これは設定項目が多く複数のタブを持つ場合など、色々なやり方があるのでそのまま使えないかもしれません。 タブごとにリセットの仕様とか、ユーザーが便利なように工夫しましょう。

「倍に」ボタンのイベントは見たとおりです。 ただし、今のままでは正常に働きません。 ボタンを押すと設定ダイアログが持つAppConfigインスタンス上ではValueBの値は倍になっています。 しかし、これはダイアログの表示に反映されません。

設定ダイアログを呼ぶ側のコード

不具合は色々ありますが、一応ここまでコードを書けばダイアログを表示させることはできます。 呼ぶ側のコードはこんな感じです。

AppConfigWindow confWindow = new AppConfigWindow();
confWindow.Owner = this;

if (confWindow.ShowDialog() == true)
{
    // 設定の適用処理
    // 他のプロセスへの設定変更通知(凝るなら)

    try
    {
        AppConfig.Current.Save();
    }
    catch
    {
        // save例外の処理
    }
}

設定メニューのClickイベントなどに書いておきましょう。

ここまでのコーディングで、ユーザーがValueAとValueBに整数を入力してOKボタンを押せばAppConfig.Currentに反映されるようになります。 実装していないチェックコードがあるので、整数以外の不正な文字が入力された状態でOKボタンを押しても設定ダイアログは閉じてしまいます。 しかし、その場合はAppConfig.Currentに反映されません。

双方向バインディングを実装するために書く必要があるコード

これまでに書いたとおり、正常に働かない機能は

  • 倍ボタンを押しても表示に反映されない。
  • 数値型のValueA、ValueBに対応したテキストボックスに数字以外が入力できる。

の2つです。 これを解決しなければなりません。

表示が反映されないのは、AppConfigからAppConfigWindowへの更新の通知を実装していないためです。 wpfで用意されているコントロールはプロパティが変更されたら通知する仕組みがあらかじめ実装されています。 この仕組みがあるからxamlに書くだけで相互の連携ができるのです。 しかし、自作クラスAppConfigにはそれをまだ実装していません。 そのコードを書く必要があります。 具体的にはINotifyPropertyChangedインターフェースを実装します。

テキストボックスに数字以外が入力できるのは入力データの検証を実装していないためです。 wpfで入力データの検証をするのは2段階あります。

  1. テキストボックスに入力された文字列を変換できるか調べるValidationRuleでのチェック
  2. 文字列から変換された値をプロパティに設定できるかの自作クラスでのチェック

この2通りのチェックのうち、必要なものを実装し入力値が受け入れられるかを検証します。 そして、エラーがあったらダイアログの表示でユーザーに通知し、OKボタンを押せないようにします。

従来のWindowsプログラムだと、たとえばテキストボックスに数字を入力する場合数字以外のキーを受け付けなくするのが一般的でした。 wpfはそうではなく、数字以外のキーが押されてもとりあえずテキストボックスでは受け付けて、テキストボックスにエラー情報を表示し、バインド先の入力値を保持する変数は最後に正しく入力されたときの値を残したままにするのが一般的です。 複数のコントロールでエラーがあった場合、それぞれのコントロールにエラー表示をして、ユーザーが任意の順番で修正できるようにします。 で、設定ダイアログ全体を見てエラーが1つでもあったらOKボタンを押せなくするのです。 当然、入力データにエラーがあった場合テキストボックスの値と保持する変数の値は異なります。 どのタイミングで整合性が取られるのかは理解しておかなくてはなりません。

自作クラスからwpfコントロールへの更新の通知

上に書いたとおり、自作クラスのプロパティの変化をバインドしたコントロールに伝えるにはINotifyPropertyChangedを実装します。 AppConfig.csに追加するコードは下の強調表示した部分です。

// AppConfig.cs
using System.ComponentModel;

namespace ConfigTest
{
    class AppConfig : INotifyPropertyChanged
    {
        ...略

        private int _ValueB;
        public int ValueB
        {
            get { return _ValueB; }
            set
            {
                _ValueB = value;
                NotifyPropertyChanged("ValueB");
            }
        }

        public void DuplationB()
        {
            ValueB *= 2;
        }

        ...略

        public event PropertyChangedEventHandler PropertyChanged;

        protected virtual void NotifyPropertyChanged(string property)
        {
            if (this.PropertyChanged != null)
            {
                this.PropertyChanged(this, new PropertyChangedEventArgs(property));
            }
        }
    }
}

これで「倍に」ボタンが動くようになります。 これ以外の修正は不要です。 おそらく、AppConfigWindow.DataContextにAppConfigクラスのインスタンスを設定した時点で、PropertyChangedにイベントハンドラが割り当てられるのだと思われます。 それはWindowクラスの中の話なので、wpfを使う側が詳しく知る必要はありません。

NotifyPropertyChangedメソッドはINotifyPropertyChangedインターフェースとは無関係の自作メソッドです。 NotifyPropertyChangedの中身は、ただPropertyChangedを呼ぶだけです。 要は、バインドしたプロパティを変更したときにPropertyChangedを呼べばいいのです。

このサンプルではValueBが変更されたときだけPropertyChangedを呼んでいます。 双方向バインディングのプロパティ変更通知が必要になるのは「バインドしたコントロールが関知しないところでプロパティを変更したとき」です。 ValueAは

  1. 設定ダイアログを開いたとき。
  2. 設定項目がユーザーにリセットされたとき。
  3. テキストボックスがユーザーに書き換えられたとき。

の3つの場合にテキストボックスの表示が更新されます。 このときのプロパティの変化はバインドしたコントロールが関知しているのでPropertyChangedを呼ぶ必要はありません。 (開いたときとリセットされたときはAppConfigWindow.DataContextが変更され、そのときにコントロールにも通知が行く。)

ValueBはその他にもDuplationBメソッドがあり、それがまさに「バインドしたコントロールが関知しないところ...」なので通知が必要になるのです。

このサンプルは単純なのでどのプロパティで通知が必要かすぐにわかります。 一般的には、自作クラス内で値を変更するときだけ通知しておけばいいでしょう。 ただし、そうしてしまうとコードの保守性が低くなってしまうかもしれません。 将来改造や継承をする可能性があるのなら、全てのプロパティで通知した方がいいかもしれません。 (特に、自分以外の人がメンテナンスや改造をする可能性がある場合。それと自分の忘れっぽさに自信がある場合。)

PropertyChangedハンドラにはプロパティ名の文字列を渡します。 引数のプロパティ名を間違えたとき、またはプロパティ名を変更したけど引数の修正は忘れたときなどコンパイルエラーにならないので注意しましょう。 特に、リファクターの「名前の変更」で一括変換はできないのには注意が必要です。

検証エラーの表示

以降、テキストボックスに入力された文字列の検証について書きます。 検証でエラーが分かってもそれを知ることができなければ意味がないので、まずは検証エラーの表示コードを追加します。

エラーを表示するには設定ダイアログのxamlにコードを書きます。 書き足すのはWindow要素の直下、Window.Resourcesの中です。

<Window x:Class="ConfigTest.AppConfigWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:app="clr-namespace:ConfigTest"
        Title="設定" Height="140" Width="300" WindowStyle="ToolWindow"
        Loaded="OnWindowLoaded">
    <Window.Resources>
        <ControlTemplate x:Key="ControlErrorTemplate">
            <Canvas>
                <Canvas Canvas.Left="-10pt" Width="10pt" Height="10pt">
                    <Ellipse Width="10pt" Height="10pt">
                        <Ellipse.Fill>
                            <LinearGradientBrush>
                                <GradientStop Color="#10FF0000" Offset="0" />
                                <GradientStop Color="#FFFF2020" Offset="1" />
                            </LinearGradientBrush>
                        </Ellipse.Fill>
                    </Ellipse>
                    <Line X1="3pt" Y1="7pt" X2="7pt" Y2="3pt" Stroke="Black" StrokeThickness="1pt"/>
                    <Line X1="3pt" Y1="3pt" X2="7pt" Y2="7pt" Stroke="Black" StrokeThickness="1pt"/>
                </Canvas>
                <AdornedElementPlaceholder />
            </Canvas>
        </ControlTemplate>
        <Style TargetType="TextBox">
            <Setter Property="Validation.ErrorTemplate" Value="{StaticResource ControlErrorTemplate}"/>
            <Style.Triggers>
                <Trigger Property="Validation.HasError" Value="True">
                    <Setter Property="ToolTip">
                        <Setter.Value>
                            <Binding Path="(Validation.Errors)[0].ErrorContent" RelativeSource="{x:Static RelativeSource.Self}"/>
                        </Setter.Value>
                    </Setter>
                </Trigger>
            </Style.Triggers>
        </Style>
    </Window.Resources>
    <DockPanel>
        <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right">
            ...略

とはいえxamlについてはあまり調べてないので、この項目については参考サイトの中身をほぼ丸写しです。

エラーがあったときの装飾内容はControlTemplate要素(x:Key="ControlErrorTemplate")に書きます。 あまり調べてないControlTemplateについては詳しく書けないのでここでは↑のコードの概要だけ説明。 参考サイトとちょっとだけ変えて、対象のコントロールの左上に赤丸×印を表示しています。 装飾対象のコントロールはAdornedElementPlaceholderのところに収まります。

それに続けて書いてあるStyle要素で、エラーがあったときだけControlErrorTemplateが適用されるように書きます。 Validation.ErrorTemplateにControlErrorTemplateを設定するだけでOK。 参考ソースと同様に、ついでにツールチップにエラーメッセージを表示するようにもしています。 TargetTypeでTextBoxを指定してあるので、全部のテキストボックスについて同様のエラー表示になります。 設定項目によって表示を変更する場合はその分だけコードを書かなくてはなりません。

このコードを動かすには、それぞれのテキストボックスでエラーチェックを有効にしなければなりません。 それについては以降、順に説明します。

テキストボックスの入力の検証1 - ValidationRule

ValidationRuleはコントロールへの入力値がバインド対象のプロパティにsetできる形式かチェックする仕組みです。 チェックのみで実際にsetはしません。 エラーが検出されると、バインド対象のプロパティsetのコードは動きません。

ValidationRuleを有効にするには、各コントロールのBinding要素でValidatesOnExceptions="True"と書きます。

<TextBox Name="textBoxA" Grid.Row="0" Grid.Column="1">
    <TextBox.Text>
        <Binding
                Path="ValueA"
                Mode="TwoWay"
                UpdateSourceTrigger="PropertyChanged"
                ValidatesOnExceptions="True"/>
    </TextBox.Text>
</TextBox>

上のコードはValueA用のテキストボックスについてのコードです。 ValueBについても同様に書きます。

ついでに書き加えているUpdateSourceTrigger="PropertyChanged"はバインド対象とのデータのやり取りが生じるタイミングを設定しています。 そのときにエラーチェックも動くのです。 設定できる値は、

  • Explicit ... UpdateSourceメソッドが呼び出されたときのみ。
  • LostFocus ... コントロールからフォーカスがなくなったとき。
  • PropertyChanged ... プロパティが変更されるたび。
  • Default

テキストボックスの場合、DefaultはLostFocusです。 PropertyChangedに変更すると1文字キー入力されるたびに更新されます。

アプリケーションを実行すると、テキストボックスに数字以外の文字を入力するとエラーを示す赤丸×印(前の項目で用意したやつ)が表示されることが確認できます。 テキストボックスにマウスカーソルをあわせるとツールチップにエラー内容が表示されます。 現時点で動いているのはデフォルトのValidationRuleです。 デフォルトのValidationRuleは、バインド対象のプロパティの型に応じて自動で選ばれます。 このサンプルはValueA、ValueBともにint型なので、テキストボックスに入力された文字列が整数に変換できるかがチェックされます。

もうちょっと凝ったチェックをするときは自作のValidationRuleを書く必要があります。 それだけじゃなくて、デフォルトのValidationRuleがはくエラーメッセージって開発者向けっぽいんですよね。 エンドユーザー向けのアプリケーションを作る場合は分かりやすいメッセージにしなければならないので、結局たいていの場合はValidationRuleを自作しなくてはなりません。

その自作ValidationRuleのサンプルコードはこちら。

// TextToIntValidationRule.cs
using System.Windows.Controls;
using System.Globalization;

namespace ConfigTest
{
    class TextToIntValidationRule : ValidationRule
    {
        public TextToIntValidationRule()
        {
            Max = int.MaxValue;
        }

        public int Max { get; set; }

        public override ValidationResult Validate(object value, CultureInfo cultureInfo)
        {
            int res;

            if (!int.TryParse(value as string, out res))
            {
                return new ValidationResult(false, "0~" + Max + "の整数を入力してください。");
            }

            if (res < 0)
            {
                return new ValidationResult(false, "0以上の整数を入力してください。");
            }

            if (Max < res)
            {
                return new ValidationResult(false, "入力できる最大値は" + Max + "です。");
            }

            return new ValidationResult(true, null);
        }
    }
}

overrideしなければならないのはValidateメソッドだけです。 引数valueが目的の型に変換できるかを調べます。 変換できるならValidationResult(true, null)を、できないならValidationResult(false, "エラーメッセージ")を返します。 あくまでも「変換できるかのチェック」をするだけのメソッドなので、変換後の値は返しません。

ちょっとした定石ですが、valueの文字列型への変換にキャストではなくas stringを、int型への変換にParseではなくTryParseを使っています。 その方が少しずつ速いです。 理由の説明については「"C#" as」や「"C#" TryParse」で検索すればすぐに出てくるので省略。 int.TryParseは大きすぎるor小さすぎる整数が入力されたときも失敗するのでエラーメッセージの内容には注意しましょう。 「整数を入力してください」というエラーメッセージだと、大きすぎる整数が入力されたときのエラー表示で意味がとおりません。 また、文字列の頭にゼロが付くときなど多少変な入力でも通るので、それが嫌なら別の方法でチェックしなければなりません。

サンプルコードのMaxプロパティのように、独自のpublicプロパティを追加するとxamlで値を指定できます。 自作クラスTextToIntValidationRuleを使うxaml側のコードはこうなります。

<Window x:Class="ConfigTest.AppConfigWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:app="clr-namespace:ConfigTest"
        Title="設定" Height="140" Width="300" WindowStyle="ToolWindow"
        Loaded="OnWindowLoaded">
        
        ...略
        
            <TextBox Name="textBoxA" Grid.Row="0" Grid.Column="1">
                <TextBox.Text>
                    <Binding
                            Path="ValueA"
                            Mode="TwoWay"
                            UpdateSourceTrigger="PropertyChanged"
                            ValidatesOnExceptions="True">
                        <Binding.ValidationRules>
                            <app:TextToIntValidationRule Max="100"/>
                        </Binding.ValidationRules>
                    </Binding>
                </TextBox.Text>
            </TextBox>
        
        ...略

Window要素に「xmlns:適当なプリフィックス="clr-namespace:プロジェクトの名前空間"」を追加して自作クラスを使えるようにします。 あとは、各コントロールのBinding要素の中に自作ValidationRuleを追加するだけです。 独自のプロパティ(サンプルの場合はMax)は属性を書けば設定できます。

ValidationRuleでの入力値チェックは単純なルールを全部のコントロールに割り当てるときなどに便利です。 しかし、次のような欠点もあります。

  • 設定項目によってValidationRuleが違う場合、個別に自作クラスを作らなければならず、コードが分散する。
  • 開発者が関知しないところでインスタンスが作られるという性質上、複数のコントロールの連携で検証ルールが変化する場合、ValidationRuleではチェックの連携をやりづらい。

次の項目で説明する検証方法と組み合わせて、保守しやすいスッキリとしたコードを考えましょう。

テキストボックスの入力の検証2 - プロパティへの設定の可否

入力されたデータがValidationRuleを通った後、実際にキャストされてバインド対象のプロパティに値がsetされます。 このsetがうまくいったかどうかを調べるためのインターフェースがIDataErrorInfoです。 バインド対象のプロパティを持つ自作クラスに実装します。

とりあえず、コードはこんな感じです。

class AppConfig : INotifyPropertyChanged, IDataErrorInfo
{
    ...IDataErrorInfoに無関係なコードは省略

    public AppConfig()
    {
        _ValueA = 10;
        _ValueB = 20;
        _Errors = new Dictionary<string, string>();
    }

    private int _ValueA;
    public int ValueA
    {
        get { return _ValueA; }
        set
        {
            _ValueA = value;

            if (value < 10 || 50 < value)
            {
                _Errors["ValueA"] = "IDataErrorInfoでのエラーメッセージ。10~50にしてください。";
            }
            else
            {
                _Errors.Remove("ValueA");
            }
        }
    }

    private int _ValueB;
    public int ValueB
    {
        get { return _ValueB; }
        set
        {
            _ValueB = value;

            if (value < 0 || 200 < value)
            {
                _Errors["ValueB"] = "IDataErrorInfoでのエラーメッセージ。0~200にしてください。";
            }
            else
            {
                _Errors.Remove("ValueB");
            }
            NotifyPropertyChanged("ValueB");
        }
    }

    public AppConfig Copy()
    {
        // 要ディープコピー
        AppConfig res = (AppConfig)this.MemberwiseClone();
        res._Errors = new Dictionary<string, string>();
        foreach (string key in this._Errors.Keys)
        {
            res._Errors.Add(string.Copy(key), string.Copy(this._Errors[key]));
        }
        return res;
    }

    private Dictionary<string, string> _Errors;

    public string Error
    {
        get
        {
            if (_Errors.Count == 0)
            {
                return null;
            }
            else
            {
                return _Errors.Count + "個のエラーがあります。";
            }
        }
    }

    public string this[string columnName]
    {
        get
        {
            if (_Errors.ContainsKey(columnName))
            {
                return _Errors[columnName];
            }

            return null;
        }
    }
}

IDataErrorInfoの実装でコーディングする必要があるのはpublic string Errorとpublic string this[string プロパティ名]それぞれのgetだけです。 Errorの方がクラス全体を見てエラーがあるかどうか、this[プロパティ名]の方が個別のプロパティについてエラーがあるかどうかを返します。

public string Errorについて、少し調べたんですがコレの使い方を詳しく書いてあるサイトは見当たりませんでした。 個別のプロパティのエラーのようにStyle.Triggersを書く方法が特に知りたかったのですが見つからず。 ただまぁ、Error自体もただのプロパティなのでバインドすれば表示できます。 適当なところにTextBlockを作って、<TextBlock Text="{Binding Error}" ToolTip="{Binding Error}"/>みたいな感じかな? このやり方だとNotifyPropertyChanged("Error")でエラー情報の更新を通知する必要があります。 Errorプロパティの中ではなく、各エラーチェックのコードで通知するので間違えないように。

this[プロパティ名]について、こちらはStyle.Triggersが勝手に反応してくれるのでNotifyPropertyChangedの必要はありません。 エラー情報を格納するためにDictionaryを使うのが定石みたいですね。

このサンプルではユーザーの入力値に複数のエラーがあったときでも最後のエラー情報しか記憶しておらず、それしか返していません。 複数のエラー全部を表示するコードは複雑になりそうなので省略で。 でも、SilverlightのINotifyDataErrorInfoというインターフェースでは複数のエラー情報を扱えるよう(詳細未確認)です。 wpfでコーディングする場合ももう少ししたら考えなくちゃならなくなるかも?

エラー情報の設定をするのは各プロパティのsetの中です。 バインドしたコントロールは入力値のset後にthis[プロパティ名]を見てエラーの有無を確認します。 このサンプルでは本来受け付けるべきではないデータでもとりあえず記憶して、その後エラー情報を設定しています。 これは「AppConfig.Currentプロパティに現在有効な設定値を入れる。設定ダイアログが持つAppConfigのインスタンスはエラーを含んでいてもいい。」という方針だからそうしているのであって、まぁやり方によって変えましょう。

後は、エラーが解消されたら忘れずに_Errors.Removeするとか、Copyメソッドのディープコピーとか忘れないように。 ってか忘れて少しだけ詰まりました。

IDataErrorInfoを有効にするにはxamlも少しだけ書き換えなければなりません。 バインドしたコントロールのBinding要素にValidatesOnDataErrors="True"を追加します。

<TextBox Name="textBoxA" Grid.Row="0" Grid.Column="1">
    <TextBox.Text>
        <Binding
                Path="ValueA"
                Mode="TwoWay"
                UpdateSourceTrigger="PropertyChanged"
                ValidatesOnDataErrors="True"
                ValidatesOnExceptions="True">
            <Binding.ValidationRules>
                <app:TextToIntValidationRule Max="100"/>
            </Binding.ValidationRules>
        </Binding>
    </TextBox.Text>
</TextBox>

ValueBのテキストボックスも同様です。 受け付ける値の範囲を変えてエラーメッセージの変化を見たり、ValidationRule → IDataErrorInfoの流れをデバッガで見たりして確認してみましょう。

ちなみに、IDataErrorInfoと同じタイミングでエラー検出をするのに、IDataErrorInfoを使わずに「適当な例外をthrowするだけ」という手も使えます。 プロパティをsetするとき、サンプルコードでは_Errors["プロパティ名"] = "エラーメッセージ"となっているところを、throw new Exception("エラーメッセージ")とするのです。 これだと同じことをやるのにDictionaryを使わなくてすむので楽なんですよね。 ただし、wpfの場合はこのやり方だとVisual C#がいちいち例外(ユーザーコードによってハンドルされませんでした)を拾って鬱陶しいのでIDataErrorInfoの方がお勧めです。

OKボタンを受け付ける? 受け付けない?

最後に、エラーがあった場合OKボタンを受け付けないようにします。

// AppConfigWindow.xaml.cs
using System.Windows;
using System.Windows.Controls;

namespace ConfigTest
{
    public partial class AppConfigWindow : Window
    {
        ...略

        private void OnOkButtonClick(object sender, RoutedEventArgs e)
        {
            if (!IsValid(this))
            {
                MessageBox.Show(this, "不正な設定項目があります。", "確認", MessageBoxButton.OK, MessageBoxImage.Information);
                return;
            }

            AppConfig.Current = (AppConfig)this.DataContext;
            DialogResult = true;
        }

        ...略

        private bool IsValid(DependencyObject node)
        {
            if (node != null)
            {
                if (Validation.GetHasError(node))
                {
                    return false;
                }
            }

            foreach (object subnode in LogicalTreeHelper.GetChildren(node))
            {
                if (subnode is DependencyObject)
                {
                    if (!IsValid((DependencyObject)subnode))
                    {
                        return false;
                    }
                }
            }

            return true;
        }
    }
}

このサンプルでは単純に検証エラーがあったらメッセージボックスを表示して、設定ダイアログを閉じないようにしています。 xamlを勉強したら、「エラーを検出してOKボタンを無効に」とかも簡単にできそうですね。 というか勉強不足でもうしわけない。

IsValidメソッドに当たるコード、Windowクラスに標準でありそうなものですが、探してもありませんでした。 自分でコーディングするしかなさそうです。 subnodeがDependencyObjectではない場合にはfalse=「エラーあり」を返してはならないのでそれだけ注意かな?

とりあえずこれで簡単な設定ダイアログは完成です。 フリーソフトを作るときなんかはこんな感じのコードでいけるんじゃないでしょうか?

  • 普段から複数のプロセスを使うようなソフトではどうする?
  • 複数のユーザーが使うようなソフトではどうする?
  • ネットワーク越しで使うようなソフトではどうする?

みたいな課題はあるけど、だいたいはこのサンプルの応用でなんとかなりそうですよね? きっと...