デベロッパー2007年12月18日 10:00
文字サイズ文字サイズ小文字サイズ中文字サイズ大

Presentation Modelパターンによる動的XAMLフォームの作成

この記事のURLhttp://japan.internet.com/developer/20071218/26.html
著者:John Wheeler
海外internet.com発の記事

はじめに

 この記事では、Windows Presentation Foundation(WPF)とPresentation Modelパターンを使用して、複数の編集可能な行項目をコレクションにバインドする高度な技法を紹介します。この記事の内容は、読者がWPFデータバインディングの技法、およびオブジェクト指向UIライブラリで一般的に使用されるデザインパターンに関する基本的知識をお持ちであることを前提としています。まず記事全体で使用するサンプルアプリケーションの概要を紹介し、その後、このアプリケーションにPresentation Modelパターンを適用してUI層とビジネスロジック層を分離する方法を示します。最後に、実際にPresentation ModelをXAMLコントロールにバインドして、複数の編集可能な行項目を持つ動的UIを作成するために必要なWPF固有の細かい処理についても説明します。

動的ユーザーインターフェイス

 「複数の編集可能な行項目を持つ動的ユーザーインターフェイス」の意味をわかりやすく示すために、まず、それとは対照的な静的インターフェイスの例として、Webベースのアカウント登録フォームの場合を考えてみましょう。このユースケースでは、ユーザーに個人情報の入力を求めるフォームを表示し、ユーザーから得られた情報を、それに対応する(ユーザープロファイルを表す)ビジネスオブジェクトまたはデータベーステーブルにマッピングします。ユーザーがフォームに入力して送信した個人情報はサイトに保存され、サイトから確認の電子メールが送信されるとユースケースは完了します。

 この場合、UIとデータ検証のコードが適切であるとすれば、このフォームを作成した開発者は必要な情報を前もって正確に把握していて、それゆえ、ユーザーによる不正なデータの入力を防止するための制約を課すことができたと考えられます。したがって、このインターフェイスは静的と表現できます。要求した情報を検証するためのパラメータは事前に判明しており、そのUIがアプリケーションのインスタンスごとに変化することはありません。

 これと対比して、今度は日々の経費を管理するための取引記録を考えてみましょう。このフォームでは、ユーザーが入力する支出項目の件数は一定せず、1件の場合もあれば20件の場合もあるでしょう。このようなアプリケーションの開発では、安全策をとって、あらかじめ定めたかなり多数の(通常1日に予想される入力件数を超える行数の)空の行項目を表示することも考えられますが、これはエレガントとは言い難い方法です。それよりも優れたもう1つの方法は、UIに表示する行項目を必要に応じて新たに生成することです。

 情報の正確な量(項目の件数)を開発者が前もって知ることはできないので、これは動的なUIです。動的なUIはより直観的で、すっきりした画面を実現でき、そのときどきの必要性に応じて項目数を調整することで、ユーザーが目下の作業に集中しやすい環境を実現します。

サンプルアプリケーション: Expenses.NET

 この記事でサンプルアプリケーションとして使用する「Expenses.NET」は、まさにそのような動的UIを持つ取引記録アプリケーションです(図1)。このアプリケーションでは、各週の日々の支出を明細項目として記録できます。起動すると、UIの中央に、1つのListViewItemを含んだListViewが表示されます。

 このListViewItemには、その週内の特定の日を選択するためのComboBoxと、支出の説明を入力するためのTextBox、金額入力用のもう1つのTextBox、および明細項目を追加するためのボタンと既存の項目を削除するためのボタンが含まれています。

 それぞれのListViewItemに表示されるデータは、ExpenseLineItemという名前のビジネスオブジェクトによって保持されます。ExpenseLineItemクラスは、上記の各コントロールに対応するプロパティを外部に公開しており、また、WPFのバインディングフレームワークと連係して動作するためにINotifyPropertyChangedインターフェイスを実装しています。ListView自体のデータはExpenseSheetという別のビジネスオブジェクトによって保持されます。このExpenseSheetには、ExpenseLineItemを要素として持つObservableCollectionが格納されています。

図1 実行中のExpenses.NETサンプルアプリケーションの画面。1件の支出項目が表示されている
図1 実行中のExpenses.NETサンプルアプリケーションの画面。1件の支出項目が表示されている

