データ ポイント

WPF の DataGrid 列のテンプレートを構成してユーザー エクスペリエンスを向上する

Julie Lerman

コード サンプルのダウンロード

最近、あるクライアント向けに Windows Presentation Foundation (WPF) を使っていくつか作業を行いました。私はサード パーティ製ツールを使用することに大きな信頼を寄せています。しかし、どういうわけか、Visual Studio インストールに含まれるツールだけを使用することに固執する開発者がいます。そのため、こうした開発者を待ち構えている課題を見つけるために、サード パーティ製ツールを使用しない場合もあります。

そこで、今回も成功を祈りつつ、WPF の DataGrid を使って作業を開始しました。ユーザー エクスペリエンスに関していくつか問題があり、Web を検索したり、オンライン フォーラムのアドバイスを活用したりして、数日かけて解決しました。このとき問題を解決するにあたって、DataGrid 列を相互に補完するテンプレートのペアに分割すると、大きな効果があることがわかりました。この解決策はこれまで明らかになっていなかったので、このコラムで説明することにしました。

今回は、WPF の DataGrid の内部で WPF の ComboBox コントロールと DatePicker コントロールの操作することに重点を置きます。

DatePicker と新しい DataGrid の行

私がフラストレーションを感じた 1 つの課題が、DataGrid の日付列のユーザー操作でした。データ ソース オブジェクトを WPF ウィンドウにドラッグして DataGrid を作成します。デザイナーの既定の動作では、オブジェクトの DateTime 値ごとに DatePicker が作成されます。たとえば、DateScheduled フィールドには次のような列が作成されました。

<DataGridTemplateColumn x:Name=" dateScheduledColumn"  
  Header="DateScheduled" Width="100">
  <DataGridTemplateColumn.CellTemplate>
    <DataTemplate>
      <DatePicker
        SelectedDate="{Binding Path=DateScheduled, Mode=TwoWay,
          ValidatesOnExceptions=true, NotifyOnValidationError=true}" />
    </DataTemplate>
  </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>

この既定の動作は、編集に向いていません。既存の行を編集しても、更新されませんでした。DatePicker は、DataGrid での編集をトリガーしません。つまり、データ バインド機能は、基になるオブジェクトを通じて変更をプッシュしません。この問題を解決するには、Binding 要素に UpdateSourceTrigger 属性を追加し、値を PropertyChanged に設定します。

<DatePicker
   SelectedDate="{Binding Path= DateScheduled, Mode=TwoWay,
     ValidatesOnExceptions=true, NotifyOnValidationError=true,
     UpdateSourceTrigger=PropertyChanged}" />

しかし、DatePicker で DataGrid の編集モードをトリガーできないと、新しい行ではさらに悪い結果を招くことが考えられます。DataGrid では、新しい行が NewRowPlaceHolder で表されます。新しい行では最初にセルを編集するときに、編集モードに切り替えることによってデータ ソースへの挿入をトリガーします (データ ソースは、データベースではなく、基になるメモリ内のソースです)。しかし、DatePicker では編集モードがトリガーされないため、モードが切り替わりません。

今回は偶然にも、日付列が行の先頭列だったため、この問題が見つかりました。行の編集モードをトリガーするのに、この日付列を使用していました。

図 1 は、新しい行の最初の編集可能な列に日付を入力しているところを示しています。

image: Entering a Date Value into a New Row Placeholder

図 1 NewRowPlaceholder に日付値を入力する

しかし、次の列の値を編集すると、前の列で編集した値が失われます (図 2 参照)。

image: Date Value Is Lost After the Value of the Task Column in the New Row Is Modified

図 2 新しい行の Task 列の値を変更すると日付値が失われる

最初の列のキー値が 0 になり、入力されたばかりの日付が 1/1/0001 に変わります。Task 列を編集すると DataGrid がトリガーされて、最終的にソースに新しいエンティティが追加されます。ID 値は整数 (既定値 0) になり、日付値が .NET の既定の最小日付の 1/1/0001 になります。このクラスに既定の日付を指定していたら、ユーザーが入力した日付は、.NET の既定値ではなくクラスの既定値に変更されます。Date Performed 列の日付は既定値に変化しなかったことに注目してください。これは DatePerformed を NULL 値を許容するプロパティにしていたためです。

では、ユーザーは DateScheduled に戻って、もう一度値を修正しなければならないのでしょうか。ユーザーはそれに不満を感じるはずです。私はしばらくこの問題に取り組みました。列を DataTextBoxColumn に変更することにも挑戦しましたが、そうすると、DatePicker で不要だった検証の問題に取り組まなくてはなりませんでした。

