路由事件

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.ClickUIElement.MouseUp等。

隧道事件(Tunneling)

  • 传递方向:从视觉树的根节点开始,由外向内逐级向下传递,直到到达触发事件的具体元素。
  • 命名约定:WPF中所有的隧道事件通常都以Preview前缀开头,例如PreviewKeyDownPreviewMouseDown等。
  • 典型应用:用于在子元素接收到事件之前进行拦截或预处理,例如在文本框获取输入前拦截特定按键。

直接事件(Direct)

  • 传递方向:行为与传统事件类似,只在触发事件的元素本身引发和处理,不沿元素树传递。
  • 典型应用:例如UIElement.MouseEnterUIElement.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"这个写法,ClickButtonBase类定义的事件,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);
作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。