WisdomSoft - for your serial experiences.

5.3 イベントルーティング

複数の UIElement が重ね合わせられている時、イベントがどのように UIElement の間で伝達されるのかを解説します。

5.3.1 バブリング・イベント

.NET Framework には、イベント処理のための機能が中間言語レベルでサポートされているため、WPF の描画要素に対するイベント処理においても従来の .NET Framework のイベント処理と同じ方法であることは説明しました。しかし、従来のイベント処理方法だけでは、WPF 固有の問題が生じます。WPF では、コントロールという概念がさらに抽象化され、画面に表示可能な、プレゼンテーション用のオブジェクトを UIElement として統合しています。よって、様々な図形とコントロールを組み合わせて表示することができます。

例えば、コントロール上に Shape オブジェクトや TextBlock などの UIElement を配置している場合、マウスクリックを処理するべきなのはどのオブジェクトでしょうか。ボタン上のコンテンツとして図形などを配置している場合、本来のクリックイベントはボタンに対して行われるべきです。ボタンなど、コントロール上に配置されている UIElement はコンテンツであり、コントロールのような対話を目的としたものではないはずです。

しかし、通常の解釈では、マウスイベントはコントロール上に配置されている最上面の UIElement で発生します。コントロールのコンテンツ上でマウスボタンが押された場合、そのイベントは最上位にあるコンテンツで発生すると考えられます。ところが、コンテンツで発生するマウスイベントが、本来コントロールで処理するべきイベントであれば、コントロール上のイベントが発生する可能性のある全てのコンテンツにデリゲートを登録しなければならなくなります。これは、開発者に不要な負担をかけてしまうことになるでしょう。

そこで、WPF では発生したイベントを階層関係を持つオブジェクト間に委託するルーティング機能を追加しています。即ち、イベントは最初にイベントが発生した UIElement だけで終わるのではなく、UIElement を保有する親または子オブジェクトに対しても投げられます。

コード1
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Shapes;
using System.Windows.Media;
using System.Windows.Input;

class Test : Window {
	[STAThread]
	public static void Main() {
		Window wnd = new Test();
		Application app = new Application();
		app.Run(wnd);
	}

	public Test() {
		Ellipse ellipse = new Ellipse();
		ellipse.Fill = Brushes.Black;
		ellipse.Width = 100;
		ellipse.Height = 100;
		ellipse.MouseDown += ellipseMouseDown;

		TextBlock textBlock = new TextBlock();
		textBlock.Text = "ごきげんよう";
		textBlock.FontSize = 40;
		textBlock.MouseDown += textBlockMouseDown;

		StackPanel panel = new StackPanel();
		panel.Background = Brushes.AliceBlue;
		panel.Orientation = Orientation.Horizontal;
		panel.Children.Add(ellipse);
		panel.Children.Add(textBlock);
		panel.MouseDown += panelMouseDown;

		Content = panel;
	}

	private void panelMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("panelMouseDown");
	}
	private void ellipseMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("ellipseMouseDown");
	}
	private void textBlockMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("textBlockMouseDown");
	}
}
実行結果
コード1 実行結果

コード1では、Ellipse オブジェクトと TextBlock オブジェクトを追加した StackPanel オブジェクトを表示しています。表示されている Ellipse オブジェクトと TextBlock オブジェクト、そして、これらの親パネルとなっている StackPanel には、MouseDown イベントを受けるデリゲートが登録されています。それぞれのオブジェクトに対してマウスをクリックすると、イベントがどのような順番で発生しているかを調べることができます。

本来ならば、黒い楕円形の上でマウスボタンを押すと、イベントは Ellipse オブジェクトの MouseDown として発生するのみですが、イベントルーティングの機能によって Ellipse オブジェクトで発生したイベントは、Ellipse オブジェクトを保有しているパネルにも発生します。イベントハンドラでは、どのイベントが発生したのかを視覚的に確認できるように、標準出力に文字列を出力しています。Ellipse オブジェクト上でマウスボタンをクリックすると、上の実行結果が得られます。

実行結果を見ると、最初に Ellipse オブジェクトで MouseDown イベントが発生し、その後、親パネルである StackPanel で発生しています。この結果を見れば、イベントが親子関係のあるオブジェクト間で共有されていることが確認できます。WPF では、親コントロールが、コンテンツとなる UIElement のイベントを監視するようなコードを書く必要はありません。最前面のコンテンツ上で発生したイベントは、親コントロールにも投げられます。

このように、イベントが発生した直接のオブジェクトから、そのオブジェクトを保有するパネルを通してルートに向かって処理されるイベントをバブリングと呼びます。もしくは、バブル型のイベントと呼んでも良いでしょう。

5.3.2 トンネリング・イベント

バブリングとは逆に、イベントが発生したオブジェクトのルートから発生するイベントのことをトンネリング(またはトンネル型のイベント)と呼びます。トンネリングは、最下位のルートパネルから順番に、最上位のイベントが発生した UIElement に向かってイベントが発生します。バブリングとの違いは、イベントの発生手順です。