最終的には、WPF チームの Varsha Mahadevan が正しい方向に導いてくれました。

WPF の合成可能な性質を利用すると、列に 2 つの要素を使用できます。DataGridTemplateColumn に CellTemplate 要素だけでなく、CellEditingTemplate も含めます。DatePicker コントロールに編集モードへのトリガーを求めるのではなく、編集にのみ DatePicker を使用します。CellTemplate で日付を表示する際は、TextBlock に切り替えます。dateScheduledColumn の新しい XAML を次に示します。

<DataGridTemplateColumn x:Name="dateScheduledColumn" 
  Header="Date Scheduled" Width="125">
  <DataGridTemplateColumn.CellTemplate>
    <DataTemplate>
      <TextBlock Text="{Binding Path= DateScheduled, StringFormat=\{0:d\}}" />
    </DataTemplate>
  </DataGridTemplateColumn.CellTemplate>
  <DataGridTemplateColumn.CellEditingTemplate>
    <DataTemplate>
      <DatePicker SelectedDate="{Binding Path=DateScheduled, Mode=TwoWay,
                  ValidatesOnExceptions=true, NotifyOnValidationError=true}" />
    </DataTemplate>
  </DataGridTemplateColumn.CellEditingTemplate>
</DataGridTemplateColumn>

これで UpdateSourceTrigger を指定しなくてもよくなりました。DatePerformed 列にも同じ変更を加えました。

日付列にはシンプルなテキストが表示され、セルに値の入力を始めると DatePicker に切り替わります (図 3 参照)。

image: DateScheduled Column Using Both a TextBlock and a DatePicker

図 3 TextBlock と DatePicker の両方を使用する DateScheduled 列

新しい行より上の行には、DatePicker のカレンダー アイコンが表示されていません。

しかし、これでもやや問題が残ります。行の編集を開始するときに、相変わらず、.NET の既定値が取得されます。この場合は、基になるクラスで既定値を定義すれば解決できます。ScheduleItem クラスのコンストラクターを変更して、新しいオブジェクトを今日の日付で初期化しました。データベースからデータを取得したら、その既定値を上書きします。今回のプロジェクトでは Enity Framework を使用しているため、クラスは自動生成されます。ただし、生成されるクラスは部分クラスです。この新たな部分クラスにコンストラクターを追加できます。

public partial class ScheduleItem
    {
      public ScheduleItem()
      {
        DateScheduled = DateTime.Today;
      }
    }

DateScheduled 列を変更して、新しい行のプレースホルダーへの日付の入力を開始すると、DataGrid は新しい ScheduleItem を自動作成し、既定値 (今日の日付) が DatePicker コントロールに表示されます。ユーザーが引き続き行を編集するにつれて、入力した値が適切な場所に残るようになります。

編集時にユーザーのクリック回数を減らす

テンプレートを 2 つ用意する問題点の 1 つは、セルを 2 回クリックしなければ DatePicker がトリガーされない点です。これは、データ入力を行うユーザーにはフラストレーションがたまり、マウスではなくキーボードを使用してデータを入力するのに慣れているユーザーにとってはなおさらです。DatePicker は編集テンプレート内にあるため、当然、編集モードがトリガーされるまではフォーカスが設定されません。これは既定の動作です。今回の設計は TextBox に合わせているためうまく機能します。しかし、DatePicker ではうまく機能しません。XAML とコードを組み合わせて使用すれば、ユーザーが Tab キーを押してそのセルに移動したらすぐに、DatePicker で強制的に入力の準備が整うようにできます。

まず、CellEditingTemplate に Grid コンテナーを追加し、DatePicker のコンテナーにします。次に、WPF の FocusManager を使用して、ユーザーがセルに移動したときに、セルのフォーカスがこの Grid が強制的に設定されるようにします。この DatePicker を含む新しい Grid 要素を次に示します。

<Grid FocusManager.FocusedElement="{Binding ElementName= dateScheduledPicker}">
  <DatePicker x:Name=" dateScheduledPicker" 
    SelectedDate="{Binding Path=DateScheduled, Mode=TwoWay,
    ValidatesOnExceptions=true, NotifyOnValidationError=true}"  />
</Grid>

DatePicker コントロールに名前を付け、FocusedElement の Binding ElementName を使用してその名前を指しています。

