路由事件
WPF中的路由事件(Routed Event)是一种特殊的事件机制。与传统的普通事件不同,路由事件的核心特性在于消息能在UI元素树中沿特定方向传递,从而可以在触发事件的元素上处理它,也可以在其父级或子级元素上统一集中处理。这篇笔记我们将介绍有关路由事件的核心概念。
为什么需要路由事件
Winform中我们处理一个按钮点击,通常是在代码中编写button.Click += handler,事件的源和订阅者往往是同一个控件,但在WPF中,一个Button的视觉树内部可能嵌着许多其它元素。
<Button Width="120" Height="60">
<StackPanel Orientation="Horizontal">
<Image Source="icon.png" Width="24"/>
<TextBlock Text="保存" VerticalAlignment="Center"/>
</StackPanel>
</Button>
当用户点击按钮内部的某个子元素时,我们却希望在Button层面收到点击事件,这显然不能靠逐层转发来实现。WPF为此引入了路由事件(Routed Event)的概念,事件不只在源头触发,而是沿着UI元素树传播,沿途的元素都有机会处理它。
路由事件的三种路由策略
路由事件按传递方向和方式可分为三种类型,冒泡事件、隧道事件和直接事件。
冒泡事件(Bubbling):
- 传递方向:从事件源(即触发事件的UI元素)开始,由内向外逐级向上层父元素传递,直到到达视觉树的根节点。
- 典型应用:最常用的事件都是冒泡事件,例如
Button.Click、UIElement.MouseUp等。
隧道事件(Tunneling):
- 传递方向:从视觉树的根节点开始,由外向内逐级向下传递,直到到达触发事件的具体元素。
- 命名约定:WPF中所有的隧道事件通常都以Preview前缀开头,例如
PreviewKeyDown、PreviewMouseDown等。 - 典型应用:用于在子元素接收到事件之前进行拦截或预处理,例如在文本框获取输入前拦截特定按键。
直接事件(Direct):
- 传递方向:行为与传统事件类似,只在触发事件的元素本身引发和处理,不沿元素树传递。
- 典型应用:例如
UIElement.MouseEnter、UIElement.MouseLeave等。
冒泡是最常用的路由策略,它的名字十分形象,事件像水泡一样从底层元素浮到顶层;隧道事件则相反,它从Window根元素一路向下钻到事件源;直接事件和传统事件行为一致,挂事件就是为了处理自身的逻辑,不存在路由。隧道和冒泡是成对工作的:同一用户动作会先触发Preview隧道事件(由外向内),再触发冒泡事件(由内向外)。以按下鼠标左键为例,触发顺序类似如下:
隧道阶段:Window.PreviewMouseDown → Grid.PreviewMouseDown → Button.PreviewMouseDown
冒泡阶段:Window.MouseDown ← Grid.MouseDown ← Button.MouseDown
这个设计给了父容器两次干预机会,隧道阶段可以提前拦截,冒泡阶段可以后置处理,WPF内置控件的很多行为正是利用成对事件实现的,比如Button内部就处理了冒泡上来的MouseLeftButtonDown并把它转换为Click事件。
在父容器捕获子控件事件实现
这里我们直接以例子的形式展示事件冒泡的用法,窗体里有一组按钮,但我们不给每个按钮单独挂Click,而是在外层StackPanel上统一处理。
MainWindow.xaml
<Window x:Class="Gacfox.Demo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Gacfox.Demo"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<StackPanel x:Name="buttonPanel" ButtonBase.Click="Panel_Click" Margin="20">
<Button Content="按钮一" Margin="5"/>
<Button Content="按钮二" Margin="5"/>
<Button Content="按钮三" Margin="5"/>
<TextBlock x:Name="logText" Margin="5" TextWrapping="Wrap"/>
</StackPanel>
</Window>
MainWindow.xaml.cs
using System.Windows;
using System.Windows.Controls;
namespace Gacfox.Demo;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void Panel_Click(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is Button button)
{
logText.Text = $"点击了:{button.Content}";
}
}
}
XAML代码中,注意ButtonBase.Click="Panel_Click"这个写法,Click是ButtonBase类定义的事件,StackPanel本身并没有Click,但XAML允许在任意元素上挂某个类的事件的处理程序,只要事件冒泡经过这里就会被捕获。C#代码中,我们通过e.OriginalSource可以拿到事件的真正源头,即用户点击的那个元素,而sender始终是挂载事件的StackPanel。
此外,路由事件处理程序的参数类型是RoutedEventArgs(或其派生类如MouseButtonEventArgs),它和Winform中的EventArgs地位相当,但多了几个关键成员。
| 成员 | 说明 |
|---|---|
Source |
事件源,即逻辑树上触发事件的元素 |
OriginalSource |
最初的事件源,即视觉树最深处的元素 |
RoutedEvent |
当前事件对应的路由事件标识符 |
Handled |
置为true可终止事件的继续路由 |
e.Handled与事件终止
路由事件默认会走完整条路径,如果某个处理程序认为事件已经处理完毕,不希望继续传播,我们可以设置e.Handled = true,后续元素上的处理程序就不会再被调用了。
private void Panel_Click(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is Button button)
{
logText.Text = $"点击了:{button.Content}";
e.Handled = true;
}
}
关于e.Handled的使用,一个常见的实际问题是TextBox内部对KeyDown的某些按键(如方向键、删除键)做了处理并设置了Handled,于是外层窗口挂的KeyDown收不到这些按键。解决方法是在需要处理的位置挂载对应的隧道事件PreviewKeyDown,因为隧道阶段先于控件内部处理发生,控件还没来得及把它标记为Handled,此时我们就可以插入处理逻辑了,其它很多类似问题也都能用隧道事件解决。
最后,如果真的确实需要接收已被标记为Handled的事件,WPF也为此留了“后门”,我们可以在代码中用AddHandler的重载方法挂载事件,其中第三个参数传true表示已处理的事件也会触发事件处理函数。
buttonPanel.AddHandler(ButtonBase.ClickEvent,
new RoutedEventHandler(Panel_Click), handledEventsToo: true);