路由事件能沿着元素树隧道向下或冒泡向上旅游,并且被沿路的事件处理器处理。路由事件能在一个元素上被处理(诸如一个标签)即使它由另一个元素发起(诸如该标签内部的一个图像)。正如依赖属性,路由事件能用传统的方法消费—用正确签名连接一个事件处理器—但是你需要理解他们如何工作,以了解它们的所有特征。
.NET开发者都熟悉事件的思想—对象(诸如WPF元素)发送消息通知你的代码有事发生。WPF用事件路由增强.NET事件模型的概念。事件路由允许事件由一个元素发起,但是由另一个元素处理。例如,点击工具栏按钮,开始事件,上升到工具栏,然后到包含窗口,直到事件被你的代码处理。
事件路由让你能灵活地写紧凑、组织良好的代码,在最便利的地方处理事件。对于WPF内容模型,事件路由也是必要的,它允许建立简单元素(如按钮)包含一打不同的成分,每个都有自己独立的事件集合。
路由事件是只读静态字段,在静态构造函数内注册,包装为标准的.net事件定义。
例如,Button类提供了Click事件,它从ButtonBase抽象类继承。这是事件的定义与注册代码:
public abstract class ButtonBase : ContentControl, ... { // 定义事件 public static readonly RoutedEvent ClickEvent; // 注册事件 static ButtonBase() { ButtonBase.ClickEvent = EventManager.RegisterRoutedEvent( "Click", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ButtonBase)); ... } // 包装事件 public event RoutedEventHandler Click { add { base.AddHandler(ButtonBase.ClickEvent, value); } remove { base.RemoveHandler(ButtonBase.ClickEvent, value); } } ... }
路由事件用EventManager.RegisterRoutedEvent()方法注册。你要指定事件名称,路由类型,定义事件处理器语法的委托(本例中,RoutedEventHandler),和拥有事件的类(本例中,ButtonBase)。
通常,路由事件包装为普通的.NET事件,所有的.NET语言都可以访问路由事件。事件包装器使用AddHandler()和RemoveHandler()方法添加和移除注册调用者,二者都被定义在FrameworkElement基类中,所有WPF元素都继承了这两个方法。
路由事件能在类之间共享。例如,二个基类UIElement和ContentElement都使用了MouseUp事件,MouseUp事件定义在System.Windows.Input.Mouse类中。UIElement和ContentElement类简单地使用RoutedEvent.AddOwner()方法重用事件:
UIElement.MouseUpEvent = Mouse.MouseUpEvent.AddOwner(typeof(UIElement));
当然,和任何事件一样,定义类需要在某些点引起路由事件,确切地发生在哪里是一个实现细节。但是,重要的因素是你的事件不是通过传统.NET事件包装器引起的。而是使用RaiseEvent()方法,所有元素从UIElement类继承了此方法。这是ButtonBase类中的相应代码:
var e = new RoutedEventArgs(ButtonBase.ClickEvent, this); base.RaiseEvent(e);
RaiseEvent()方法负责引发事件到每个调用者。调用者可以直接调用AddHandler()注册自己,或他们能使用事件包装器。
所有的WPF事件签名遵守.NET约定。第一个参数是引发事件的对象(sender)。第二个参数是EventArgs对象,附加可能用到的所有细节。例如,MouseUp事件提供MouseEventArgs对象,指示当事件发生时鼠标按压了哪个按钮:
private void img_MouseUp(object sender, MouseButtonEventArgs e) { }
如果一个事件不需要传送额外的细节,则使用RoutedEventArgs类,它包含一些关于事件如何被路由的细节。如果事件需要传送额外的信息,它使用一个更特殊的RoutedEventArgs派生对象(诸如MouseButtonEventArgs)。因为每个WPF事件参数类从RoutedEventArgs派生,每个WPF事件处理器都能够访问关于事件路由的信息。
可以在XAML标记中附加事件:
<Image Source="happyface.jpg" Stretch="None" Name="img" MouseUp="img_MouseUp" />
事件处理方法的命名约定是:ElementName_EventName
用代码连接事件处理器:
img.MouseUp += new MouseButtonEventHandler(img_MouseUp);
这个代码按照要求的签名为事件构造了委托对象(MouseButtonEventHandler),将委托指向img_MouseUp()方法,然后将委托添加到为img.MouseUp事件注册的事件处理器列表。
可以省略委托对象MouseButtonEventHandler的构造,以上代码也可以简写为:
img.MouseUp += img_MouseUp;
也可以直接使用UIElement.AddHandler() 连接事件处理器:
img.AddHandler(Image.MouseUpEvent,new MouseButtonEventHandler(img_MouseUp));
这个方法需要显示构造委托对象MouseButtonEventHandler,因为UIElement.AddHandler()方法支持所有的WPF事件,不知道你要添加的委托类型。
一些开发者更喜欢使用事件被定义类的名字,而不是事件被引发类的名字,这是等价的代码,明确MouseUp事件是定义在UIElement中:
img.AddHandler(UIElement.MouseUpEvent,new MouseButtonEventHandler(img_MouseUp));
解除事件处理器只能通过代码,可以使用自减操作符:
img.MouseUp -= img_MouseUp;
也可以使用UIElement.RemoveHandler()方法:
img.RemoveHandler(Image.MouseUpEvent,new MouseButtonEventHandler(img_MouseUp));
不要多次重复添加事件处理器。
路由事件有三个流派:
对于冒泡事件,sender参数指向链条最后一环。例如,如果一个事件从图像冒泡到标签,则sender参数指向标签。
RoutedEventArgs类的属性:
名称 | 描述 |
Source | 指示什么对象引发事件。当事件发生时,在键盘事件情况下,指拥有焦点的控件。在鼠标事件情况下,指鼠标指针下最顶的元素。 |
OriginalSource | 指最初引发事件的对象。通常,OriginalSource等同于Source。但是,在某些情况下,OrignialSource指对象树更深的元素。例如,如果点击了窗口的边界,Source是Window对象,但是,OriginalSource是Border对象。 |
RoutedEvent | 提供RoutedEvent对象,用于事件处理器的触发,如静态的UIElement.MouseUpEvent对象。这些信息常用在使用相同的事件处理器处理不同的事件时。 |
Handled | 用于中止冒泡或者隧道过程。当Handled为true时,事件不再旅游,不会再为任何其他的元素引发事件。 |
下面是一个简单的窗口用来演示事件冒泡。当你点击标签的一个部分,事件次序被显示在一个列表框。MouseUp事件旅行通过5层,向上结束在BubbledLabelClick窗口。
为创造这测试窗格,图像和它的每个长辈元素被连线到相同的事件处理器—SomethingClicked()方法。
<Window x:Class="RoutedEvents.BubbledLabelClick" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="BubbledLabelClick" 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" BorderBrush="Black" BorderThickness="1" MouseUp="SomethingClicked"> <StackPanel MouseUp="SomethingClicked"> <TextBlock Margin="3" MouseUp="SomethingClicked"> Image and text label</TextBlock> <Image Source="happyface.jpg" Stretch="None" MouseUp="SomethingClicked" /> <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对象的属性和添加一个消息到列表框:
protected int eventCounter = 0; 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; lstMessages.Items.Add(message); e.Handled = (bool)chkHandle.IsChecked; }
如果你已经选择chkHandle复选框,SomethingClicked()方法设置RoutedEventArgs.Handled属性为真,在事件第一次发生时,停止事件冒泡序列。结果,你将会看到只有第一个事件出现在列表。
因为SomethingClicked()方法处理由Window引发的MouseUp事件,你将能拦截在列表框和空白的窗口表面上的点击。但是,当你点击Clear按钮(它移除所有的列表框内容)时,MouseUp事件不会被引发。那是因为按钮压缩掉了MouseUp事件,和引起一个更高水平的Click事件。与此同时,Handled标记被设置为真,阻止MouseUp事件继续。
使用AddHandler()方法,设置第三个参数为true,即使Handled标志为真,也会收到事件:
cmdClear.AddHander(UIElement.MouseUpEvent, new MouseButtonEventHandler(cmdClear_MouseUp), true);
从设计角度讲,这不是一个好决定。
一个面板,里面有一组按钮。众所周知,面板不支持按钮的Click事件,那么如何在面板上定义这组按钮的Click事件?
答案是使用附加事件。附加事件的语法形如:ClassName.EventName,这是代码示例:
<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>
在使用代码绑定附加事件时,只能使用 UIElement.AddHandler()方法,不能使用自加操作符。
pnlButtons.AddHandler(Button.Click, new RoutedEventHandler(DoSomething));
在DoSomething()方法中,有几个方法可以获知是哪一个按钮引发的事件:
对象比较法测试按钮:
private void DoSomething(object sender, RoutedEventArgs e) { if (e.Source == cmd1) { ... } else if (e.Source == cmd2) { ... } else if (e.Source == cmd3) { ... } }
使用Tag标签测试按钮:
private void DoSomething(object sender, RoutedEventArgs e) { object tag = ((FrameworkElement)sender).Tag; MessageBox.Show((string)tag); }
隧道事件都以单词Preview开头,隧道事件与冒泡事件通常成对出现,总是先引发隧道事件,再引发冒泡事件。
隧道事件与冒泡事件工作相同,方向相反。例如,上例中,如果定义的是隧道事件PreviewMouseUp,点击标签中的图像后,首先引发窗口的PreviewMouseUp事件,然后是Grid,然后是StackPanel等等,直到到达实际源,就是标签中的图像。
如果你标记隧道事件为Handled,冒泡事件就不会发生。因为两事件共享相同的RoutedEventArgs类。
隧道事件主要用于执行一些预处理,比如,对特定击键采取行动或者过滤掉特定鼠标行为。
事件分五类:
鼠标、键盘、Stylus、Multitouch事件放到一起统称为输入事件。
FrameworkElement类定义的Lifetime事件
Initialized
发生在元素被实例化,并且它的属性按照XAML标记设置完成以后。在这一点,元素被初始化,但是,还没有应用样式和数据绑定。在这一点,IsInitialized属性为true。Initialized是直接事件,不是路由事件。
Loaded
发生在整个窗口被初始化,并且样式和数据绑定已经应用。这是元素呈现之前的最后一站。在这一点,IsLoaded属性为true。
Unloaded
发生在下列情况出现时,元素释放,容器窗口关闭,指定元素从窗口被移除。
FrameworkElement实现了ISupportInitialize接口,有两个方法:BeginInit()、EndInit()。呈现过程:
在创建一个窗口时,每个分支的元素按自底向上的顺序被初始化。这意味着,被嵌套的元素先于他们的容器初始化。当初始化事件发生时,当前元素的子元素肯定已完全初始化了。但是,包含该元素的元素可能没有初始化,并且也不能肯定窗口的其他部分被初始化。
在每个元素被初始化后,每个元素被放到各自的容器中,被样式化,绑定到数据源,如果必要的话。在窗口的Initialized事件发生后,是时候进到下一步了。
Loaded事件按照与Initialized事件相反的路径装载,换句话说,容器窗口首先被装载,接着是它包含的元素。当所有元素的Loaded事件发生完成,窗口被显示,元素被呈现。
相对于FrameworkElement类,窗口类添加了Lifetime事件:
SourceInitialized
发生在HwndSource属性被获得,在窗口呈现之前。HwndSource属性是遗留代码中的功能。
ContentRendered
发生在在窗口呈现之后。在这个事件中,不要有可能影响window视觉外观的代码,也不要强制执行第二次呈现操作。但是,ContentRendered事件确实指示窗口完全可见,并且准备好输入。
Activated
发生在用户切换到这个窗口时,可能从应用程序的另一个窗口,也可能从另外一个应用程序。窗口第一次加载时,也会引发Activated事件。相当于控件的GotFocus事件。
Deactivated
发生在用户从窗口离开时,可能到应用程序的另一个窗口,也可能到另外一个应用程序。当窗口被关闭时,在Closing事件之后,Closed事件之前,引发Deactivated事件。相当于控件的LostFocus事件。
Closing
发生在窗口被关闭时。或者用户操作,或者在代码中使用Window.Close()方法,或者Application.Shutdown()方法。通过设置CancelEventArgs.Cancel属性为true,Closing事件可以取消关闭操作,保持窗口打开。但是,如果计算机关机或者日志关闭不会发生Closing事件。为了处理这个可能性,你需要处理Application.SessionEnding事件。
Closed
发生在窗口关闭以后。但是,元素对象仍是可访问的,并且Unloaded事件还没有发生。在此处,你能执行清除,写设置到配置文件,到注册表等等。
如果你只想在首次初始化控件,最好在Loaded事件引发时。通常,凡是初始化都能在Window.Loaded事件中执行。
也可以在Window构造函数的InitializeComponent()语句之后添加初始化代码。但是,如果这些初始化代码会引发异常,最好将初始化代码移到Loaded事件中。
输入事件的自定义事件参数由InputEventArgs类派生。
InputEventArgs类添加二属性:Timestamp和Device。
Timestamp提供一个整数,使用毫秒指示事件何时发生。时间戳不表示实际的时间。大的时间戳值表示后发生的事件。
Device返回触发事件设备的信息对象。可以是鼠标、键盘、或手写笔。每个都是一个类,都派生自抽象的System.Windows.Input.InputDevice类。
下几节,将详细介绍如何处理鼠标、键盘、和多点触摸行为。
所有元素按发生顺序的键盘事件:
PreviewKeyDown
隧道事件,发生在键按下时
KeyDown
冒泡事件,发生在键按下时
PreviewTextInput
隧道事件,发生在击键完成,且元素收到文本输入时。当按键却没有文本输入时,此事件不会发生。如:按Ctrl、Shift、Backspace、箭头键、功能键等等。
TextInput
冒泡事件,发生在击键完成,且元素收到文本输入时。当按键却没有文本输入时,此事件不会发生。
PreviewKeyUp
隧道事件,发生在键被释放时。
KeyUp
冒泡事件,发生在键被释放时。
一些控件压缩一些事件,他们能执行自己更特殊的键盘处理。例如,文本框控件压缩了TextInput事件,也压缩了功能键的KeyDown事件。但是,没有压缩隧道事件PreviewTextInput和PreviewKeyDown,它们仍能使用。
文本框控件也添加新的事件,TextChanged事件。发生在紧接着一个击键引发文本框改变文本以后。这时,新的文本已显示在文本框中。所以太迟不能阻止不希望的击键。
范例:监视一个文本框所有的可能的键事件,并且报告事件何时发生。图形5-6显示键入一个大写字母S的结果。
每次按键都会引发PreviewKeyDown和KeyDown事件。但是,TextInput事件只有当一个字符被键入到元素中才被引发。实际上,键入行为可能涉及组合按键。例如,当键入大写字母S时,你使用Shift+S键,结果,你将会看到两个KeyDown和KeyUp事件,但是只有一个TextInput事件。
PreviewKeyDown、KeyDown、PreviewKeyUp、和KeyUp事件都使用KeyEventArgs对象提供信息。最重要的是Key属性,返回System.Windows.Input.Key枚举值。识别被按下或释放的键。这是处理键事件的事件处理器:
private void KeyEvent(object sender, KeyEventArgs e) { string message = "Event: " + e.RoutedEvent + " " + " Key: " + e.Key; lstMessages.Items.Add(message); }
Key值不考虑其它键的状态。例如,当按下S键时,不管是否按下Shift键,都将获得相同的键值(Key.S)。
默认情况下,按住一个键一段时间后,会重复键值。所以,当输入Shift+S时,可能会产生多个Shift,如果你希望忽视重复Shift键,依靠检查KeyEventArgs.IsRepeat属性,可以确定是否一个击键是键一直被按着的结果。如下所示:
if ((bool)chkIgnoreRepeat.IsChecked && e.IsRepeat) return;
TextInput事件提供TextCompositionEventArgs对象。此对象包含一个Text属性,是控件即将收到的处理文本。见代码:
private void TextInput(object sender, TextCompositionEventArgs e) { var message = "Event: " + e.RoutedEvent + " " + " Text: " + e.Text; lstMessages.Items.Add(message); }
KeyConverter用于将Key枚举值转换为更有用的字符串:
KeyConverter converter = new KeyConverter(); string key = converter.ConvertToString(e.Key);
考虑一个只接受数字的文本框,需要在PreviewTextInput、和PreviewKeyDown事件中都进行验证,因为有些输入会旁路掉PreviewTextInput事件,而且,PreviewKeyDown过于低层次,对于相同的输入提供了过多的信息。最佳做法是,在PreviewTextInput中验证大多数输入,在PreviewKeyDown中验证PreviewTextInput漏掉的输入。
private void pnl_PreviewTextInput(object sender, TextCompositionEventArgs e) { short val; if (!Int16.TryParse(e.Text, out val)) { // 拒绝非数字键输入 e.Handled = true; } } private void pnl_PreviewKeyDown(object sender, KeyEventArgs e) { if (e.Key == Key.Space) { // 拒绝空格键,它不会引发PreviewTextInput事件。 e.Handled = true; } }
你能连接这两个事件处理器到单个的文本框,也可连接他们到包括几个仅输入数字的文本框的容器中,以提高编码效率。
为了控件能接受焦点,它的Focusable属性必须是true,这也是所有控件的默认值。Focusable属性定义在UIElement类中,这意味着非控件元素也能获得焦点。通常,非控件类的Focusable默认为false。但是,你能设置它为true。
如果没有显式设置Tab序列,当你按下Tab键切换焦点时,焦点将移动到当前元素的第一个子元素,如果当前的元素没有子元素,到同层的下一个子元素。
如果为每个控件设置TabIndex属性,TabIndex为0的控件获得第一焦点,紧接着跳跃到下一个最高TabIndex值(例如1,然后2,然后3,等等)。如果一个以上元素具有相同的TabIndex值,则使用自动的tab序列,跳到随后最近的元素。
所有控件的TabIndex属性的默认值为Int32.MaxValue。
除了TabIndex属性,Control类还定义了IsTabStop属性,设为false时,按压Tab键,控件将不能获得焦点。但是,与Focusable为false不同,控件仍然能通过其他方式(如鼠标点击、代码中调用Fucus()方法)获得焦点。
当控件是不可见或不可用时,不管如何设置TabIndex、IsTabStop、和Focusable属性,这些控件都不包括在Tab序列中。控制可见的属性是Visibility,控制可用的属性是IsEnabled。
键事件的KeyEventArgs对象包含一个KeyStates属性,反映了触发事件键的属性。KeyboardDevice属性提供对于键盘上的任何键相同的信息。
KeyboardDevice属性提供KeyboardDevice类的一个实例。它有两个属性:FocusedElement、Modifiers。焦点元素是指事件发生时拥有焦点的元素。修饰符是指当事件发生时什么修饰符键被按下,包括Shift,Ctrl,和Alt。并且你能使用按位逻辑核对它们的状况,像这样:
if ((e.KeyboardDevice.Modifiers & ModifierKeys.Control) == ModifierKeys.Control) { lblInfo.Text = "You held the Control key."; }
KeyboardDevice属性也提供几个方便的方法,列在表5-5。对于每个方法,你传递入一个Key枚举值。
IsKeyDown(),IsKeyUp():测试键是按下或是未按下。
IsKeyToggled():键是否是在一个开启状态。这只能应用于具有开关状态的键,诸如Caps Lock,Scroll Lock,和Num Lock。
GetKeyStates():返回一个或多个KeyStates枚举值,告诉你目前这个键是否是未按下、按下、或开启状态。这方法本质上等同于同时调用IsKeyDown()和IsKeyToggled()。
当你使用KeyEventArgs.KeyboardDevice属性,你的代码获得虚拟的键状态。这意味着它获得在事件发生时键盘的状态。这不是必然地等同于当前键盘状态。例如,考虑如果用户打字比代码执行更快发生什么。每次你的KeyPress事件引发,你将访问那个引发事件的击键,而不是刚键入的字符。这几乎总是你希望的行为。
但是,你不限于在键事件中获得键信息。你能在任何时间获得键盘状态。诀窍是使用键盘类Keyboard,它非常类似于KeyboardDevice,除了它的成员是静态的。这是一个例子,使用Keyboard类检出左Shift键当前的状态:
if (Keyboard.IsKeyDown(Key.LeftShift)) { lblInfo.Text = "The left Shift is held down."; }
最根本的鼠标事件允许你对鼠标在一个元素上移动作出反应。这些事件是MouseEnter(当鼠标指针在元素上移动)和MouseLeave(当鼠标指针离开元素时)。这两个事件都是直接事件。
例如,如果你有一个StackPanel,其中包含一个按钮。你移动鼠标指针到按钮上,MouseEnter事件将首先对StackPanel引发(因为你进入它的边界),然后对于按钮(因为你移动直接在它上)。当你移开鼠标,MouseLeave事件将首先对按钮引发,然后对StackPanel。
鼠标移动时会引发两个事件:PreviewMouseMove(隧道事件)和MouseMove(冒泡事件)。所有的这些事件提供一个MouseEventArgs对象。MouseEventArgs对象包含一些鼠标按钮状态的属性。它包含一个GetPosition()方法,获得相对于一个元素的坐标。这是一个例子,用与设备无关的像素显示鼠标指针相对于窗口的位置:
private void MouseMoved(object sender, MouseEventArgs e) { Point pt = e.GetPosition(this); lblInfo.Text = String.Format("You are at ({0},{1}) in window coordinates", pt.X, pt.Y); }
在本例中,坐标从窗口客户区域的左上角测量(就在标题栏下面)。
UIElement类有两个属性:
IsMouseOver,确定是否鼠标在一个元素上,或者在它的一个子元素上。
IsMouseDirectlyOver,确定是否鼠标在一个元素上,但不在它的子元素上。
所有元素的鼠标点击事件
名称 | 路由类型 | 描述 |
PreviewMouseLeftButtonDown PreviewMouseRightButtonDown |
隧道 | 发生在按下鼠标按钮时 |
MouseLeftButtonDown MouseRightButtonDown |
冒泡 | 发生在按下鼠标按钮时 |
PreviewMouseLeftButtonUp PreviewMouseRightButtonUp |
隧道 | 发生在鼠标按钮释放时 |
MouseLeftButtonUp MouseRightButtonUp |
冒泡 | 发生在鼠标按钮释放时 |
所有的鼠标按钮事件提供一个MouseButtonEventArgs对象。MouseButtonEventArgs类从MouseEventArgs类派生(这意味着它包含相同的坐标和按钮状态信息),并且它添加几个成员。MouseButton(告诉你哪一个按钮触发了事件)和ButtonState(告诉你当事件发生时按钮被按压或未被按压)。ClickCount,这告诉你按钮被点击多少次,可用于区分单击(ClickCount是1)、双击(ClickCount是2)。
通常,Windows应用在被鼠标点击后反应(up事件而不是down事件)。
一些元素添加更高水平的鼠标事件。例如,Control类添加PreviewMouseDoubleClick和MouseDoubleClick事件代替MouseLeftButtonUp事件。类似地,Button类有一个Click事件,能被鼠标或键盘触发。
为获得当前的鼠标位置和鼠标按钮状态,你能使用Mouse类的静态成员,类似于MouseButtonEventArgs的相应成员。