事件处理(Event Handling)

在前面的例子中,我们学会了如何在 XAML 中创建一个按钮。但如何让这个按钮发挥作用呢?我们需要为按钮的Click事件挂接一个事件处理器(Event Handler),按钮被点击时,就会触发内部的代码。当我们在 Visual Studio 中为按钮定义Click属性时,可以通过Tab键自动补全后台代码中的事件处理器。

我们会在MainWindow.xaml.cs中看到一个自动补全的方法:

1
2
3
4
private void SampleButton_Click(object sender, RoutedEventArgs e)
{

}

让我们填充一些内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private void SampleButton_Click(object sender, RoutedEventArgs e)
{
    ContentDialog dialog = new ContentDialog()
    {
        XamlRoot = this.Content.XamlRoot,
        Title = "2333",
        Content = "This is a sample dialog.",
        CloseButtonText = "OK"
    };
    dialog.ShowAsync();
}

现在点击按钮,你就能看到效果了。

更进一步😶🌫️

你也许会好奇方法签名中的(object sender, RoutedEventArgs e)是什么。让我们先了解路由事件的概念。

路由事件(Routed events)

在 WinUI 3 这样的图形界面中,UI 是由很多层控件嵌套组成的(即视觉树)。路由事件就是一种可以在控件树中向上传递的事件。当用户点击按钮时,点击动作不仅发生在“按钮”上,也同时发生在它背后的“容器”和“网格”上。路由事件允许这些父级元素也能接收到这个信号。事件从最初被触发的元素(子元素)开始,像气泡一样沿着视觉树向上漂浮,直到到达根元素。

来看看这个例子:

1
2
3
4
5
6
<Grid x:Name="RootGrid">
    <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
        <Button x:Name="InnerButton" Content="Click Me"/>
        <TextBlock x:Name="LogText"/>
    </StackPanel>
</Grid>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public sealed partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        InnerButton.Click += InnerButton_Click;
        RootGrid.AddHandler(
            UIElement.PointerPressedEvent,
            new PointerEventHandler(RootGrid_PointerPressed),
            handledEventsToo: true);
    }
    private void InnerButton_Click(object sender, RoutedEventArgs e)
    {
        LogText.Text = "InnerButton.Click Invoked";
    }
    private void RootGrid_PointerPressed(object sender, PointerRoutedEventArgs e)
    {
        LogText.Text += "\nRootGrid:PointerPressed";
    }
}

这里我们为Grid父控件监听了PointerPressed事件,即鼠标按下。试着对按钮或文本框按下鼠标,都会触发对应的事件处理器。

object sender —— 事件的发送者

sender 是对触发事件的对象的引用。

为什么类型是 object

由于一个事件处理器可以被多种不同类型的控件挂载(例如:一个方法可以同时处理 ButtonCheckBoxHyperlink 的点击),为了保证通用性,参数被定义为所有类的祖先 object。在“路由事件”的语境下,sender 指的是挂载了该事件的处理程序的元素。

典型用法:共享处理逻辑

当你有一组类似的控件(如 10 个数字按钮)需要执行相同的逻辑时,你会让它们共享同一个方法,通过 sender 来区分。

1
2
3
4
5
6
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
    <Button Content="1" Click="Button_Click"/>
    <Button Content="2" Click="Button_Click"/>
    <Button Content="3" Click="Button_Click"/>
    <TextBlock x:Name="LogText"/>
</StackPanel>
1
2
3
4
5
6
7
private void Button_Click(object sender, RoutedEventArgs e)
{
    if(sender is Button btn && btn.Content is string s)
    {
        LogText.Text = $"Button {s} clicked.";
    }
}

RoutedEventArgs e —— 事件参数

除了知道事件的发送者之外,我们还需要知道事件触发时的一些细节。

在路由事件中,e 至少是 RoutedEventArgs 类型(或其派生类)。它主要提供两个核心功能:

溯源:e.OriginalSource

由于事件会冒泡,sender 参数会随着事件传递而改变(变为当前挂载处理程序的父容器)。但 e.OriginalSource 永远指向最初触发事件的那个最原始的元素。