Presentation Modelパターン

 Expenses.NETの中核を成すのはPresentation Modelパターン(図2)です。名高い4人組のデザインパターン本(『Design Patterns: Elements of Reusable Object-Oriented Software』)にはこのパターンに関する章は含まれていませんが、Martin FowlerのWebサイトでは、「Enterprise Application Architecture」セクションの1ページを割いて、このパターンが詳しく解説されています。Fowlerの言葉を借りれば、このパターンは「インターフェイスで使用されているGUIコントロールに依存せずにプレゼンテーションの状態と振る舞いを表す」方法です。これはMVCモデルや、UIコントロールライブラリで一般的に使用されている類似のパターンとどう違うのだろうと疑問に思われるかもしれませんが、実のところ本質的には違いはありません。

 このパターンはコントロールをアプリケーションレベルで抽象化したもので、ビジネスオブジェクトからのデータに基づいてユーザーインターフェイス全体の状態を制御します。これらのビジネスオブジェクトの各プロパティは、コントロールのプロパティに対応しています。ここで取り上げている例では、WPFデータバインディングが接着剤としての役割を担い、これらのプロパティの同期を保持します。この抽象化は特定のプラットフォームに固有のものではなく、ASP.NETのWebアプリケーションやモバイルデバイスなどを対象とするその他のUIでも再利用可能なので、非常に有効です。

図2 Presentation Modelパターン。このモデルでは、ビジネスオブジェクトのデータに基づいてUI全体の状態を制御する
図2 Presentation Modelパターン。このモデルでは、ビジネスオブジェクトのデータに基づいてUI全体の状態を制御する

具体例

 Presentation Modelに基づく動的UIを作成するには、DataTemplateを使用してコレクションの要素をListViewItemにバインドするListViewを定義します。これらのコンポーネントが連係して動作するようになれば、UIイベントハンドラからPresentation Modelのメソッドを呼び出すだけで、行項目の追加や削除を実行できるようになります。しかし、この連係動作を実現するには、まずXAML UIにPresentation Modelを導入する必要があります。

 ExpenseSheetWindowは、Expenses.NET用に定義したXAML UIです。そして、それに対応するPresentation ModelがExpenseSheetWindowModelです。ルートのWindow要素のResourcesブロック内でObjectDataProviderを作成し、それをWindowのDataContextにバインドします。これにより、Presentation Modelとそのプロパティがすべてのコントロールで使用可能になります。

public class ExpenseSheetWindowModel : INotifyPropertyChanged {
   private ExpenseSheet currentExpenseSheet;
   public event PropertyChangedEventHandler PropertyChanged;
   
   public ExpenseSheet CurrentExpenseSheet {
      get { return currentExpenseSheet; }
   }
   
   private void NotifyPropertyChanged(string info) {
      if (PropertyChanged != null) {
         PropertyChanged(this, new 
            PropertyChangedEventArgs(info));
      }
   }
}

 基本的なXAMLコードは次のようになります。

<Window x:Class=
   "ComFrame.Expenses.Presentation.ExpenseSheetWindow"
   xmlns=
     "http://schemas.microsoft.com/winfx/2006/xaml/presentation"
   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
   xmlns:sys="clr-namespace:System;assembly=mscorlib" 
   xmlns:my="clr-namespace:ComFrame.Expenses.Presentation" 
   Title="Expenses" SizeToContent="WidthAndHeight"    
   Background="White" Name="rootWindow">
   <!-- Window Resources -->
   <Window.Resources>
      <!-- Backing presentation model -->
      <ObjectDataProvider x:Key="presentationModel" 
         ObjectType="{x:Type my:ExpenseSheetWindowModel}" />
   </Window.Resources>
   <Window.DataContext>
      <Binding Source="{StaticResource presentationModel}"/>
   </Window.DataContext>
</Window>

 次に、行項目を表示するListViewを作成します。ここで、上位クラスのItemsControlの代わりにListViewを使用することに何か利点があるのかと疑問に思われるかもしれません。というのも、ListViewはSelectorの派生クラスであり、Selectorには、今回の目的には不要な機能が含まれているからです(たとえば、このアプリケーションでは、選択されている行項目を強調表示する必要はありません)。

 しかし、ListViewはGridViewという特別のビューを備えており、これを利用すると、各列の最上部に列ヘッダーが表示されて、ユーザーが必要に応じて列の表示順を変更することが可能になるので、Expenses.NETの外観や使用感が向上します。ItemsControlの内部に手を加えて、ItemsControlクラスでGridViewを利用できるようにするという方法も考えられますが、ListViewを使用した上で選択機能を抑止する方が簡単です。

<ListView Name="lineItemListView" Height="300">
  <ListView.View>
    <GridView AllowsColumnReorder="True">
      <GridViewColumn Header="Date"/>
      <GridViewColumn Header="Description"/>
      <GridViewColumn Header="Amount"/>
      <GridViewColumn Header="Actions"/>
    </GridView>
  </ListView.View>