WPF は、コントロールも含めて全ての要素を WPF の内部で描画しています。よって、Windows 32 API における HWND 型のハンドルは、最上位のトップレベルウィンドウのみということになり、ボタンなども含めた全てのコントロールは、Windows のネイティブ API ではなく、WPF 内部で用意されたものです。よって、実質的に Windows からメッセージを受け取っているのはトップレベルのウィンドウであり、そこからイベントの種類に応じて、イベントが発生したオブジェクトに向かってイベントが回されることになります。

つまり、トンネリング・イベントはバブリング・イベントよりも確実に左記に発行されるイベントです。WPF の仕組から、最初にルートパネルから最上位の UIElement に向かってトンネリング・イベントが発生し、その後、最上位の UIElement から最下位に向かってバブリング・イベントが発生することになります。

コード1の実行結果を見ると MouseDown はバブリング・イベントであることがわかります。トンネリング・イベントは、対応するバブリング・イベントのイベント名の前に Preview という接頭辞を付加しています。例えば、MouseDown イベントに対応するトンネリング・イベントは PreviewMouseDown イベントとなります。

UIElement クラス PreviewMouseDown イベント
public event MouseButtonEventHandler PreviewMouseDown

詳細は割愛しますが、他のイベントでも同様の法則が適用されます。例えば、MouseUp に対しては PreviewMouseUp イベント、KeyDown イベントに対しては PreviewKeyDown イベントが用意されています。

コード2
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Shapes;
using System.Windows.Media;
using System.Windows.Input;

class Test : Window {
	[STAThread]
	public static void Main() {
		Window wnd = new Test();
		Application app = new Application();
		app.Run(wnd);
	}

	public Test() {
		Ellipse ellipse = new Ellipse();
		ellipse.Fill = Brushes.Black;
		ellipse.Width = 100;
		ellipse.Height = 100;
		ellipse.MouseDown  += ellipseMouseDown;
		ellipse.PreviewMouseDown  += ellipsePreviewMouseDown;

		DockPanel panel = new DockPanel();
		panel.Background = Brushes.AliceBlue;
		panel.Children.Add(ellipse);
		panel.MouseDown  += panelMouseDown;
		panel.PreviewMouseDown  += panelPreviewMouseDown;

		Content = panel;
	}

	private void panelMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("バブリング panelMouseDown");
	}
	private void ellipseMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("バブリング ellipseMouseDown");
	}

	private void panelPreviewMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("トンネリング panelMouseDown");
	}
	private void ellipsePreviewMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("トンネリング ellipseMouseDown");
	}
}
実行結果
コード2 実行結果

コード2は、トンネリング・イベントとバブリング・イベントがどのような手順で実行されるのかを調べるためのプログラムです。画面上の黒い楕円形は、パネル上に配置された Ellipse オブジェクトで、その背面にある薄い青色の背景が親パネルである DockPanel オブジェクトです。それぞれのオブジェクトには PreviewMouseDown イベントと MouseDown イベントに、デリゲートが追加されています。

領域上でマウスボタンを押すとイベントが発生し、それぞれのオブジェクトに追加されているイベントハンドラが呼び出されます。イベントハンドラでは、どのイベントが発生したのかを視覚的に確認するため、標準出力に文字列を出力しています。Ellipse オブジェクト上でマウスボタンを押すと、上の実行結果が得られます。

この結果を見れば、マウスボタンを押した瞬間に、一番最初に発生しているイベントは、楕円形の Ellipse オブジェクトではなく、親パネルのトンネリング・イベントであることが確認できます。その後、トンネリング・イベントは子 UIElement にイベントを渡していくため Ellipse で PreviewMouseDown が発生します。最後の子 UIElement にまでトンネリング・イベントが渡されると、その後、通常のバブリング・イベントが逆方向に発生しています。

5.3.3 ルーティングの制御

ある程度の開発経験がある方ならば、この便利なルーティング機能が、イベントの種類によっては様々な問題を発生させる原因になることを想像したことでしょう。開発シナリオによっては、親パネルと子コンテンツに独立した MouseDown イベントを与える必要があるかもしれません。しかし、MouseDown はバブリング・イベントなので、子コンテンツで発生したイベントは親パネルにも伝えられます。これが、好ましくない場合も十分に考えられます。他にも、プログラムの都合でイベントを隠蔽したり、イベントを変換したい場合も問題になります。

この問題は、イベントハンドラが受け取るパラメータを使って解決することができます。WPF のルーティング可能なイベントに登録するデリゲートには、必ず System.Windows.RoutedEventArgs クラスを継承するオブジェクトを受け取ります。例えば、MouseDown イベントで呼び出されるメソッドは、 MouseButtonEventArgs クラス型のオブジェクトをパラメータから受け取っていました。MouseButtonEventArgs クラスは、RoutedEventArgs クラスを継承しています。

System.Windows.RoutedEventArgs クラス
System.Object 
   System.EventArgs 
    System.Windows.RoutedEventArgs
public class RoutedEventArgs : EventArgs

RoutedEventArgs クラスは、ルーティングの制御に必要な情報を提供し、またはイベントハンドラから設定することができます。イベントのルーティングを、現在実行中のイベントハンドラで停止させたい場合は Handled プロパティを使います。