この DatePicker を含む DataGrid に注目すると、次のように 3 つの新しいプロパティ (RowDetailsVisibilityMode、SelectionMode、および SelectionUnit) と、新しいイベント ハンドラー (SelectedCellsChanged) が追加されていることがわかります。

<DataGrid AutoGenerateColumns="False" EnableRowVirtualization="True" 
          ItemsSource="{Binding}" Margin="12,12,22,31" 
          Name="scheduleItemsDataGrid" 
          RowDetailsVisibilityMode="VisibleWhenSelected" 
          SelectionMode="Extended" SelectionUnit="Cell"
          SelectedCellsChanged="scheduleItemsDataGrid_SelectedCellsChanged">

この DataGrid への変更により、ユーザーが DataGrid 内の新しいセルを選択したときに通知が有効になります。最後に、これが発生したときに、DataGrid が編集モードに移行して、DatePicker に必要なカーソルが設定されるようにする必要があります。scheduleItemsDataGrid_SelectedCellsChanged メソッドは、この最後のちょっとしたロジックを提供します。

private void scheduleItemsDataGrid_SelectedCellsChanged
  (object sender, 
   System.Windows.Controls.SelectedCellsChangedEventArgs e)
{
  if (e.AddedCells.Count == 0) return;
  var currentCell = e.AddedCells[0];
  string header = (string)currentCell.Column.Header;

  var currentCell = e.AddedCells[0];
  
  if (currentCell.Column == 
    scheduleItemsDataGrid.Columns[DateScheduledColumnIndex])
  {
    scheduleItemsDataGrid.BeginEdit();
  }
}

クラスの宣言で、DateScheduledColumnIndex 定数を 1 と定義しています。これは、グリッド内の列の位置を表します。

以上のすべての変更を行うと、エンド ユーザーのフラストレーションは解消します。DataGrid 内で DatePicker を適切に機能させるための XAML とコード要素の正しい組み合わせを見つけるまでにちょっとした調査が必要だったため、皆さんが同じ労力を費やすことのないように説明しておきます。正しく組み合わせれば、ユーザーが自然だと感じる方法で UI が機能するようになります。

制限付きの ComboBox を有効にしてレガシ値を表示する

DataGridTemplateColumn 内部で要素を階層化する価値を理解したと思ったら、DataGrid の ComboBox 列で別の問題が発生し、ほぼあきらめそうになりました。

今回のアプリケーションは、レガシ データを使用するレガシ アプリケーションを置き換えるために作成しています。レガシ アプリケーションでは、ほとんど制限なしにユーザーがデータを入力できました。新しいアプリケーションでは、ドロップダウン リストを使用してデータ入力を一部制限するように、クライアントから要請されました。ドロップダウン リストのコンテンツは、文字列のコレクションを使用して簡単に提供できました。この新しい制限付きリストにはレガシ データが含まれていないのに、そのレガシ データを引き続き表示しなければならないことが問題でした。

最初に、DataGridComboBoxColumn の使用を試みました。

<DataGridComboBoxColumn x:Name="frequencyCombo"   
 MinWidth="100" Header="Frequency"
 ItemsSource="{Binding Source={StaticResource frequencyViewSource}}"
 SelectedValueBinding=
 "{Binding Path=Frequency, UpdateSourceTrigger=PropertyChanged}">
</DataGridComboBoxColumn>

ソース項目は、次のように分離コードで定義します。

private void PopulateTrueFrequencyList()
{
  _frequencyList =
                 new List<String>{"",
                   "Initial","2 Weeks",
                   "1 Month", "2 Months",
                   "3 Months", "4 Months",
                   "5 Months", "6 Months",
                   "7 Months", "8 Months",
                   "9 Months", "10 Months",
                   "11 Months", "12 Months"
                 };
    }

この _frequencyList は、別のメソッドで frequencyViewSource.Source にバインドします。

DataGrid の ComboBox 列では多種多様な構成が可能ですが、データベース テーブルの Frequency フィールドに既に格納されているかもしれない、リストとは異なる値を表示する方法が見つかりませんでした。今回試したすべての解決策をリストアップすることはしません。たとえば、こうした異なる値を _frequencyList の下に動的に追加し、必要に応じて削除する方法などを考えました。この解決策は私の好みではありませんが、我慢しなければならないと思いました。

