UI 最前線

グリッドの外側を考える

Charles Petzold

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

キャンバスは、Windows Presentation Foundation (WPF) と Silverlight で使用できる複数のレイアウト オプションの 1 つであると同時に、最も由緒正しいオプションです。キャンバスに子要素を配置するには、Canvas.Left 添付プロパティと Canvas.Top 添付プロパティで座標を指定して、各子要素の位置を指定します。これは、他のパネルとは大きく異なるパラダイムです。キャンバス以外のパネルでは、プログラマが実際の位置を特定しなくても、単純なアルゴリズムに基づいて子要素が配置されます。

"キャンバス" という単語から、きっと絵画やスケッチを連想するでしょう。おそらくこのような理由から、WPF や Silverlight を使用するプログラマは、キャンバスの役割をベクター グラフィックスの表示に限定しがちです。しかし、キャンバスを使用して Line、Polyline、Polygon、および Path の各要素を表示するときは、キャンバス内でのその要素の位置を指定する座標点が要素自体に含まれています。このため、わざわざ Canvas.Left 添付プロパティと Canvas.Top 添付プロパティを設定する必要はありません。

では、キャンバスに用意されている添付プロパティが必要ないのに、なぜキャンバスを使用するのでしょうか。もっと良い方法はあるでしょうか。

キャンバスとグリッド

ここ数年、私はベクター グラフィックスの表示にキャンバスを使わなくなり、代わりに単一セルのグリッドに魅力を感じるようになりました。単一セルのグリッドは、行や列がまったく定義されていないことを除けば通常のグリッドと同じです。グリッドのセルが 1 つだけの場合は、そのグリッド セルに複数の要素を配置でき、グリッドの添付プロパティを使用して行や列を指定する必要はありません。

最初は、キャンバスを使用することと単一セルのグリッドを使用することがほぼ同じに思えるでしょう。キャンバスと単一セルのグリッドのどちらをベクター グラフィックスに使用するかに関係なく、Line、Polyline、Polygon、および Path の各要素の位置は、その要素の座標点を基に、要素のコンテナーの左上隅からの相対位置として配置されます。

キャンバスと単一セルのグリッドの違いは、レイアウト システムに含まれる他の要素からコンテナーを認識する方法です。WPF と Silverlight には、トップダウンで行われるの 2 パス方式のレイアウトが組み込まれています。このレイアウトでは、各親要素がその子要素のサイズを調べ、親要素自体からの相対で子要素を配置します。このレイアウト システム内では、キャンバスと単一セルのグリッドは次のように大きく異なります。

  • 子要素を中心に考えると、グリッドのサイズはそのグリッドの親要素のサイズと同じです。子要素からは、グリッドには通常有限のサイズが指定され、キャンバスには常に無限のサイズが指定されていると認識されます。
  • グリッドから親要素へは、グリッドの子要素を組み合わせたサイズが報告されます。一方、キャンバスでは、内部の子要素に関係なく見かけのサイズは常に 0 になります。

一連の Polygon 要素があり、なんらかの種類のマンガのようなベクター グラフィックス イメージを形成しているとしましょう。これらの Polygon 要素すべてを単一セルのグリッドに配置すると、グリッドのサイズは、一連の Polygon 要素で最大の水平座標と垂直座標に基に決定されます。グリッドのサイズを示すプロパティには、複合イメージのサイズが反映されているため、グリッドは通常の有限サイズの要素としてレイアウト システムで処理されます (実際には、このしくみが正常に機能するのは、イメージの左上隅座標 (0, 0)で、負の座標が存在しない場合だけです)。

一方、これらの Polygon 要素すべてをキャンバスに配置すると、キャンバスからレイアウト システムへは、サイズが 0 だと報告されます。一般に、アプリケーションに複合ベクター グラフィックス イメージを追加する場合は、ほぼ確実にキャンバスよりも単一セルのグリッドの動作を希望するでしょう。

では、キャンバスはまったく役に立たないのでしょうか。そんなことはありません。ポイントは、キャンバスの性質を活用することです。キャンバスは、本質的に、レイアウトに関与しません。したがって、レイアウトの制限を超える必要がある場合、つまり、レイアウト システムの境界を越え、レイアウト システムの外部に要素を表示する必要がある場合にキャンバスを使用します。既定では、キャンバスは子要素をクリッピングしないため、キャンバスのサイズが非常に小さくても、その境界外にある子要素をホストできます。キャンバスは、コンテナーと言うよりも、むしろ要素やグラフィックスを表示するための参照点と言えます。

