【WPF学习】第十四章 事件路由
由上一章可知,WPF中的许多控件都是内容控件,而内容控件可包含任何类型以及大量的嵌套内容。例如,可构建包含图形的按钮,创建混合了文本和图片内容的标签,或者为了实现滚动或折叠的显示效果而在特定容器中放置内容。设置可以多次重复嵌套,直至达到你所希望的层次深度。如下所示:
<Window x:Class="RouteEvent.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="350" Width="525"> <Grid> <Label BorderThickness="1" BorderBrush="Black"> <StackPanel> <TextBlock Margin="3">Image and text label</TextBlock> <Image Source="face.jpg" Stretch="Fill" Width="64" Height="64"></Image> <TextBlock Margin="3">Courtesy of the StackPanel</TextBlock> </StackPanel> </Label> </Grid> </Window>
正如上面所看到的,放在WPF窗口中的所有要素都在一定层次上继承自UIElement类,包括Label、StackPanel、TextBlock和Image。UIElement定义了一些核心事件。例如,每个继承自UIElement的类都提供了MouseDown事件和MouseUp事件。
但当单击上面这个特殊标签中的图像部分时,想一想会发生什么事情。很明显,引发Image.MouseDown事件和Image.MouseUp事件是合情合理的。但如果希望采用相同的方式来处理标签上的所有单击事件,该怎么办呢?此时,不管单击了图像、某块文本还是标签内的空白处,都应当使用相同的代码进行相应。
显然,可为每个元素的MouseDown或MouseUp事件关联同一个事件处理程序,但这样会是标记变得杂乱无章且难以维护。WPF使用路由事件模型提供了一个更好的解决方案。
路由事件实际上以下列三种方式出现:
- 与普通.NET事件类似的直接路由事件(direct event)。它们源于一个元素,不传递给其他元素。例如,MouseEnter事件(当鼠标指针移到元素上时发生)是直接路由事件。
- 在包含层次中向上传递的冒泡路由事件(bubbling event)。例如,MouseDown事件就是冒泡路由事件。该事件首先由被单击的元素引发,接下来被该元素的父元素引发,然后被父元素的父元素引发,依此类推,直到WPF到达元素树的顶部为止。
- 在包含层次中向下传递的隧道路由事件(tunneling event)。隧道路由事件在事件到达恰当的控件之前为预览事件(甚至终止事件)提供了机会。例如,通过PreviewKeyDown事件可截获是否按下了某个键。首先在窗口级别上,然后是更具体的容器,直至到达当按下键时具有焦点的元素。
当使用EventManager.RegisterEvent()方法注册路由事件时,需要传递一个RoutingStrategy枚举值,该值用于指示希望应用于事件的事件行为。
MouseUp事件和MouseDown事件都是冒泡路由事件,因此现在可以确定在上面特殊的标签示例中会发生什么事情。当单击标签上的图像部分时,按一下顺序触发MouseDown事件:
(1)Image.MouseDown事件
(2)StackPanel.MouseDown事件
(3)Label.MouseDown事件
为标签引发了MouseDown事件后,该事件会传递到下一个控件(在本例中是位于窗口中的Grid控件),然后传递到Grid控件的父元素(窗口)。窗口时整个层次中的顶级元素,并且是事件冒泡顺序的最后一站,它是处理冒泡路由事件(如MouseDown事件)的最后机会。如果用户释放了鼠标按键,就会按相同的顺序触发MouseUp事件。
没有限制要在某个位置处理冒泡路由事件。实际上,完全可在任意层次上处理MouseDown事件或MouseUp事件。但通常选择最合适的事件路由层次完成这一任务。
一、RoutedEventArgs类
在处理冒泡路由事件时,sender参数提供了对整个链条上最后那个链接的引用。例如,在上面的示例中,如果事件在处理之前,从图像向上冒泡到标签,sender参数就会引用标签对象。
有些情况下,可能希望确定事件最初发生的位置。可从RoutedEventArgs类的属性(如下表所示)获得这一信息以及其他细节。由于所有WPF事件参数类继承自RoutedEventArgs,因此任何事件处理程序都可以使用这些属性。
表 RoutedEventArgs类的属性
二、冒泡路由事件
如下图显示了一个简单窗口,该窗口演示了事件的冒泡过程。当单击标签中的一部时,在列表框中显示事件发生的顺序。图中显示了单击标签中的图像之后窗口的情况。MouseUp事件传递了5级,在窗体中停止向上传递。
图 冒泡的图像单击事件
要创建该测试窗口,将元素层次结构中的图像以及它上面的每个元素都关联到同一个事件处理程序——名为SomethingClicked()的方法。下面是所需的XAML标记:
<Window x:Class="RouteEvent.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="359" Width="329" MouseUp="SomethingClicked"> <Grid Margin="3" MouseUp="SomethingClicked"> <Grid.RowDefinitions> <RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="*"></RowDefinition> <RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="Auto"></RowDefinition> </Grid.RowDefinitions> <Label Margin="5" Grid.Row="0" HorizontalAlignment="Left" Background="AliceBlue" BorderThickness="1" BorderBrush="Black" MouseUp="SomethingClicked"> <StackPanel MouseUp="SomethingClicked"> <TextBlock Margin="3" MouseUp="SomethingClicked">Image and text label</TextBlock> <Image Source="face.jpg" Stretch="Fill" Width="16" Height="16" MouseUp="SomethingClicked"></Image> <TextBlock Margin="3" MouseUp="SomethingClicked">Courtesy of the StackPanel</TextBlock> </StackPanel> </Label> <ListBox Grid.Row="1" Margin="5" Name="lstMessages"></ListBox> <CheckBox Grid.Row="2" Margin="5" Name="chkHandle">Handle first event</CheckBox> <Button Grid.Row="3" Margin="5" Padding="3" HorizontalAlignment="Right" Name="cmdClear" Click="cmdClear_Click">Clear list</Button> </Grid> </Window>
SomethingClicked()方法简单地检查RoutedEventArgs对象的属性,并且给列表框添加消息:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Windows; using System.Windows.Controls; using System.Windows.Data; using System.Windows.Documents; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Navigation; using System.Windows.Shapes; namespace RouteEvent { /// <summary> /// MainWindow.xaml 的交互逻辑 /// </summary> public partial class MainWindow : Window { protected int eventCounter = 0; public MainWindow() { InitializeComponent(); } private void SomethingClicked(object sender, RoutedEventArgs e) { eventCounter++; string message = "#" + eventCounter.ToString() + ":\r\n" + " Sender: " + sender.ToString() + "\r\n" + " Source: " + e.Source + "\r\n" + " Original Source: " + e.OriginalSource + "\r\n"; lstMessages.Items.Add(message); e.Handled = (bool)chkHandle.IsChecked; } private void cmdClear_Click(object sender, RoutedEventArgs e) { eventCounter = 0; lstMessages.Items.Clear(); } } }
在本例中还有一个细节。如果选中chkHandle复选框,SomethingClicked()方法就将RoutedEventArgs.Handled属性设为true,从而在事件第一次发生时就终止事件的冒泡过程。因此,这时在列表框中就只能看到第一个事件,如下图所示:
因为SomethingClicked()方法处理由Window对象引发的MouseUp事件,所以也能截获在列表框和窗口表面空白处的鼠标单击事件。但当单击Clear按钮时(这会删除所有列表框条目)不会引发MouseUp事件,这时因为按钮包含了一些有趣的代码,这些代码会挂起MouseUp事件,并引发更高级的Click事件。同时,Handled标记被设置为true,从而会阻止MouseUp事件继续传递。
三、处理挂起的事件
有一种方法可接受被标记处理过的事件。不是通过XAML关联事件处理程序,而是必须使用前面介绍的AddHandler()方法。AddHandler()方法提供了一个重载版本,该版本可以接收一个Boolean值作为它的第三个参数。如果将该参数设置为true,那么即使设置了Handled标记,也将接收到事件:
cmdClear.AddHandler(UIElement.MouseUpEvent,new MouseButtonEventHandler(cmdClear_MouseUp),true);
这通常并不是正确的设计决策。为防止可能造成的困惑,按钮被设计为会挂起MouseUp事件。毕竟,可采用多种方式使用键盘“单击”按钮,这是Windows中非常普遍的约定。如果为按钮错误地处理了MouseUp事件,而没有处理Click事件,那么事件处理代码就只能对鼠标单击做出相应,而不能对相应的键盘操作做出相应。
四、附加事件
上面这个有趣的标签示例是一个非常简单的事件冒泡示例,因为所有的元素都支持MouseUp事件。然而,许多控件有各自的特殊事件。按钮便是一个例子——它添加了Click事件,而其他任何基类都没有定义该事件。
这导致两难的境地。假设在StackPanel面板中封装了一堆按钮,并希望在一个事件处理程序中处理所有这些按钮的单击事件。粗略的方法是将每个按钮的Click事件关联到同一个事件处理程序。但Click事件支持事件冒泡,从而提供了一种更好的选择。可通过处理更高层次元素的Click事件(如包含按钮的StackPanel面板)来处理所有按钮的Click事件。
但看似浅显的代码却不能工作:
<StackPanel Click="DoSomething" Margin="5"> <Button Name="cmd1">Command 1</Button> <Button Name="cmd2">Command 2</Button> <Button Name="cmd3">Command 3</Button> ... </StackPanel>
问题在于StackPanel面板没有Click事件,所以XAML解析器会将其解释错误。解决方案是以“类名.事件名"的形式使用不同的关联事件语法。下面是更正后的示例:
<StackPanel Button.Click="DoSomething" Margin="5"> <Button Name="cmd1">Command 1</Button> <Button Name="cmd2">Command 2</Button> <Button Name="cmd3">Command 3</Button> ... </StackPanel>
现在,事件处理程序可以接收到StackPanel面板包含的所有按钮的单击事件了。
可在代码中关联附加事件,但需要使用UIElement.AddHandler()方法,而不能使用+=运算符语法。下面是一个示例(该例假定StackPanel面板已被命名为pnlButtons):
pnlButtons.AddHandler(Button.Click,new RoutedEventHandler(DoSomething));
在DoSomething()事件处理程序中,可使用多种方法确定是哪个按钮引发了事件。可以比较按钮的文本(对与本地化这可能会引起问题),也可以比较按钮的名称(这是脆弱的方法,因为当构建应用程序时无法捕获输入错误的名称)。最好确保每个按钮在XAML中都有Name属性设置,从而可以通过窗口类的一个字段访问相应的对象,并使用事件发送者比较应用。下面列举一个示例:
private void DoSomething(object sender,RoutedEventArgs e) { if(sender==cmd1) { ... } else if(sender==cmd2) { ... } else if(sender==cmd3) { ... } ... }
另一个选择是简单地随按钮传递一段可以在代码中使用的信息。比如设置每个按钮的Tag属性。在此不列举出具体实例。
五、隧道路由事件
随着路由事件的工作方式和冒泡路由事件相同,当方向相反。例如,如果MouseUp事件是隧道路由事件(实际上不是),在特殊的标签示例中单击图形将导致MouseUp事件首先在窗口中被引发,然后在Grid控件中被引发,接下来在StackPanel面板中呗引发,依此类推,直至到达实际源头,即标签中的图像为止。
隧道路由事件易于识别,他们都以单词Preview开头。而且,WPF通常成对地定义冒泡路由事件和隧道路由事件。这意味着如果发现冒泡的MouseUp事件,就还可以找到PreviewMouseUp隧道事件。隧道路由事件总在冒泡路由事件之前被触发。如下图所示:
更有趣的是,如果将隧道路由事件标记为已处理过,那就不会发生冒泡路由事件。这是因为两个事件共享RoutedEventArgs类的同一个实例。
如果需要执行一些预处理(根据键盘上特定的键执行动作或过滤掉特定的鼠标动作),隧道路由事件是非常有用的。
如下面实例所示,该例测试PreviewKeyDown事件的隧道过程。当在文本框按下一个键时,事件首先在窗口触发,然后再整个层次结构中向下传递。如果在任何位置将PreviewKeyDown事件标记为已处理过,就不会发生冒泡的KeyDown事件。
下面是所需的XAML标记:
<Window x:Class="TunnelRouteEvent.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="359" Width="329" PreviewKeyDown="SomethingClicked"> <Grid Margin="3" PreviewKeyDown="SomethingClicked"> <Grid.RowDefinitions> <RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="*"></RowDefinition> <RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="Auto"></RowDefinition> </Grid.RowDefinitions> <Label Margin="5" Grid.Row="0" HorizontalAlignment="Left" Background="AliceBlue" BorderThickness="1" BorderBrush="Black" PreviewKeyDown="SomethingClicked"> <StackPanel PreviewKeyDown="SomethingClicked"> <TextBlock Margin="3" PreviewKeyDown="SomethingClicked">Image and text label</TextBlock> <Image Source="face.jpg" Stretch="Fill" Width="16" Height="16" PreviewKeyDown="SomethingClicked"></Image> <TextBox Margin="3" PreviewKeyDown="SomethingClicked"></TextBox> </StackPanel> </Label> <ListBox Grid.Row="1" Margin="5" Name="lstMessages"></ListBox> <CheckBox Grid.Row="2" Margin="5" Name="chkHandle">Handle first event</CheckBox> <Button Grid.Row="3" Margin="5" Padding="3" HorizontalAlignment="Right" Name="cmdClear" Click="cmdClear_Click">Clear list</Button> </Grid> </Window>
后台代码如下所示:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Windows; using System.Windows.Controls; using System.Windows.Data; using System.Windows.Documents; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Navigation; using System.Windows.Shapes; namespace TunnelRouteEvent { /// <summary> /// MainWindow.xaml 的交互逻辑 /// </summary> public partial class MainWindow : Window { protected int eventCounter = 0; public MainWindow() { InitializeComponent(); } private void SomethingClicked(object sender, RoutedEventArgs e) { eventCounter++; string message = "#" + eventCounter.ToString() + ":\r\n" + " Sender: " + sender.ToString() + "\r\n" + " Source: " + e.Source + "\r\n" + " Original Source: " + e.OriginalSource + "\r\n" + " Event: " + e.RoutedEvent; lstMessages.Items.Add(message); e.Handled = (bool)chkHandle.IsChecked; } private void cmdClear_Click(object sender, RoutedEventArgs e) { eventCounter = 0; lstMessages.Items.Clear(); } } }