RoutedEventArgs クラス Handled プロパティ
public bool Handled { get; set; }

ルーティングが行われている途中の場合、このプロパティには false が設定されています。ルーティングを強制的に終了させたい場合は、このプロパティに true を設定してイベントハンドラを終了させてください。このプロパティが true に設定されると、現在実行中のイベントハンドラを最後にイベントのルーティングは終了します。

コード3
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Shapes;
using System.Windows.Media;
using System.Windows.Input;

class Test : Window {
	[STAThread]
	public static void Main() {
		Window wnd = new Test();
		Application app = new Application();
		app.Run(wnd);
	}

	public Test() {
		Ellipse ellipse = new Ellipse();
		ellipse.Fill = Brushes.Black;
		ellipse.Width = 100;
		ellipse.Height = 100;
		ellipse.MouseDown  += ellipseMouseDown;
		ellipse.PreviewMouseDown  += ellipsePreviewMouseDown;

		DockPanel panel = new DockPanel();
		panel.Background = Brushes.AliceBlue;
		panel.Children.Add(ellipse);
		panel.MouseDown  += panelMouseDown;
		panel.PreviewMouseDown  += panelPreviewMouseDown;

		Content = panel;
	}

	private void panelMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("バブリング panelMouseDown");
	}
	private void ellipseMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("バブリング ellipseMouseDown");
	}

	private void panelPreviewMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("トンネリング panelMouseDown");
		e.Handled = true;
	}
	private void ellipsePreviewMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("トンネリング ellipseMouseDown");
	}
}

コード3のウィンドウの外観は コード2と同じです。このプログラムでは、親パネルである DockPanel、すなわちルートオブジェクト上で発生した PreviewMouseDown イベントで Handled プロパティを true に設定しています。これによって、DockPanel 上で発生したマウスイベントのルーティングはその場で終了し、トンネリングは行われません。当然、その結果としてバブリングも発生しないため、他のイベントにとってはイベントが発生していないように振舞うことができます。結果として、このプログラムでは panelPreviewMouseDown メソッドのみが呼び出され、他のメソッドが呼び出されることはありません。

5.3.4 イベントの発生元

.NET Framework のイベントの仕組みでは、イベントハンドラが受け取る第1パラメータにはイベントの発生元オブジェクトが渡されることになっていました。しかし、この情報をルーティングされるイベントの中で利用する場合は注意が必要です。このパラメータに設定されるオブジェクトは、本質的な発生元ではなく、イベントハンドラに関連付けられているオブジェクトです。

ルーティング・イベント上の本質的なイベント発生源を取得するには、第1パラメータではなく RoutedEventArgs オブジェクトが提供する OriginalSource プロパティを使います。

RoutedEventArgs クラス OriginalSource プロパティ
public Object OriginalSource { get; }

このプロパティから取得できるオブジェクトは、イベントハンドラの第1パラメータとは異なり、バブリングやトンネリングのイベント発生元を返します。

コード4
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Shapes;
using System.Windows.Media;
using System.Windows.Input;

class Test : Window {
	[STAThread]
	public static void Main() {
		Window wnd = new Test();
		Application app = new Application();
		app.Run(wnd);
	}

	public Test() {
		Ellipse ellipse = new Ellipse();
		ellipse.Fill = Brushes.Black;
		ellipse.Width = 100;
		ellipse.Height = 100;
		ellipse.MouseDown  += ellipseMouseDown;

		DockPanel panel = new DockPanel();
		panel.Background = Brushes.AliceBlue;
		panel.Children.Add(ellipse);
		panel.MouseDown  += panelMouseDown;

		Content = panel;
	}

	private void panelMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("---親パネル MouseDown---");
		Console.WriteLine("sender=" + sender);
		Console.WriteLine("Source=" + e.OriginalSource);
	}
	private void ellipseMouseDown(object sender, MouseButtonEventArgs e) {
		Console.WriteLine("---楕円形 MouseDown---");
		Console.WriteLine("sender=" + sender);
		Console.WriteLine("Source=" + e.OriginalSource);
	}
}
実行結果
コード4 実行結果

コード4を実行して表示される黒い楕円形をクリックすると、楕円形と親パネルに登録されている MouseDown イベントが発生し、標準出力に文字列が出力されます。黒い楕円形の上でマウスのボタンを押すと上の実行結果が得られます。

黒い楕円形は Ellipse オブジェクトなので、楕円形に関連付けられているイベントハンドラでは、sender パラメータと OriginalSource は同一のオブジェクトです。これは、イベントハンドラを関連付けている Ellipse オブジェクトと、イベントの発生元である Ellipse オブジェクトが同一なので当然です。

次に、このイベントはバブリングによるルーティングによって親パネルに伝えられます。親パネルに関連付けられているイベントハンドラでは、sender パラメータに格納されているオブジェクトが DockPanel になっています。当然、このオブジェクトは本質的なイベント発生元ではなく、イベントハンドラが関連付けられているオブジェクトです。これに対して、OriginalSource プロパティから得られたオブジェクトは Ellipse 型であることから、適切な発生元のオブジェクトであることが確認できます。