</ListView>

 ListViewを定義したら、それをExpenseSheetWindowModelと連係させる必要があります。そのためには、ListViewのItemsSourceプロパティに、Presentation Model内に保持されているコレクションを割り当てます。ExpenseSheetWindowModelは、ある1週間の期間に対応した現在のExpenseSheetへの参照を保持しています。ExpenseSheetにはExpenseLineItemを要素として持つObservableCollectionが含まれていることを思い出してください。このコレクションがバインディングのソースプロパティとなります。

<ListView Name="lineItemListView" 
  ItemsSource="{Binding Path=CurrentExpenseSheet.LineItems}"
  Height="300">

 この時点でExpenses.NETを起動すると、空白のListViewが表示されます。しかし、ユーザーが支出項目の入力を開始しやすいように、最初から1つのExpenseLineItemが表示されるようにした方が望ましいでしょう。既に定義したObjectDataProviderはExpenseSheetWindowModelの既定コンストラクタを暗黙で呼び出します。このコンストラクタは初期化を行うのに適した場所なので、空の行項目を1つ追加する次のコードをここに記述します。

public ExpenseSheetWindowModel() {
   currentExpenseSheet = new ExpenseSheet(DateTime.Now);
   CurrentExpenseSheet.LineItems.Insert(0, new ExpenseLineItem());
}

 意外なことに、上記のコードを追加してから再びアプリケーションを起動しても、表示されるListViewは依然として空白のように見えます。しかし、見かけにだまされてはいけません。空白に見えるこのListViewには、空のExpenseLineItemが含まれています。ListViewItemの既定のプレゼンテーションでは、ExpenseLineItemのToStringメソッドの出力がTextBlock内にレンダリングされます。この実装ではdescriptionプロパティの値が返されますが、この値は初期状態ではnullに設定されているため、ListViewItemが見える形では表示されないのです。ただし、ListViewItemが表示されるべき場所をクリックすると、その領域が強調表示されるので、ListViewItemが存在することは確認できます。

 各ExpenseLineItemのデータを格納するために、各ListViewItemの既定の読み取り専用TextBlockを、DataTemplateを使用して編集可能コントロールに置き換えます。DataTemplateを使用すると、WPFのリッチコンテンツモデルを活用して、コレクションの各要素のレンダリング時にユーザー定義のプレゼンテーションを適用できます。

 最初の3つのDataTemplateは、ListViewのItemsSourceから暗黙的に渡されるExpenseLineItemの各プロパティにバインドされています。最後のDataTemplateには、ExpenseLineItemの追加用と削除用の2つのボタンが含まれています。これらのボタンをイベントに対応付けるXAMLコードビハインドは後で定義します。

<ListView.Resources>
   <DataTemplate x:Key="startDateTemplate">
      <ComboBox MinWidth="200" Margin="3,0,3,0"   
         SelectedItem="{Binding Path=StartDate}"/>
   </DataTemplate>
   
   <DataTemplate x:Key="descriptionTemplate">
      <TextBox Width="350" Text="{Binding Description}" 
         Margin="3,0,3,0"/>
   </DataTemplate>
  
   <DataTemplate x:Key="amountTemplate">
      <TextBox Width="50" Text="{Binding Amount}" 
         Margin="3,0,3,0"/>
   </DataTemplate>
   
   <DataTemplate x:Key="actionsTemplate">
      <StackPanel Orientation="Horizontal">
         <Button Margin="6,0,0,0" Click="addButton_Click">
            <Image Source="/Images/add.png"/>
         </Button>
         <Button Margin="3,0,0,0" Click="deleteButton_Click">
            <Image Source="/Images/delete.png"/>
         </Button>
      </StackPanel>
   </DataTemplate>
</ListView.Resources>

 この4つのDataTemplateは、以前に定義した4つのGridViewColumnに対応するものです。実際にDataTemplateとGridViewColumnを関連付けるには、各GridViewColumnのCellTemplateプロパティを次のように設定します。

<GridView AllowsColumnReorder="True">
   <GridViewColumn Header="Date" 
      CellTemplate="{StaticResource startDateTemplate}"/>
   <GridViewColumn Header="Description" 
      CellTemplate="{StaticResource descriptionTemplate}"/>
   <GridViewColumn Header="Amount" 
      CellTemplate="{StaticResource amountTemplate}"/>
   <GridViewColumn Header="Actions" 
      CellTemplate="{StaticResource actionsTemplate}"/>
</GridView>