キャンバスは、私が「グリッドの外側を考える」と呼ぶようになった手法に適しています。今回のコラムでは、Silverlight のコード例を示していますが、同じ手法を WPF で使用することもできます。今回のコラムに付属するダウンロード可能なソース コードは、ThinkingOutsideTheGrid という Visual Studio ソリューションです。また、このプログラムを charlespetzold.com/silverlight/ThinkingOutsideTheGrid (英語) で実行することもできます。

図形でコントロールを接続する

Silverlight アプリケーションや WPF アプリケーションに一連のコントロールがあり、2 つ以上のコントロールをなんらかの種類の図形で結ぶ必要があるとします。たとえば、あるコントロールから別のコントロールに直線を描画し、この直線は途中で他のコントロールと重なる可能性があるとしましょう。

もちろんこの直線には、ユーザーがウィンドウやページのサイズを変更する場合などに備えて、レイアウトの変化が反映される必要があります。レイアウトの更新時に通知を受ける処理は、LayoutUpdated イベントのすばらしい活用例です。LayoutUpdated イベントは、このコラムで説明する問題を調査するまで使用するチャンスがなかったイベントです。LayoutUpdated イベントは、WPF では UIElement で、Silverlight では FrameworkElement で定義されています。その名のとおり、このイベントは、レイアウト パスによって画面上の要素が再配置されると発生します。

LayoutUpdated イベントを処理するときは、レイアウト パスが再び開始され、無限に再帰処理が繰り返されるような処理にはしないでください。このような場合にこそ、キャンバスが役に立ちます。キャンバスから親要素へは、必ずサイズ 0 と報告されるため、レイアウトに影響を及ぼさずにキャンバス内の要素を変更できます。

ConnectTheElements プログラムの XAML ファイルは、次のような構造です。

<UserControl ... >
  <Grid ... >
    <local:SimpleUniformGrid ... >
      <Button ... />
      <Button ... />
      ...
    </local:SimpleUniformGrid>

    <Canvas>
      <Path ... /> 
      <Path ... />
      <Path ... />
    </Canvas>
  </Grid>
</UserControl>

グリッドには SimpleUniformGrid が含まれています。SimpleUniformGrid では、行数と列数を計算して、グリッド全体のサイズと縦横比に基づいて子要素を表示します。ウィンドウのサイズを変更すると、行数と列数が変化し、セルが並べ替えられます。この SimpleUniformGrid に含まれている 32 個のボタンのうち 2 つのボタンには、btnA と btnB という名前を付けています。キャンバスの領域は SimpleUniformGrid の領域と同じ位置を占めていますが、キャンバスの方が手前に表示されます。このキャンバスには、2 つの名前付きボタンを囲む楕円とその間を結ぶ直線を描画するためにプログラムから使用する Path 要素が含まれています。

分離コード ファイルの処理はすべて、LayoutUpdated イベントで実行されます。ここでは、2 つの名前付きボタンのキャンバスとの相対位置を特定する必要があります。便利なことに、キャンバスは SimpleUniformGrid、グリッド、および MainPage 自体に揃えて配置されています。

同じビジュアル ツリーに含まれる他の要素と任意の要素の相対位置を特定するには、TransformToVisual メソッドを使用します。このメソッドは、WPF では Visual クラスで、Silverlight では UIElement クラスで定義されていますが、どちらの環境でも動作は同じです。el2 要素が占有する領域内に el1 要素が配置されているとしましょう (ConnectTheElements プログラムの場合、el1 が Button 要素で el2 が MainPage 要素です)。このメソッド呼び出しは、GeneralTransform 型のオブジェクトを返します。これは、他のすべてのグラフィックス変換クラスの抽象親クラスです。

el1.TransformToVisual(el2)

実際に GeneralTransform クラスで行っているのは、Transform メソッドを呼び出すことだけです。Transform メソッドは、ある座標空間の点を別の座標空間に変換します。

el1 の中心座標を el2 の座標空間で特定するとします。コードは次のようになります。

Point el1Center = new Point(
  el1.ActualWidth / 2, el1.ActualHeight / 2); 
Point centerInEl2 = 
  el1.TransformToVisual(el2).Transform(el1Center);

