事件处理(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?
由于一个事件处理器可以被多种不同类型的控件挂载(例如:一个方法可以同时处理 Button、CheckBox 和 Hyperlink 的点击),为了保证通用性,参数被定义为所有类的祖先 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 属性与数据源之间的对应关系,系统底层的绑定引擎会自动处理后续的同步工作。
数据绑定有三个核心要素:
绑定目标 (Binding Target):通常是 UI 控件的属性(必须是依赖属性),例如 TextBlock 的 Text 属性。
绑定源 (Binding Source**)**:提供数据的对象,通常是 C# 类中的属性或数据模型。
路径 (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 和数据,而不是在运行时通过反射查找。
1
2
| <!-- 示例:将 TextBlock 绑定到后台代码的 UserName 属性 -->
<TextBlock Text="{x:Bind ViewModel.UserName, Mode=OneWay}" />
|
{Binding} —— 运行时绑定
这是源自 WPF 的经典绑定方式。
举个例子🌰
假设我们想要实现一个滑块(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 进行重绘。这是最基础、较为繁琐的手动实现,我们不难发现这里的多数代码都属于模板代码,为任意普通属性实现数据绑定似乎都需要写一套相同的逻辑。那么有没有办法简化实现呢?