UI を構成する WPF の 階層型アプローチで、このためのメカニズムを提供しなければならないことはわかりました。DatePicker 関する問題を解決したことで、ComboBox にも同様のアプローチを使用できることに気付きました。最初の手法は、洗練された DataGridComboBoxColumn を使用しないで、ComboBox を DataGridTemplateColumn 内部に埋め込む、昔ながらのアプローチを使用することです。次に、WPF の合成可能な性質を利用して、DateScheduled 列と同じように、列で 2 つの要素を使用します。1 つは値を表示する TextBlock で、もう 1 つは編集目的の ComboBox です。

図 4 に、これらの 2 つの要素をどのように使用しているかを示します。

図 4 値を表示する TextBlock と編集用の ComboBox を組み合わせる

<DataGridTemplateColumn x:Name="taskColumnFaster" 
  Header="Task" Width="100" >
  <DataGridTemplateColumn.CellTemplate>
    <DataTemplate>
      <TextBlock Text="{Binding Path=Task}" />
    </DataTemplate>
  </DataGridTemplateColumn.CellTemplate>

  <DataGridTemplateColumn.CellEditingTemplate>
    <DataTemplate>
      <Grid FocusManager.FocusedElement=
       "{Binding ElementName= taskCombo}" >
        <ComboBox x:Name="taskCombo"
          ItemsSource="{Binding Source={StaticResource taskViewSource}}" 
          SelectedItem ="{Binding Path=Task}" 
            IsSynchronizedWithCurrentItem="False"/>
      </Grid>
    </DataTemplate>
  </DataGridTemplateColumn.CellEditingTemplate>
</DataGridTemplateColumn>

TextBlock には、制限付きリストとの依存関係がないため、データベースに格納されているどのような値でも表示できます。ただし、編集時は ComboBox が使用され、入力が frequencyViewSource 内の値に制限されます。

セルにフォーカスが移動したときにユーザーが ComboBox を編集できるようにする

繰り返しになりますが、ComboBox はユーザーがセルを 2 回クリックしないと使用できないため、ComboBox を Grid 内にラップして、FocusManager を使用します。

ユーザーが最初の列に移動するのではなく、Task セルをクリックして新しい行データの入力を開始する場合に備えて、SelectedCellsChanged メソッドを変更しました。唯一変更したのは、現在のセルが Task 列かどうかをコードで確認することです。

private void scheduleItemsDataGrid_SelectedCellsChanged(object sender,  
  System.Windows.Controls.SelectedCellsChangedEventArgs e)
{
  if (e.AddedCells.Count == 0) return;
  var currentCell = e.AddedCells[0];
  string header = (string)currentCell.Column.Header;

  if (currentCell.Column == 
    scheduleItemsDataGrid.Columns[DateScheduledColumnIndex] 
    || currentCell.Column == scheduleItemsDataGrid.Columns[TaskColumnIndex])
  {
    scheduleItemsDataGrid.BeginEdit();
  }
}

ユーザー エクスペリエンスをおろそかにしないでください

開発者はソリューションを構築しますが、データを有効にしてしかるべき場所に移動するといった問題に集中的に取り組むのが一般的です。日付を編集するためにコントロールを 2 回クリックしなければならないことなど気が付かないことさえあります。しかし、ユーザーの作業を効率よく実行できるように考えて作成したアプリケーションが、実際には、マウスとキーボードを切り替えながら操作しなければならないことでユーザーの足を引っ張っているのであれば、それをすぐに開発者に教えてくれるのはユーザーです。

Visual Studio 2010 に含まれる WPF のデータ バインド機能は、開発時間を短縮するすばらしい機能ですが、複雑なデータ グリッドを同じように複雑な DatePicker や ComboBox と組み合わせる場合は特に、ユーザー エクスペリエンスを微調整するだけで、エンド ユーザーからとても感謝されます。おそらく、アプリケーションがユーザーの期待どおりに動作するため、ユーザーは開発者がアプリケーションに加えた配慮に気が付かないかもしれませんが、それもこの仕事のおもしろいところの 1 つです。

Julie Lerman は、バーモント ヒルズ在住の Microsoft MVP、.NET の指導者、およびコンサルタントです。世界中のユーザー グループやカンファレンスで、データ アクセスなどの Microsoft .NET トピックについてプレゼンテーションを行っています。彼女のブログは thedatafarm.com/blog (英語) で、彼女が執筆した『Programming Entity Framework』(O'Reilly Media、2010 年) は絶賛を浴びました。Twitter (twitter.com/julielerman、英語) で彼女をフォローしてください。

この記事のレビューに協力してくれた技術スタッフの Varsha Mahadevan に心より感謝いたします。