仕上げ

 ListViewの実質的な定義は完了しましたが、この記事では話を簡単にするために一部の重要なコードの説明を割愛しています。その部分の詳細については、サンプルコードをご覧ください。省略した部分のコードでは、IValueConverterを持つもう1つのDataTemplateを使用して、startDateTemplateのComboBoxに表示する日付のフォーマットを変更しています。

 既にListViewは定義されているので、XAMLのコードビハインドにイベントハンドラを追加することができます。これらのハンドラはExpenseLineItemを追加または削除する処理をExpenseSheetWindowModelに委譲します。そのためには、ユーザーが追加または削除しようとしているListViewItemのインデックス(ユーザーが[Add]ボタンまたは[Delete]ボタンをクリックした項目のインデックス)を調べて、そのインデックスの次の位置への行項目の挿入、またはそのインデックスの位置の行項目の削除を実行できるようにする必要があります。サンプルコードのaddButton_ClickおよびdeleteButton_Clickの2つのイベントハンドラでは、ビジュアルツリーを上にたどってListViewItemを見つけ出し、それをListViewのItemContentGeneratorメソッドに渡すことによってインデックスを取得しています。

筆者注
 ListViewItemのインデックスを調べる処理については、このスレッドでJosh Smith氏(MVP)から助言をいただきました。
private void addButton_Click(
   object sender, RoutedEventArgs e) {
   int index = GetListViewItemIndex(
      e.OriginalSource as DependencyObject);
   presentationModel.InsertRowIntoCurrentExpenseSheet(
      index + 1, new ExpenseLineItem());
}
   
private void deleteButton_Click(
   object sender, RoutedEventArgs e) {
   ItemCollection items = lineItemListView.Items;
   
   // don't delete last item
   if (items.Count == 1) { return; } 
   
   int index = GetListViewItemIndex(
      e.OriginalSource as DependencyObject);
   presentationModel.RemoveRowFromCurrentExpenseSheet(index);
}
   
private int GetListViewItemIndex(DependencyObject depObj) {
   while (!(depObj is ListViewItem)) {
      depObj = VisualTreeHelper.GetParent(depObj);
   }
   
   return lineItemListView.ItemContainerGenerator.
      IndexFromContainer(depObj);
}

 ハンドラから呼び出されるExpenseSheetWindowModelのメソッドは、ExpenseSheetに格納されているExpenseLineItemのObservableCollectionに対して要素を挿入または削除する単純な処理を行います。この処理の対象はObservableCollectionであるため、それが変更された時点でPropertyChangedイベントが発生し、UIが自動的に再描画されます。

public void RemoveRowFromCurrentExpenseSheet(int index) {
   CurrentExpenseSheet.LineItems.RemoveAt(index);
}
   
public void InsertRowIntoCurrentExpenseSheet(
   int index, ExpenseLineItem lineItem) {
   CurrentExpenseSheet.LineItems.Insert(index, lineItem);
}

基本原則の拡張的適用

 この記事では、複数の編集可能な行項目を持つ動的ユーザーインターフェイスを構築するうえでPresentation Modelがどのように有効であるのかを示しました。サンプルコードの内容をご覧になれば、図1の左上のナビゲーションボタン(緑色の3角形と正方形が表示されているボタン)など、この記事では取り上げなかったその他のUI要素も、ExpenseSheetWindowModelによって制御されていることがわかるでしょう。ここで説明した考え方は、行項目を表示するUIの開発だけにとどまらず、データセットに合わせて複数の編集可能コンポーネントをさまざまなレイアウトで表示することが必要なUIの開発に応用できます。

 Presentation Modelパターンを使用すると、ユーザーインターフェイスの状態と振る舞いをカプセル化することができます。この例のメソッドの本体はXAMLのコードビハインドに無理なく記述できるほど小さいので、Presentation Modelの導入は話を無駄に複雑にするだけではないかと考える人もいるでしょう。確かに、この例のように小さなアプリケーションの場合はそう言えるもしれませんが、構築するUIの規模が大きくなってくると、ロジックはたちまち複雑化して厄介なものとなります。さらに、その他の付加的な機能――たとえば時間のかかる処理のためのバックグラウンドスレッドの起動や、マウスカーソルの状態の設定、進捗インジケータやメッセージボックスの表示など――も必要となるので、コードは混沌として手に負えない状態に劣化し始めるでしょう。

 しかし、Presentation Modelを使用すれば、UIとビジネスオブジェクトを抽象化されたクリーンな形で結び付けることができるので、将来、まったく異なる別のUIの構築が必要となった場合でも、中核的なロジックを実装し直す必要なしにUIの変更を実現できます。

著者紹介

John Wheeler(John Wheeler)
ComFrame Software Corporation(Southeastern Microsoft認定ゴールドパートナー、IBM Rationalパートナー)の上級ソフトウェアエンジニア。フルプロジェクト開発のスペシャリストで、.NETのほか、Java、Ruby on Rails、軽量開発手法にも精通している。

Copyright 2008 Jupitermedia Corporation All Rights Reserved.http://www.internet.com/