el2 がキャンバスの場合、またはキャンバスと揃えて配置されている場合、上記のコードの centerInEl2 の点を使用してキャンバスにグラフィックスを配置すると、このグラフィックスは el1 の中心に配置されているように見えます。

ConnectTheElements プログラムでは、WrapEllipseAroundElement メソッドでこの変換を実行して 2 つの名前付きボタンの周りに楕円を描画し、ボタンの中心間を結ぶ直線の交点に基づいて、楕円間を結ぶ直線の座標を算出します。図 1 に、実行結果を示します。

image: The ConnectTheElements Display

図 1 ConnectTheElements の表示

このプログラムを WPF で試す場合は、SimpleUniformGrid を WrapPanel に置き換えると、プログラムのウィンドウのサイズ変更に応じて動的に変化します。

スライダーを追跡する

スクロール バーやスライダーの操作に応じてグラフィックスなどの表示を変更するのはごく基本的な処理でです。WPF や Silverlight では、この処理をコードと XAML バインディングの両方で実行できます。しかし、実際のスライダーのつまみに正確に合わせてグラフィックスを配置するにはどうすればよいでしょう。

この疑問に答えるために作成したのが TriangleAngles プロジェクトです。このプロジェクトは、三角法を対話的に説明するサンプルの一種として考案したものです。ここでは、垂直方向と水平方向の 2 つのスライダーを直角に配置します。2 つのスライダーのつまみは、直角三角形の 2 つの頂点になります (図 2 参照)。

image: The TriangleAngles Display

図 2 TriangleAngles の表示

2 つのスライダー上に半透明の三角形があります。スライダーのつまみを動かすと、三角形の辺の長さと比率が変化します。このことを示すために、三角形の内角と、垂直方向と水平方向のスライダーにラベルを示しています。

これも、明らかにキャンバスを重ね合わせる方法が適しています。ただし、プログラムからスライダーのつまみにアクセスする必要があるため、複雑さが増しています。スライダーのつまみは、コントロール テンプレートに含まれています。テンプレート内ではつまみに名前が付けられていますが、残念ながらテンプレート外からはこの名前にアクセスできません。

代わりに、よく重要になる VisualTreeHelper 静的クラスが役に立ちます。このクラスでは、GetParent メソッド、GetChildenCount メソッド、および GetChild メソッドを使用して、WPF や Silverlight の任意のビジュアル ツリーを探索できます (どちらかと言えば、1 階層ずつ調査できます)。特定の型の子要素の探索処理を汎用化するために、次のようにちょっとした再帰ジェネリック メソッドを作成しました。

T FindChild<T>(DependencyObject parent) 
  where T : DependencyObject

このメソッドは、次のように呼び出します。

Thumb vertThumb = FindChild<Thumb>(vertSlider);
Thumb horzThumb = FindChild<Thumb>(horzSlider);

このメソッドの代わりに、2 つのつまみの TransformToVisual メソッドを使用して、重なり合うキャンバスとの相対でつまみの座標を取得することもできます。

しかし、この手法は一方のスライダーには有効でしたが、もう一方のスライダーには有効ではありませんでした。しかも、Slider 要素のコントロール テンプレートに 2 つのつまみ (水平方向のつまみと垂直方向のつまみ) が含まれていることを思い出すまでしばらくかかりました。また、スライダーの向きによっては、テンプレートの真ん中あたりで Visibility プロパティの値が Collapsed に設定されています。そこで、FindChild メソッドに mustBeVisible という 2 つ目の引数を追加し、この引数を使用して要素が表示されていない子分岐の検索を中止しました。

三角形を描画する Polygon 要素の IsHitTestVisible プロパティを False に設定することで、この要素がスライダーのつまみをマウスで操作する際の邪魔にならないようにしました。

ItemsControl の外側をスクロールする

ItemsControl や ListBox を DataTemplate と併用して、コントロールのコレクションに含まれるオブジェクトを表示しているとします。この DataTemplate にキャンバスを含めて特定の項目に関する情報をコントロールの外部に表示し、コントロールをスクロールすると、項目の動きを追跡してその情報も移動するようにできるでしょうか。

私の調査では、このとおりの処理を実行する優れた方法はまだ見つかっていません。ScrollViewer によって生じるクリッピング領域が大きな問題になっているようです。ScrollViewer では、境界の外にはみ出したすべてのキャンバスがクリッピングされるため、そのキャンバスに配置されたすべての要素がクリッピングされます。