拦截:e.Handled

这是一个布尔值。将其设为 true 就像在视觉树的传递路径上放了一个“止步”牌。后续的父级处理程序(通常情况下)将不再接收到此事件。

特定事件元数据

根据事件的不同,e 会转换为特定的子类,提供详细的事件相关参数。让我们来看一个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
    <Rectangle x:Name="MyRectangle" 
       Width="400" Height="200" 
       Fill="SteelBlue"
       PointerPressed="MyRectangle_PointerPressed" />

    <TextBlock x:Name="InfoDisplay" 
       Margin="0,20,0,0" 
       FontSize="16" 
       Text="请点击上方矩形..." />
</StackPanel>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
using Microsoft.UI;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;

private void MyRectangle_PointerPressed(object sender, PointerRoutedEventArgs e)
{
    // 1. 获取点击相对于矩形左上角的坐标
    // GetCurrentPoint 的参数决定了坐标系的参考对象
    PointerPoint ptrPt = e.GetCurrentPoint(MyRectangle);
    var position = ptrPt.Position;

    // 2. 识别输入设备类型
    string deviceType = e.Pointer.PointerDeviceType switch
    {
        PointerDeviceType.Mouse => "鼠标",
        PointerDeviceType.Touch => "手指触摸",
        PointerDeviceType.Pen => "手写笔",
        _ => "未知设备"
    };

    // 3. 检查是否按下了辅助键 (KeyModifiers)
    bool isCtrlPressed = (e.KeyModifiers & Windows.System.VirtualKeyModifiers.Control) != 0;

    // 4. 获取详细的按键信息
    bool isLeftButton = ptrPt.Properties.IsLeftButtonPressed;
    bool isRightButton = ptrPt.Properties.IsRightButtonPressed;

    // 5. 更新 UI 显示
    InfoDisplay.Text = $"设备: {deviceType}\n" +
                       $"坐标: X={position.X:F1}, Y={position.Y:F1}\n" +
                       $"左键: {isLeftButton}, 右键: {isRightButton}\n" +
                       $"Ctrl键按下: {isCtrlPressed}";

    // 6. 根据操作逻辑修改矩形颜色
    if (isRightButton)
    {
        MyRectangle.Fill = new SolidColorBrush(Colors.Red);
    }
    else
    {
        MyRectangle.Fill = new SolidColorBrush(Colors.Orange);
    }

    // 7. 标记事件已处理,防止父级容器再次响应
    e.Handled = true;
}

在这里,我们通过 e 获取了大量点击事件发生时的相关参数。

数据绑定(Data Binding)

在传统的命令式编程中,若要更新界面(如修改文本框内容),开发者需要获取控件实例并手动为其属性赋值。而数据绑定是一种声明式编程模式,开发者只需定义 UI 属性与数据源之间的对应关系,系统底层的绑定引擎会自动处理后续的同步工作。

数据绑定有三个核心要素:

  1. 绑定目标 (Binding Target):通常是 UI 控件的属性(必须是依赖属性),例如 TextBlockText 属性。

  2. 绑定源 (Binding Source**)**:提供数据的对象,通常是 C# 类中的属性或数据模型。

  3. 路径 (Path):指定绑定源中具体哪个属性的值需要被同步。

根据数据流动的方向,WinUI 支持以下三种主要的绑定模式:

  • OneTime (一次性绑定):仅在应用程序启动或数据上下文首次更改时更新 UI。此模式内存开销最小,适用于不变量(如界面上的静态标签)。

  • OneWay (单向绑定):默认模式(针对多数控件)。当绑定源的数据发生变化时,UI 会自动更新。适用于只读数据的展示。

  • TwoWay (双向绑定):数据在源与目标之间双向流动。当用户修改 UI(如在 TextBox 中输入)或后台数据改变时,另一端都会同步。常用于表单输入。

在 C# 中,普通属性的修改并不会主动通知 UI 引擎。为了让“单向”或“双向”绑定生效,数据源对象必须实现 INotifyPropertyChanged 接口。 当属性的 set 访问器被调用时,对象会抛出一个 PropertyChanged 事件,告知绑定引擎该属性的名称。引擎接收到信号后,会重新读取该属性的值并刷新 UI。

