WPF と MVVM で時刻入力コントロール このエントリーをはてなブックマークに追加

ご無沙汰しております。

会社としての仕事がなかなか収入につながらないため、昨年の秋から個人事業主として某所で頂いた設計の仕事に従事しています。当初は 1~2ヶ月程度の短期の仕事として契約しておりましたが、のっぴきならない諸処の事情 (^-^; により、最低でも今月末まで、できればその後の開発の方にも~、ってな感じでなにげに長引きそうであります。

お金がないので (泣、仕事があること自体はありがたいことではあります… 残業続きですでにへとへとですが。

で、そのお仕事なのですが、 Windows 向けの業務アプリでありまして、開発には C# (Visual Studio 2010 / .NET 4.0) と WPF を利用することが決まっております。我々設計部隊も、画面イメージ作成のために XAML を書いたり、レビューの際の説明のために簡単な制御ロジックを書いたりもしています。

まぁ画面作りに時間をかけてばかりも居られないので、たいていの人は WYSIWYG の XAML エディタで枠だけ書いて終わり、制御ロジックを書くにしても code behind に直接書くだけなのですが (そもそも WPF とか知らん、さらにいえばプログラミング自体ほとんど経験無いって人も少なくなく…)、開発工程にも関わる可能性があり、なおかつ WPF なんて現場ではほとんど使われていないために経験がある技術者が見つかりにくいという中で、いい機会なのでこの際勉強もかねて MVVM スタイルでの記述を心がけるようにしていたりするわけです。割と楽しいし。

そんなわけで、当ブログではこれからしばらくの間、 WPF と MVVM 関連のネタを綴っていくことにしようかと思っております。どうぞお付き合いくださいませ…。

DatePicker はあるのに…

そんなわけで、今回は時刻を入力するコントロールの作り方について考えてみることにします。ありがちな課題だとは思うのですが、日付の入力は DatePicker があるのに時刻を入力するための適当なコントロールが見当たらなくてどうしようか、といった場合に、既製品をどこかから仕入れて組み込んでしまえばいいのですが、ライセンス管理がどうこうだの、バグがあった場合にどこまでサポートしてもらえるだの、それはそれで面倒なことも少なくないので、であれば適当に仕様を決めて作ってしまうのも手かなとか思ったりもするわけです。このくらいであればそんなに難しくもないですし。

結論から (?) 言ってしまうと、できあがったサンプルプログラムを GitHub 上で公開しています。何気に Git の練習も兼ねているという… (^^;

WPF + MVVM で時刻入力コントロール

とりあえず設計

あくまでサンプルなのでそんなに厳密な話はするつもりはないのですが、おおざっぱに仕様を決めておきましょう。

  • 入力するのは時と分だけとします。秒は扱いません。
  • hh:mm形式で表示します。但しコロンは固定、時と分でテキストボックスが分かれているイメージです。また、分は 2桁固定で 0 パディングします (時は 0 パディングしません)。
  • 時、分は普通にテキスト入力させます。スピンボタンとかは設けません。
  • 時、分のテキストボックスは、どちらも 2文字までしか入力を受け付けません。
  • 時、分のテキストボックスは、どちらもフォーカスを抜けた段階でバリデーションを行います。受け付けられない値が入力された場合は、元々入力されていた値 (空欄だった場合は空欄) に戻します。
    • 空欄にすることは可能とします。
  • ちょっと細かい話ですが、時、または分のテキストボックスは、フォーカスを受け取ったら中身の数字を全選択状態にします。
  • さらに細かい話ですが、分が空欄の状態で、時に有効な値が入力された (バリデーションをパスした) ら、その時点で時に「00」を設定します。利便性のための仕様ですね。
  • 時、分の両方に有効な値が入力されている場合のみ、「値がある」状態とします。それ以外の場合は「値がない」状態として扱います。

開発環境

Visual Studio は製品版である必要はありません。ていうかそんなお高いもの弊社の経済事情では調達できません (T-T)。普通に Visual C# 2010 Express を使いましょう。

※仕事の関係上、 Visual Studio 2012 / .NET 4.5 はノーチェックです。サンプルもあくまで 2010 をターゲットに作ってます。バージョンの変わり目っていろいろと面倒ですね… (‘A`)

それから、 MVVM スタイルでのプログラミングと言うことで、 Expression Blend SDK が必須となっております (Expression Blend 自体が必要という話ではありません… あった方がいいんでしょうけど)。今回のサンプルでも使っているので、以下よりダウンロードしてインストールしてください。

ソリューションとかプロジェクトとか名前空間とか

ありがちな手法としてよく紹介されるのは、 Model、 View、 ViewModel の 3つに名前空間を分けましょう、といってフォルダを 3つ掘らせる (さらに Common とか掘らせちゃったりする) やり方だと思うのですが、 Model を単体テストさせることを念頭に置くのであれば、個人的には Model は名前空間ではなくてプロジェクト自体を分けちゃった方がいいんじゃないかとか思ったりするわけです。

そんなわけで、今回のソリューションの構成は最終的には以下のような感じになりました。

TimeInputSample ソリューションの構成
プロジェクト 名前空間 説明
TimeInputSample TimeInputSample 形骸化するwメイン部分
TimeInputSample.View View (XAML 置き場)
TimeInputSample.ViewModel ViewModel やその他の制御ロジック
TimeInputModel TimeInputModel Model
TestTimeInputModel TestTimeInputModel Model 用の単体テスト

ちなみに今回は面倒なので MainWindow.xaml は名前空間 TimeInputSample から移動してません。本当はこれも TimeInputSample.View の配下に移動すべきなんでしょうが…。

で、「最終的に」と書きましたが、何でかというと、この最終形態はあくまで実際に動いているところを見せるための使用例に過ぎず、今回のトピックのメインはあくまで時刻入力コントロールの実装方法だからです。

時刻入力コントロールの実装

XAML

まず、プロジェクト上にユーザーコントロールを新規追加すると、デフォルトでは <UserControl> 要素に d:DesignHeight やら d:DesignWidth やらとありますが、これらは WYSIWYG エディタ上での表示の大きさが設定できるだけみたいです。固定サイズのウィンドウやコントロールに対して WYSIWYG 操作で編集する文には便利なんですが、今回は不要なので取っ払ってしまいましょう。

とりあえず大枠で見た目を作ってしまいます。だいたい以下のようなコードになります。

<UserControl x:Class="TimeInputSample.View.TimeInputControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">
    <UserControl.Resources>
        <ResourceDictionary Source="CommonResources.xaml" />
    </UserControl.Resources>
    <Grid>
        <Grid.Resources>
            <Style x:Key="TimeInputTextBox" TargetType="TextBox" BasedOn="{StaticResource DigitTextBox}">
                <Setter Property="Background" Value="Transparent" />
                <Setter Property="BorderThickness" Value="0" />
                <Setter Property="Padding" Value="0" />
                <Setter Property="Width" Value="26" />
                <Setter Property="Height" Value="24" />
                <Setter Property="MaxLength" Value="2" />
            </Style>
        </Grid.Resources>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="Auto" />
        </Grid.ColumnDefinitions>
        <Label Background="White" BorderBrush="#555" BorderThickness="1,2,1,1" Grid.ColumnSpan="3" />
        <TextBox Text="{Binding Path=HourText}" Grid.Column="0" Style="{StaticResource TimeInputTextBox}" />
        <Label Content=":" Margin="4,0" Padding="0" Grid.Column="1" />
        <TextBox Text="{Binding Path=MinuteText}" Grid.Column="2" Style="{StaticResource TimeInputTextBox}" />
    </Grid>
</UserControl>

外枠の見た目は Label を使っています。 Rectangle だと BorderThicknessdouble 型で、上下左右同じ太さにしかできないからです。

時、分を入力する 2つのテキストボックスは、枠を消し、背景色を透明にした上で、最大 2文字しか入力できないようにしてあります。こうした見た目などの補助的な属性はすべて Style として設定するようにしてみました。ちなみにベースになるスタイルはリソースディクショナリとして別ファイルで記述しています。

Grid.ColumnDefinitions は、グリッドの列を定義します。ここでグリッドの列を分け、配下のコントロール毎に添付属性を使って、配置する列番号を Grid.Column で指定したり、列をまたぐ数を Grid.ColumnSpan で指定したりすることができます。

ViewModel

あくまでコントロールとして作るので、 ViewModel とは言っても、最初から View に割り当てられているのではなく、そのコントロールを配下にもつコンテナとなるウィンドウやコントロールの制御ロジック上でバインドするデータとして使うことを想定します。

表示上では数字以外の文字列を入力されたり、そも分は 0パディング表示しなければいけなかったりするので、時、分のテキストボックスにバインドするデータは string 型である必要があります。その一方で、制御ロジック上では時も分も int 値で扱えないと不便ですし、日付の DateTime に時刻を混ぜ合わせるような処理も欲しいところです。

    /// 時刻入力コントロール用のデータオブジェクトクラス。
    public class TimeInputControlData : ViewModelBase
    {
        /// 時刻の時。
        public int? Hour
        {
            // ...
        }

        /// 時刻の分。
        public int? Minute
        {
            // ...
        }

        /// 時刻の時の文字列表現。
        public string HourText
        {
            // ...
        }

        /// 時刻の分の文字列表現。
        public string MinuteText
        {
            // ...
        }

        /// 入力済みチェック。
        public bool HasValue
        {
            // ...
        }

        /// 日付に時刻を加えて日時に変換する。
        public DateTime? BlendWithDate(DateTime? date)
        {
            // ...
        }
    }

必要になるアクセサ、メソッドはざっとこんなところでしょうか。

ViewModelBase というクラスを継承していますが、これは WPF で View にバインドするデータを制御ロジック込みのオブジェクトとして定義したい場合に実装すべき機能をまとめたものです。実装は至ってシンプルで、単に System.ComponentModel.INotifyPropertyChanged インタフェースを実装し、 View に関連づけられたプロパティの更新イベントを発生させるためのメソッド NotifyPropertyChanged() を用意しているだけです。

入力とバリデーション

時分の時の入力について考えてみましょう。ここで View に関連づけるプロパティは HourText の方ですが、制御ロジック側からのアクセスを考えると、 int 値として直接設定ができた方が何かとはかどりそうです。そこで、まずは Hour プロパティへの操作を実装してみます。

using System.Diagnostics;

    public class TimeInputControlData : ViewModelBase
    {
        private int? hour_val;

        public int? Hour
        {
            get { return hour_val; }
            set
            {
                Debug.Assert(!value.HasValue || 0 <= value && value < 24);
                hour_val = value;
                NotifyPropertyChanged("HourText");
            }
        }

        // ...

Hour プロパティに触れるのは制御ロジックだけで、ユーザー入力がここに直接入ってくるわけではない、というスタンスなので、ここでの Validation は単に Assertion としての扱いとなっています (さすがに手抜きに過ぎるかもですが…)。

View に関連づけているのはあくまで HourText プロパティなので、 NotifyPropertyChanged() メソッドに渡す「表示を更新すべきプロパティの名前」も Hour ではなく HourText ということになります。

ちなみに Hour プロパティも hour_val コンテナも、型は int ではなく int? 、すなわち Nullable<int> としています。これは、このコントロールが空欄の場合もあり得るためです。つまり、すでに存在する時刻入力コントロールに対して、制御ロジック側から表示を空欄にしたい場合は、以下のように書けばいい、ということになります。

    // 時刻入力コントロールにバインドするプロパティ
    TimeInputControlData TimeInput { get; private set; }

    // 時刻入力を空欄にする
    private void ResetTime()
    {
        TimeInput.Hour = TimeInput.Minute = null;
    }

さて、値の設定と表示への反映の仕組みはできあがったので、 HourText プロパティはこれを利用して実装することができます。ユーザー入力を扱わなければならない HourText の処理はこれよりやや複雑になります。

        public string HourText
        {
            get { return hour_val.HasValue ? hour_val.Value.ToString() : null; }
            set
            {
                // 空欄にされた場合
                if (value == null || value == "")
                {
                    Hour = null;
                    return;
                }
                // 入力のバリデーション
                int hour;
                if (int.TryParse(value, out hour) && 0 <= hour && hour < 24)
                {
                    Hour = hour;
                    // 時分の分が空欄なら、ここで "00" を設定する
                    if (!minute_val.HasValue)
                    {
                        Minute = 0;
                    }
                }
            }
        }

HourText の中では、値の設定で hour_val コンテナ直接代入するのではなく、 Hour プロパティに代入するようにしています。こうすることで、 Hour プロパティ側で NotifyPropertyChanged() メソッドを呼んでくれるので、 View の表示もしっかり更新されるというわけです。反面、ユーザーが数字以外の文字や、 99 などのあり得ない値を入力してきた場合、 Hour への代入は発生しません。この場合、変更されなかった hour_val コンテナがもつ元々の値で表示が更新されます。たとえば、元々 "3" が入っていたところに "33" と入力しても、フォーカスが離れた途端に "3" に戻ってくれるということです。

MinuteMinuteText もだいたい似たような感じで実装します。ここでは説明を割愛します。

フォーカスを受け取ったら全選択

データバインディング周りはこんなもんなので、あとは View のさらに細かい制御です。ここまでで、時刻の時を先に入力すると、分に勝手に「00」が入るようにしてあります。ここでさらに、時を入力した後に Tab キーでフォーカスを分に移すと分の「00」が全選択状態になるようにしてあると、分の書き換えも容易にできて便利かも知れません。

そこで (ちゃんと仕様にも書いてますが)、時、分の入力欄はフォーカスを受け取ったら全選択するように実装してみることにします。

この手の処理はさすがにコントロールのイベントに頼る必要があります。初めてのイベントドリブンプログラミングです。しかしそのためだけにコードビハインドを使うのはさすがにちょっとダサイです。そこで、 Expression SDK によってサポートされる Behavior や TriggerAction の利用を検討してみることにします。

今回は単にコントロールのイベントと View Model の Command を結びつけられれば十分なので、 TriggerAction を利用することにします。 Behavior や TriggerAction を利用するための準備については、上記サイトに詳しく書かれていますのでそちらを参照ください。要約するとこんな感じです。

  1. View および View Model があるプロジェクトの「参照設定」に、以下のアセンブリを追加する。
    • System.Windows.Interactivity
    • Microsoft.Expression.Interactions
  2. Behavior や TriggerAction を利用する UI の XAML ファイルにて、先頭のタグにおける名前空間の宣言部分に、以下の名前空間の宣言を書き加える。
                 xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
                 xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
    

次に XAML ファイルにて、時、分のテキストボックスに、 TriggerAction を追加します。ざっとこんな感じになります。

        <TextBox Text="{Binding Path=HourText}" Grid.Column="0" Style="{StaticResource TimeInputTextBox}">
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="GotFocus">
                    <i:InvokeCommandAction Command="{Binding Path=GotFocusCommand}"
                                           CommandParameter="{Binding RelativeSource={RelativeSource FindAncestor,
                        AncestorType=TextBox}}" />
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </TextBox>
        <Label Content=":" Margin="4,0" Padding="0" Grid.Column="1" />
        <TextBox Text="{Binding Path=MinuteText}" Grid.Column="2" Style="{StaticResource TimeInputTextBox}">
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="GotFocus">
                    <i:InvokeCommandAction Command="{Binding Path=GotFocusCommand}"
                                           CommandParameter="{Binding RelativeSource={RelativeSource FindAncestor,
                        AncestorType=TextBox}}" />
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </TextBox>

これで、各テキストボックスの GotFocus イベントに、 GotFocusCommand という名前のコマンドを関連づけることができました。今回は CommandParameter の記述がポイントで、このバインディングでコマンドに渡されるパラメータは、まさにこの TriggerAction を設定しているテキストボックスオブジェクト自身です。

GotFocusCommand は、渡されるパラメータがテキストボックスオブジェクトである想定で実装します。まず、パラメータが本当にテキストボックスオブジェクトかどうかを実行可否の判断材料として記述し、

using System.Windows.Controls;  // 忘れずに。

// ...

        private bool CanGotFocus(object parameter)
        {
            return parameter is TextBox;
        }

そして実行処理ではそのテキストボックスであるパラメータに対して全選択を行うメソッドの呼び出しを記述します。

        private void OnGotFocus(object parameter)
        {
            var target = parameter as TextBox;
            target.SelectAll();
        }

あとは、これらのメソッドをデリゲートとして設定したカスタムコマンドを GotFocusCommand コマンドとして定義してやれば完成です。

        private CustomCommand got_focus_command;

        public CustomCommand GotFocusCommand
        {
            get
            {
                if (got_focus_command == null)
                    got_focus_command = new CustomCommand(OnGotFocus, CanGotFocus);

                return got_focus_command;
            }
        }

CustomCommand ってなんじゃい、という突っ込みが聞こえそうですが (^^;、これは自前で実装したクラスです。コマンド毎に ICommand インタフェースを実装したクラスを定義するのは激しく面倒なので、実際の実行処理と実行可否チェックはデリゲートとして渡せばいいようにした、というものです。

ちなみに、フォーカスを受け取ったら全選択、みたいな処理はありふれているので、もっと再利用性の高い方法で部品化できた方がいいかもしれません。その場合は、添付 Behavior というテクニックを検討してみる価値があるかも知れません。

なんかさっきから参考にしている情報のほとんどが同一人物によるものであるような気がしますが気にしないことにします (^_^;

日時入力コントロールの実装

せっかく時刻入力コントロールができたので、これに DatePicker を組み合わせただけのお手軽日時入力コントロールも作ってしまいましょう。

XAML

本当に DatePicker とさっきの時刻入力コントロールを並べるだけです。コード見た方が早いでしょう。

View Model

時刻入力コントロールの部分のデータバインディングについては、時刻入力コントロール用の View Model オブジェクト TimeInputControlData オブジェクトをアクセサで渡すだけ渡してあげれば、あとは TimeInputControlData クラス内のメソッドがよしなにやってくれます。

        public TimeInputControlData Time { get; private set; }

        public DateTimeInputControlData(DateTime? dt = null)
        {
            Time = new TimeInputControlData();  // コンストラクタの中でインスタンスをぶち込むのを忘れずに…
            setDateTime(dt);
        }

プログラム側からは、日付と時刻を別々に見るのではなく、日時を 1つの DateTime として参照できた方が便利に決まっています。ので、それ用のアクセサ DateTimeValue を実装します。

        public DateTime? DateTimeValue
        {
            get { return Time.BlendWithDate(date_val); }
            set
            {
                setDateTime(value);
                NotifyPropertyChanged("Date");
                NotifyPropertyChanged("Time");
            }
        }

説明してませんでしたが、 TimeInputControlData.BlendWithDate() メソッドに、日付の DateTime に自身の時刻を混ぜ合わせた新しい DateTime を生成する処理を記述しています。

特筆すべきはそのくらいかな…?

サンプルプログラム「日時メモ」

せっかくなので、今回作ったコントロールを用いた簡単なサンプルプログラムを作ってみましょう。記事冒頭ですでにキャプチャ画像を示しましたが、日時と文を書いて溜めていくだけの簡単なメモ的なものです。

Model - ローカルデータベース

まずは Model です。単体テストを構成しやすいように別プロジェクトにしたという構成の話は冒頭ですでに記述したとおりです。

書き込んだメモは当然保存されることを想定しているので、保存する先としてファイルかデータベースを利用する必要があります。 .NET でもローカルデータベースは SQLight を使う方がメジャーになっているようですが (System.Data.SQLight)、一方 Visual Studio をインストールするときに一緒に入る Microsoft SQL Server にも、ローカルデータベース版の SQL Server Compact があり、追加でコンポーネントをインストールせずとも、そのままプロジェクトに組み込んで開発に利用することができます。

ネット上での情報が少ない (といっても必要な情報は概ね MSDN にありますが…)、配置に難がある等、使い勝手の面で SQLight に劣る部分も多いですが、 .NET を使うような仕事ともなると、フリーソフトウェアを製品に組み込みづらいケースも少なくないですし ('A`)、せっかくの機会なのでこっちを使ってみることにしました。

ネット上にありがちな情報では、データグリッドに直接バインドしたりとか、エンティティモデルがどうのこうのとかやってたりして、単純に SQL 書いてデータ出し入れする方法がなかなか見つからなかったりするわけですが、実際のところ使い方は至って単純です。

  1. プロジェクトの参照設定に System.Data.SqlServerCe を追加する。 System.Data.SqlServerCe は .NET コンポーネントから選択できます。
  2. プロジェクトへの「新しい項目」の追加で、「ローカル データベース」を選んで追加します。ここで指定したファイル名が、ローカルデータベースの DB ファイル名になります。
  3. データベースエクスプローラにて、上記の DB ファイルのツリーを開き、配下の「テーブル」を右クリックして「テーブルの作成」を選びます。テーブル スキーマを編集するウィンドウが出てくるので、テーブル名やらテーブルレイアウトやらを設定します。必要になるテーブルの数だけこれを繰り返します。
  4. あとは、他の DBMS を使うのと同じように DB にアクセスするコードを書いていくだけです。 connection は System.Data.SqlServerCe.SqlCeConnection クラスを使います。

コードの書き方は実際のコードを見てもらうのが一番手っ取り早いと思います…。

Unit Test

テストコードは NUnit を利用したものとなっています。ここではモデルに対する単体テストはこういう形でできるんだよねという確認程度で、テスト自体は大したことはやっていません。ビルドして吐き出される DLL を NUnit の GUI ツールに読み込ませることでテストが実行でき、テスト結果とコンソール出力が見られるようです。

今後の予定

今回は WPF や MVVM 自体が初めてと言うことで、全工程込みで書かせていただきました。無駄にボリュームのある記事になってしまった…。

今後はポイントになる部分に絞った内容になっていくことになると思います。

2013 年 3 月 1 日 by 村山 俊之

タグ: , , ,

コメントをどうぞ