しかし、ItemsControl の内部処理について少し学ぶだけで、目的に近い処理を実行できます。

この機能は、ItemsControl の項目と関係があっても、ItemsControl 自体の外に飛び出しているという意味で、コールアウトのようなものです。この手法を実証するのが ItemsControlPopouts プロジェクトです。表示する項目を ItemsControl に渡すために、ここでは ProduceItems.xml という小さなデータベースを作成しました。このデータベースは、ClientBin ディレクトリの Data サブディレクトリに格納されています。ProduceItems データベースは、ProduceItem というタグ名の多数の要素で構成され、各要素には、Name 属性、Photo 属性 (項目に対応するビットマップを指します)、および省略可能な Message 属性 (ItemsControl から "飛び出して" 表示されます) が含まれています (このプロジェクトで使用する写真などの画像は、Microsoft Office クリップ アートです)。

ProduceItem クラスと ProduceItems クラスでは、コードで XML ファイルの機能を補完し、ProduceItemsPresenter クラスでは、XML ファイルを読み取って ProduceItems オブジェクトにシリアル化解除します。この ProduceItems オブジェクトを、ScrollViewer と ItemsControl が含まれるビジュアル ツリーの DataContext プロパティに設定します。ItemsControl には、項目を表示する単純な DataTemplate が含まれています。

ここで、ちょっとした問題に気が付いた方もいらっしゃるでしょう。プログラムでは、実質的に ProduceItem 型のビジネス オブジェクトが ItemsControl に挿入されています。ItemsControl の内部では、DataTemplate に基づいた項目ごとにビジュアル ツリーが構築されます。これらの項目の動きを追跡するには、その項目内部のビジュアル ツリーにアクセスして、プログラムの他の要素とその項目との正確な相対位置を特定する必要があります。

この情報はアクセス可能です。ItemsControl では、ItemContainerGenerator という、ItemContainerGenerator 型のオブジェクトを返す読み取り専用プロパティを定義しています。この ItemContainerGenerator クラスでは、ItemsControl の各項目に関連付けられたビジュアル ツリーを生成し、ContainerFromItem などの便利なメソッドを備えています。このメソッドは、コントロールに含まれる各オブジェクトのコンテナー (ここでは ContentPresenter) を返します。

他の 2 つのプログラムと同様に、ItemsControlPopouts プログラムのページ全体がキャンバスで覆われています。このプログラムでも、LayoutUpdated イベントを使用すると、キャンバス上の項目が変更されたかどうかをプログラムから確認できます。このプログラムの LayoutUpdated ハンドラーでは、ItemsControl の ProduceItem オブジェクトを列挙し、null でも空の文字列でもない Message プロパティがあるかどうかを確認します。これらの各 Message プロパティは、キャンバスに含まれている PopOut 型のオブジェクトに対応します。PopOut クラスは、直線とメッセージ テキストを表示するテンプレートが含まれる、ContentControl から派生した小さなクラスです。PopOut オブジェクトが存在していなければ、このオブジェクトを作成してキャンバスに追加します。存在していれば、その PopOut オブジェクトを再利用します。

続いて、キャンバス内での PopOut オブジェクトの位置を指定する必要があります。プログラムでは、データ オブジェクトに対応するコンテナーを取得して、コンテナーの位置をキャンバスとの相対位置に変換します。コンテナーの位置が ScrollViewer の上端と下端の間に含まれている場合は、PopOut オブジェクトの Visibility プロパティの値を Visible に設定します。この範囲に含まれていなければ、PopOut を非表示にします。

セルから自由になる

WPF と Silverlight では確かにレイアウトが非常に容易になりました。グリッドなどのパネルでは、要素がセルに整然と配置され、セルから飛び出すことはありません。だからと言って、任意の場所に要素を配置する自由が利便性の犠牲になっても仕方がないと考えるのは、もったいないでしょう。

Charles Petzold は MSDN Magazine の記事を長期にわたって担当している寄稿編集者です。最新の著書には『The Annotated Turing: A Guided Tour through Alan Turing's Historic Paper on Computability and the Turing Machine』(Wiley、2008 年) があります。Petzold のブログは、彼の Web サイト (charlespetzold.com、英語) で公開されています。

この記事のレビューに協力してくれた技術スタッフの Arathi Ramani と WPF レイアウト チームに心より感謝いたします。