WinUI 中的绑定技术:{x:Bind} 与 {Binding}

{x:Bind} —— 编译期绑定

{x:Bind} 是 WinUI 3 引入的高性能绑定方式。它在编译阶段生成 C# 代码来连接 UI 和数据,而不是在运行时通过反射查找。

  • 强类型检查:如果在 XAML 中写错了属性名,编译时会直接报错,而不是在运行时失效。

  • 高性能:由于不使用反射,执行效率极高。

  • 作用域:默认绑定到当前页面(Page)或控件(UserControl)本身,你可以直接访问后台代码(Code-behind)中的属性。

1
2
<!-- 示例:将 TextBlock 绑定到后台代码的 UserName 属性 -->
<TextBlock Text="{x:Bind ViewModel.UserName, Mode=OneWay}" />
{Binding} —— 运行时绑定

这是源自 WPF 的经典绑定方式。

  • 基于反射:在运行时查找属性,灵活性更高但性能相对较低。

  • DataContext 机制:它依赖于控件的 DataContext 属性。它会在视觉树中向上查找直到找到匹配的源。

举个例子🌰

假设我们想要实现一个滑块(Slider)与数值联动的效果(就像你在 Windows 右下角操作中心用滑块调节音量那样)。让我们先把控件放到页面上:

1
2
3
4
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
    <TextBlock x:Name="MyText" Text="233"/>
    <Slider x:Name="MySlider" Minimum="0" Maximum="100" Width="200"/>
</StackPanel>

现在文本框的内容只是一个固定值,并不会响应滑块的变化。让我们写一条最简单的数据绑定:

1
2
3
4
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
    <TextBlock x:Name="MyText" Text="{x:Bind MySlider.Value, Mode=OneWay}"/>
    <Slider x:Name="MySlider" Minimum="0" Maximum="100" Width="200"/>
</StackPanel>

这里以MySlider.Value作为数据源,单向绑定到Text属性上,实现了滑动改变文本的效果。

如果我们想通过手动输入数值来改变滑块(双向绑定),记得把控件改为可输入的TextBox

1
2
3
4
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
    <TextBox x:Name="MyText" Text="{x:Bind MySlider.Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Width="200"/>
    <Slider x:Name="MySlider" Minimum="0" Maximum="100" Width="200"/>
</StackPanel>

上面进行的都是控件之间的数据绑定,得益于依赖属性,我们只需要操作 XAML 就可以实现绑定。那么如何绑定一个普通对象属性呢?

下面的代码实现了一个简单的计数器,有"+1"与"-1"两个按钮:

1
2
3
4
5
6
7
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12">
    <TextBlock x:Name="MyText" Text="{x:Bind Counter, Mode=OneWay}"/>
    <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="12">
        <Button Content="-1" Click="OnDecrementClick" />
        <Button Content="+1" Click="OnIncrementClick" />
    </StackPanel>
</StackPanel>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public sealed partial class MainWindow : Window, INotifyPropertyChanged
{
    private int counter;

    public MainWindow()
    {
        InitializeComponent();
    }
    
    public int Counter
    {
        get => counter;
        set
        {
            if (counter == value)
            {
                return;
            }
            counter = value;
            OnPropertyChanged(nameof(Counter));
        }
    }

    public event PropertyChangedEventHandler? PropertyChanged;
    
    private void OnPropertyChanged(string propertyName)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    private void OnIncrementClick(object sender, RoutedEventArgs e)
    {
        Counter++;
    }

    private void OnDecrementClick(object sender, RoutedEventArgs e)
    {
        Counter--;
    }
}

可以看到,这里我们使用了一个int类型的Counter属性,并实现了INotifyPropertyChanged接口,在属性发生变更时手动触发PropertyChanged事件,从而通知 UI 进行重绘。这是最基础、较为繁琐的手动实现,我们不难发现这里的多数代码都属于模板代码,为任意普通属性实现数据绑定似乎都需要写一套相同的逻辑。那么有没有办法简化实现呢?