面向刚学完数据绑定,准备理解「程序怎样启动和退出」「事件怎样传递」「拖动和框选怎样实现」的读者。 推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,当前电脑可直接编译;这里用到的 API 在 .NET 9 与 .NET 10 中没有变化。 最后更新:2026-10-21
目录
App生命周期与全局异常捕获
对象事件总结说明
鼠标输入事件
对象拖动案例
框选基本逻辑
路由事件的处理过程
写在前面 这份笔记解决什么问题 前面的笔记主要在处理「数据怎样显示到界面」。这一篇换一个方向,观察「程序怎样运行」以及「用户操作怎样进入程序」:
1 2 3 4 5 6 7 8 WPF 程序启动时,Application 和 Window 谁先谁后? 窗口关闭后,程序一定会立刻退出吗? UI 线程、后台线程和 Task 中的异常,能不能只写一个处理器? 普通事件、路由事件、生命周期事件有什么区别? 鼠标按下时,sender 和 OriginalSource 为什么不是同一个对象? 拖动卡片时,为什么卡片会突然跳到鼠标下面? 从右下向左上框选时,为什么矩形宽度会变成负数? PreviewMouseDown 和 MouseDown 的触发顺序为什么相反?
这篇笔记不要求你一次记住所有 API。更重要的是先建立三个观察角度:
时间顺序 :先发生什么,后发生什么。
对象层级 :事件从谁开始,经过谁,最后到哪里。
状态变化 :按下、移动、抬起之间要保存哪些数据。
图 1:先理解程序生命周期,再逐步进入对象事件、鼠标输入、拖动、框选和路由事件。
建议的学习顺序
先运行随附示例,依次点开六个页签,不要急着改代码。
第 1 章用日志观察启动和退出,再分别触发一次 UI 异常。
第 2、3 章只观察,不写复杂逻辑,先分清事件名称和触发时机。
第 4、5 章亲手拖动卡片、从四个方向框选,重点观察坐标。
第 6 章最后学,因为路由事件会把前面的事件知识串起来。
每完成一章,都在自己的练习项目里复现对应页签,再完成章末小练习。
示例项目位置 1 C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfLifecycleEventsDemo
在 PowerShell 中运行:
1 2 3 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfLifecycleEventsDemo" dotnet build dotnet run
如果使用 Visual Studio:
打开 WpfLifecycleEventsDemo.csproj。
确认启动项目是 WpfLifecycleEventsDemo。
按 F5 启动。
打开“输出”窗口,将“显示输出来源”切到“调试”。
出现异常时,同时观察界面日志和“输出”窗口。
2026 年的目标框架 如果电脑安装了 .NET 10 SDK,新练习项目推荐:
1 <TargetFramework > net10.0-windows</TargetFramework >
如果只安装了 .NET 9 SDK,就使用:
1 <TargetFramework > net9.0-windows</TargetFramework >
本篇示例已经在 .NET 9 上完成编译、交互和日志验证。把目标框架改为 .NET 10 后,示例代码不需要修改。
先建立自己的跟做项目 打开 PowerShell:
1 2 3 4 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码" dotnet new wpf -n WpfLifecycleEventsPractice -o .\WpfLifecycleEventsPractice cd .\WpfLifecycleEventsPracticedotnet run
看到默认窗口后,先关闭程序。接下来把 MainWindow.xaml 改成六个页签的骨架:
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 <Window x:Class ="WpfLifecycleEventsPractice.MainWindow" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" Title ="WPF 生命周期与输入练习" Width ="1280" Height ="900" WindowStartupLocation ="CenterScreen" Background ="#F3F6FB" FontFamily ="Microsoft YaHei UI" > <TabControl Margin ="20" > <TabItem Header ="1 生命周期与异常" > </TabItem > <TabItem Header ="2 对象事件" > </TabItem > <TabItem Header ="3 鼠标输入" > </TabItem > <TabItem Header ="4 对象拖动" > </TabItem > <TabItem Header ="5 框选" > </TabItem > <TabItem Header ="6 路由事件" > </TabItem > </TabControl > </Window >
再按一次 F5,看到六个空页签,说明跟做环境正常。
先准备一个记录事件的小工具 生命周期和路由事件都发生在很短的时间里。只靠 MessageBox 会挡住程序,看不清顺序,所以先准备一个简单的日志类。在项目中新建 EventLogStore.cs:
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 using System.IO;using System.Text;namespace WpfLifecycleEventsPractice ;public static class EventLogStore { private static readonly object SyncRoot = new (); public static string FilePath { get ; } = Path.Combine(AppContext.BaseDirectory, "events.log" ); public static void Reset () { File.WriteAllText( FilePath, "WPF 生命周期与输入实验日志" + Environment.NewLine, new UTF8Encoding(false )); } public static void Write (string stage, string detail ) { var line = $"{DateTime.Now:HH:mm:ss.fff} | {stage} | {detail} " ; lock (SyncRoot) { File.AppendAllText( FilePath, line + Environment.NewLine, new UTF8Encoding(false )); } } public static string [] ReadAllLines () { return File.Exists(FilePath) ? File.ReadAllLines(FilePath, Encoding.UTF8) : Array.Empty<string >(); } }
这里把日志写到 bin\Debug\net9.0-windows\events.log。AppContext.BaseDirectory 是程序运行目录,不要把它误认为项目根目录。
示例文件分工 1 2 3 4 5 6 7 8 9 10 11 WpfLifecycleEventsDemo ├─ App.xaml 应用启动配置 ├─ App.xaml.cs 生命周期与全局异常入口 ├─ MainWindow.xaml 六个实验页签的界面 ├─ MainWindow.xaml.cs 输入、拖动、框选和路由事件处理 ├─ InteractionMath.cs 可单独测试的几何计算 ├─ Models │ ├─ AppOptions.cs 启动参数解析 │ └─ EventRecord.cs 日志记录模型 └─ ViewModels └─ MainViewModel.cs 界面状态与日志集合
跟做时不必一次创建所有文件。先让每一章能单独运行,再把代码合并到同一个项目里。
1. App生命周期与全局异常捕获 本节目标 完成本章后,你应当能够:
说清 Application.Startup、Window.Loaded、Window.ContentRendered、Window.Closing、Window.Closed、Application.Exit 的先后关系。
区分「窗口关闭」和「应用退出」。
知道 UI 线程异常、后台线程异常、未观察任务异常分别从哪里进入。
写出一个不会让异常悄无声息消失的全局异常记录器。
1.1 先看完整时间线
图 2:本篇示例实际记录到的顺序。 Loaded 和 ContentRendered 都属于窗口显示阶段,但含义不同。
本篇示例使用:
1 2 3 4 5 <Application x:Class ="WpfLifecycleEventsDemo.App" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" ShutdownMode ="OnMainWindowClose" > </Application >
ShutdownMode="OnMainWindowClose" 的意思是:主窗口关闭后,应用进入退出流程。它不是「窗口一关就立即消失」,中间还会经过 Closing、Closed 和 Exit。
1.2 Startup:应用已经开始,窗口还没有显示 重写 App.OnStartup:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 using System.Windows;namespace WpfLifecycleEventsPractice ;public partial class App : Application { protected override void OnStartup (StartupEventArgs e ) { base .OnStartup(e); EventLogStore.Reset(); EventLogStore.Write( "Application.Startup" , $"参数数量={e.Args.Length} " ); var window = new MainWindow(); MainWindow = window; window.Show(); } }
此时可以读取命令行参数,也可以决定第一个窗口是谁。不要在这里假设所有控件都已经完成布局,因为窗口才刚刚创建。
1.3 Loaded:元素树已经可用 在窗口构造函数中订阅:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public MainWindow (){ InitializeComponent(); Loaded += OnWindowLoaded; ContentRendered += OnWindowContentRendered; Closing += OnWindowClosing; Closed += OnWindowClosed; } private void OnWindowLoaded (object sender, RoutedEventArgs e ){ EventLogStore.Write("Window.Loaded" , "窗口元素已经加载" ); }
Loaded 适合做:
读取控件当前的 ActualWidth、ActualHeight。
第一次加载数据。
根据界面尺寸调整内部布局。
但 Loaded 不保证窗口已经完成第一次绘制。此时界面可能已经参与布局,却还没有真正显示到屏幕上。
1.4 ContentRendered:第一次渲染完成 1 2 3 4 5 6 private void OnWindowContentRendered (object ? sender, EventArgs e ){ EventLogStore.Write( "Window.ContentRendered" , "窗口第一次完成渲染" ); }
ContentRendered 比 Loaded 更晚,通常适合:
启动首次进入动画。
在界面已经可见后显示引导层。
需要等待第一次画面呈现再执行的操作。
不要把两者简单记成「Loaded 是加载,ContentRendered 是渲染」就结束。真正的判断方法是:你需要元素树可用,还是需要第一帧已经画出来?
1.5 Closing 与 Closed:一个能取消,一个不能取消 1 2 3 4 5 6 7 8 9 10 11 12 13 14 using System.ComponentModel;private void OnWindowClosing (object ? sender, CancelEventArgs e ){ EventLogStore.Write("Window.Closing" , "准备关闭" ); } private void OnWindowClosed (object ? sender, EventArgs e ){ EventLogStore.Write("Window.Closed" , "窗口已经关闭" ); }
两者最重要的区别:
事件
时机
能否取消
常见用途
Closing
关闭动作正在进行
可以,设置 e.Cancel = true
询问是否保存、阻止误关闭
Closed
窗口已经关闭
不可以
释放资源、记录最终状态
如果用户在 Closing 中取消关闭,后续的 Closed 和 Application.Exit 都不应继续执行。
1.6 Exit:应用退出时收尾 1 2 3 4 5 6 7 8 protected override void OnExit (ExitEventArgs e ){ EventLogStore.Write( "Application.Exit" , $"退出码={e.ApplicationExitCode} " ); base .OnExit(e); }
Exit 是应用级事件,不等于某一个窗口的 Closed。如果应用有多个窗口,或者 ShutdownMode 不是 OnMainWindowClose,窗口关闭和应用退出之间可能没有你想象的同步关系。
1.7 三种全局异常入口
图 3:三个入口覆盖不同来源,不能只写一个就以为捕获了全部异常。
在 App 构造函数中注册三个入口:
1 2 3 4 5 6 public App (){ DispatcherUnhandledException += OnDispatcherUnhandledException; AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException; TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; }
入口一:DispatcherUnhandledException 1 2 3 4 5 6 7 8 9 10 11 12 using System.Windows.Threading;private static void OnDispatcherUnhandledException ( object sender, DispatcherUnhandledExceptionEventArgs e ){ EventLogStore.Write( "DispatcherUnhandledException" , $"{e.Exception.GetType().Name} : {e.Exception.Message} " ); e.Handled = true ; }
它负责 UI 线程上的未处理异常,例如按钮点击、布局、渲染和 Dispatcher.BeginInvoke 回调中的异常。设置 e.Handled = true 可以让程序继续运行,但这不代表异常已经修复。
生产程序中至少应记录完整堆栈,并提示用户保存工作。对于无法安全恢复的异常,继续运行反而更危险。
入口二:AppDomain.UnhandledException 1 2 3 4 5 6 7 8 9 10 private static void OnDomainUnhandledException ( object ? sender, UnhandledExceptionEventArgs e ){ var exception = e.ExceptionObject as Exception; EventLogStore.Write( "AppDomain.UnhandledException" , $"{exception?.GetType().Name ?? "未知异常" } : {exception?.Message} " ); }
它能看到非 UI 线程中最终没有被处理的异常。这个入口主要用于记录和诊断,通常不能像 DispatcherUnhandledException 那样简单地让进程继续运行。
入口三:TaskScheduler.UnobservedTaskException 1 2 3 4 5 6 7 8 9 10 private static void OnUnobservedTaskException ( object ? sender, UnobservedTaskExceptionEventArgs e ){ EventLogStore.Write( "TaskScheduler.UnobservedTaskException" , $"{e.Exception.GetType().Name} : {e.Exception.Message} " ); e.SetObserved(); }
它处理「任务已经失败,但异常一直没有被 await、Wait() 或其他方式观察」的情况。触发时间可能比任务真正失败晚,不能把它当成实时错误通知。
1.8 跟做:做一个异常实验按钮 在 MainWindow.xaml 第 1 个页签中放一个按钮和日志列表:
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 <Grid Margin ="24" > <Grid.RowDefinitions > <RowDefinition Height ="Auto" /> <RowDefinition Height ="*" /> </Grid.RowDefinitions > <StackPanel > <TextBlock Text ="生命周期与异常日志" FontSize ="22" FontWeight ="Bold" /> <WrapPanel Margin ="0,14,0,0" > <Button Content ="刷新日志" Padding ="14,8" Click ="OnRefreshLogClick" /> <Button Content ="触发 UI 异常" Margin ="10,0,0,0" Padding ="14,8" Click ="OnThrowUiExceptionClick" /> <Button Content ="关闭窗口" Margin ="10,0,0,0" Padding ="14,8" Click ="OnCloseWindowClick" /> </WrapPanel > </StackPanel > <ListBox x:Name ="LifecycleLogList" Grid.Row ="1" Margin ="0,14,0,0" FontFamily ="Consolas" FontSize ="13" Background ="#0F172A" Foreground ="#DCE7F8" /> </Grid >
后台代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 private void OnRefreshLogClick (object sender, RoutedEventArgs e ){ LifecycleLogList.ItemsSource = EventLogStore.ReadAllLines().Reverse().ToArray(); } private void OnThrowUiExceptionClick (object sender, RoutedEventArgs e ){ Dispatcher.BeginInvoke(new Action(() => { throw new InvalidOperationException("这是 UI 线程演示异常。" ); })); } private void OnCloseWindowClick (object sender, RoutedEventArgs e ){ Close(); }
注意按钮点击处理器本身不要直接写 throw 后期待界面继续更新。使用 Dispatcher.BeginInvoke 可以更清楚地模拟「UI 消息循环中的一次未处理异常」。
1.9 跟做检查点 按顺序执行下面四步:
启动程序,点击“刷新日志”,应看到 Application.Startup、Window.Loaded、Window.ContentRendered。
点击“触发 UI 异常”,再刷新日志,应出现 DispatcherUnhandledException。
点击“关闭窗口”,日志应依次出现 Window.Closing、Window.Closed。
重新打开日志文件,最后应有 Application.Exit。
示例项目实际验证得到的顺序:
1 2 3 4 5 6 7 Application.Startup Window.Loaded Window.ContentRendered DispatcherUnhandledException Window.Closing Window.Closed Application.Exit
ContentRendered 与 DispatcherUnhandledException 的相对位置会受异常触发时机影响,不要把这两行当成永远固定的相邻关系。真正稳定的是:Startup 在窗口显示前,Loaded 早于 ContentRendered,Closing 早于 Closed,Exit 在应用退出阶段。
1.10 常见错误
现象
常见原因
修正方法
只看到 Loaded,看不到 ContentRendered
在 Loaded 中阻塞 UI 线程
不在 UI 线程中长时间等待
在 Closed 中设置 e.Cancel 编译失败
Closed 的事件参数不是 CancelEventArgs
在 Closing 中取消
后台异常没有被记录
只注册了 DispatcherUnhandledException
同时注册后台线程和任务入口
任务异常日志很晚才出现
异常没有被观察
主动 await 任务并记录
e.Handled = true 后界面状态错乱
把异常吞掉了,却没有恢复状态
记录、提示、停止危险操作
1.11 小练习
在 Closing 中弹出一个确认框,用户选择“否”时设置 e.Cancel = true,观察 Closed 是否还会出现。
在 OnStartup 中输出 e.Args,使用 dotnet run -- hello WPF 启动,确认参数能读到。
把 ShutdownMode 改成 OnExplicitShutdown,关闭主窗口,观察程序是否立即退出。测试结束后记得改回 OnMainWindowClose。
2. 对象事件总结说明 本节目标
把 WPF 中常见事件分成应用生命周期事件、普通对象事件和路由事件三层。
理解事件发布者和订阅者的关系。
分清 Click、PreviewMouseDown、MouseDown 各自处于什么阶段。
知道什么时候该用 sender,什么时候该看 e.OriginalSource。
2.1 三层事件模型
图 4:同一段交互里,三层事件可能同时出现。先判断它属于哪一层,再判断传播方式。
第一层:应用生命周期事件 例如 Application.Startup、Application.Exit、Window.Loaded、Window.Closing。它们描述的是程序或窗口的生命周期,通常只发生一次,或者发生次数很少。
第二层:对象普通事件 例如属性变化通知 PropertyChanged、按钮的 Click、定时器的 Tick。普通事件由对象自己触发,通常没有沿元素树自动传播的过程。
第三层:路由事件 例如 PreviewMouseDown、MouseDown、KeyDown。路由事件会沿元素树按特定策略传播,可能先向下,再向上。
判断一个事件时先问三个问题:
1 2 3 谁触发? 谁订阅? 设置 Handled 后,还要不要继续传递?
2.2 事件处理器里的两个 sender 在 WPF 中经常会看到:
1 2 3 4 5 private void OnSomeEvent (object sender, RoutedEventArgs e ){ var handlerOwner = sender; var original = e.OriginalSource; }
它们不是一回事:
名称
含义
典型用途
sender
当前挂载这个事件处理器的对象
知道是哪个控件注册了处理器
e.Source
路由事件开始时设置的逻辑来源
逻辑树上的来源
e.OriginalSource
最初命中的可视元素
判断鼠标真正点到了什么
例如窗口订阅了 MouseDown,用户点在窗口里的一个 TextBlock 上:
sender 可能是窗口。
e.OriginalSource 可能是那个 TextBlock。
e.Source 可能经过逻辑树调整,和 OriginalSource 不一定相同。
不要用 sender 去判断「用户点了哪个子元素」。这类判断通常应该看 e.OriginalSource。
2.3 Click 不是 MouseDown 的别名 按钮的 Click 表示一次完整的点击语义:鼠标在按钮上按下,又在同一个按钮上抬起,并且没有被取消。MouseDown 只表示按下的瞬间。
所以不能这样理解:
1 MouseDown 发生 → 一定会有 Click
如果按下后把鼠标拖出按钮再抬起,MouseDown 已经发生,但 Click 可能不会发生。
2.4 跟做:一个按钮观察三种事件 在第 2 个页签加入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <StackPanel Margin ="24" > <TextBlock Text ="对象事件观察" FontSize ="22" FontWeight ="Bold" /> <Button x:Name ="EventProbeButton" Margin ="0,16,0,0" Padding ="18,10" HorizontalAlignment ="Left" Content ="点击我" PreviewMouseDown ="OnProbePreviewMouseDown" Click ="OnProbeClick" /> <TextBlock x:Name ="EventResultText" Margin ="0,18,0,0" FontSize ="18" Text ="还没有操作" /> </StackPanel >
后台代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 private void OnProbePreviewMouseDown ( object sender, MouseButtonEventArgs e ){ EventResultText.Text = $"PreviewMouseDown:sender={((FrameworkElement)sender).Name} " ; } private void OnProbeClick (object sender, RoutedEventArgs e ){ EventResultText.Text = "Click:按下和抬起都完成了" ; }
2.5 跟做检查点
单击按钮,先看到 PreviewMouseDown,抬起后看到 Click。
在按钮上按下,拖到按钮外面再抬起,观察 Click 是否发生。
给按钮加一个 MouseDown="OnProbeMouseDown",再观察它和 PreviewMouseDown 的先后。
如果事件处理器没有触发,先检查 XAML 中的事件名是否和后台方法签名完全一致。WPF 的事件处理器通常需要 object sender 和对应的事件参数类型。
2.6 常见错误
把 Click 当成鼠标按下事件,导致拖动场景中逻辑过早执行。
用 sender 判断子元素,结果总是得到订阅事件的外层容器。
事件处理器签名写成 EventArgs,但实际事件要求 MouseButtonEventArgs,导致编译失败。
在 Loaded 里订阅同一个事件多次,刷新页面后处理器执行多次。
2.7 小练习
给按钮增加 MouseEnter 和 MouseLeave,把结果写入一个列表。
用 e.OriginalSource 判断点击是否来自按钮内部的 TextBlock。
把 PreviewMouseDown 改成 PreviewMouseLeftButtonDown,比较两者在右键点击时的差异。
3. 鼠标输入事件 本节目标
理解鼠标事件从进入到离开的常见顺序。
会用 e.GetPosition(元素) 取得相对于指定元素的坐标。
能读取按键、滚轮增量和鼠标离开状态。
知道 Preview 事件和普通鼠标事件的区别。
3.1 一次鼠标交互的常见顺序
图 5:一次左键点击通常经过进入、移动、预览按下、按下、预览抬起、抬起和 Click。具体事件会受控件和操作方式影响。
常见顺序可以这样记:
1 2 3 4 5 6 7 MouseEnter → PreviewMouseMove → PreviewMouseDown → MouseDown → PreviewMouseUp → MouseUp → Click(仅特定控件在完整点击后产生)
Preview 是隧道阶段,通常从外层向目标向下传递;不带 Preview 的鼠标事件是冒泡阶段,通常从目标向外层向上传递。第 6 章会把这条路径完整跑一遍。
3.2 e.GetPosition 的参照物 1 var point = e.GetPosition(MouseProbeArea);
这行代码得到的是相对于 MouseProbeArea 左上角 的坐标,不是相对于整个窗口,也不是相对于屏幕。
同一个鼠标位置,调用不同的参照元素会得到不同结果:
1 2 var relativeToArea = e.GetPosition(MouseProbeArea);var relativeToWindow = e.GetPosition(this );
拖动和框选时,所有参与计算的坐标必须使用同一个参照物。混用坐标是「对象跳动」「选框偏移」的常见原因。
3.3 跟做:鼠标观察区 在第 3 个页签加入一个带边框的区域:
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 49 <Grid Margin ="24" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="430" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <Border x:Name ="MouseProbeArea" Height ="230" Background ="#EEF4FF" BorderBrush ="#B9CDEE" BorderThickness ="1" CornerRadius ="10" PreviewMouseMove ="OnMouseProbeMove" PreviewMouseDown ="OnMouseProbeDown" PreviewMouseUp ="OnMouseProbeUp" PreviewMouseWheel ="OnMouseProbeWheel" MouseLeave ="OnMouseProbeLeave" > <TextBlock Text ="在这里移动、点击和滚动" HorizontalAlignment ="Center" VerticalAlignment ="Center" Foreground ="#2456A6" /> </Border > <StackPanel Grid.Column ="1" Margin ="24,0,0,0" > <TextBlock Text ="当前坐标" Foreground ="#617089" /> <TextBlock x:Name ="MousePositionText" Margin ="0,6,0,0" FontSize ="22" FontWeight ="Bold" Text ="尚未进入观察区" /> <TextBlock Margin ="0,18,0,0" Text ="最近事件" Foreground ="#617089" /> <TextBlock x:Name ="MouseEventText" Margin ="0,6,0,0" FontSize ="17" Text ="等待鼠标输入" /> <TextBlock Margin ="0,18,0,0" Text ="滚轮增量" Foreground ="#617089" /> <TextBlock x:Name ="MouseWheelText" Margin ="0,6,0,0" FontSize ="17" Text ="0" /> </StackPanel > </Grid >
后台代码:
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 private void OnMouseProbeMove (object sender, MouseEventArgs e ){ var point = e.GetPosition(MouseProbeArea); MousePositionText.Text = $"X={point.X:N1} Y={point.Y:N1} " ; MouseEventText.Text = "PreviewMouseMove" ; } private void OnMouseProbeDown (object sender, MouseButtonEventArgs e ){ var point = e.GetPosition(MouseProbeArea); MousePositionText.Text = $"X={point.X:N1} Y={point.Y:N1} " ; MouseEventText.Text = $"PreviewMouseDown:{e.ChangedButton} " ; } private void OnMouseProbeUp (object sender, MouseButtonEventArgs e ){ var point = e.GetPosition(MouseProbeArea); MousePositionText.Text = $"X={point.X:N1} Y={point.Y:N1} " ; MouseEventText.Text = $"PreviewMouseUp:{e.ChangedButton} " ; } private void OnMouseProbeWheel (object sender, MouseWheelEventArgs e ){ MouseWheelText.Text = e.Delta.ToString(); MouseEventText.Text = "PreviewMouseWheel" ; } private void OnMouseProbeLeave (object sender, MouseEventArgs e ){ MouseEventText.Text = "MouseLeave:指针已经离开观察区" ; }
3.4 跟做检查点
鼠标移动到区域中,坐标应随位置变化,X 和 Y 不应出现负数(在区域内部时)。
在区域左上角附近移动,坐标应接近 0, 0。
滚轮向前和向后滚动,Delta 应分别出现正值和负值。
鼠标移出区域,最近事件应变为 MouseLeave。
分别按左键、右键、中键,观察 ChangedButton。
3.5 常见错误
使用 e.GetPosition(this) 取得坐标,却拿它和画布中的对象坐标比较。
只监听 MouseMove,没有处理 MouseLeave,状态一直停在最后位置。
把滚轮的 Delta 当成固定 1 或 -1;实际它通常以 120 为一步。
在 PreviewMouseDown 中设置 e.Handled = true,导致普通 MouseDown 处理器不再收到事件。
3.6 小练习
增加一个 MouseEnter 事件,把最近事件显示为“进入观察区”。
在坐标旁边显示当前左键状态:e.LeftButton。
把观察区放进另一个 Border,在两个元素上分别显示相对坐标,理解参照物。
4. 对象拖动案例 本节目标
理解拖动时为什么不能直接把鼠标坐标赋给对象左上角。
会记录按下点相对于对象的偏移量。
会使用鼠标捕获,让鼠标移出对象后仍能继续拖动。
会把对象限制在容器范围内。
4.1 直接赋坐标会发生什么
图 6:鼠标点不一定在对象左上角。直接使用鼠标坐标,对象会突然跳到指针下面。
假设对象左上角是 (90, 80),你在对象内部 (30, 20) 的位置按下。此时:
1 2 offsetX = 30 offsetY = 20
鼠标移动到画布坐标 (200, 150) 时,对象的左上角应该是:
1 2 left = 200 - 30 = 170 top = 150 - 20 = 130
如果直接把 left = 200、top = 150 赋给对象,卡片左上角会跳到鼠标位置,视觉上就是卡片突然「跳一下」。
4.2 鼠标捕获为什么必要 拖动速度很快时,鼠标可能瞬间移出卡片。如果没有捕获,卡片收不到后续的 MouseMove 和 MouseUp,拖动就会卡住。
1 2 3 DragCard.CaptureMouse(); DragCard.ReleaseMouseCapture();
捕获的意思是:在这段时间内,鼠标输入优先送到捕获它的元素。即使指针已经离开卡片边界,卡片仍然能收到移动和抬起。
4.3 位置限制 如果容器宽 400、对象宽 80,对象左上角的 X 最大值是:
所以 X 要限制在 0 到 320 之间。对象比容器还大时,最大值为负数,此时应该把上限归零,避免出现反向区间。
在项目中新建 InteractionMath.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 using System.Windows;namespace WpfLifecycleEventsPractice ;public static class InteractionMath { public static Point ClampTopLeft ( Point desired, Size item, Size container ) { var maxX = Math.Max(0 , container.Width - item.Width); var maxY = Math.Max(0 , container.Height - item.Height); return new Point( Math.Clamp(desired.X, 0 , maxX), Math.Clamp(desired.Y, 0 , maxY)); } }
4.4 跟做:可拖动卡片 第 4 个页签的 XAML:
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 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 <Grid Margin ="24" > <Grid.ColumnDefinitions > <ColumnDefinition Width ="360" /> <ColumnDefinition Width ="*" /> </Grid.ColumnDefinitions > <StackPanel > <TextBlock Text ="按住卡片并移动" FontSize ="22" FontWeight ="Bold" /> <TextBlock Margin ="0,10,0,0" Text ="拖动结束后,卡片不会越过画布边界。" TextWrapping ="Wrap" Foreground ="#617089" /> <TextBlock Margin ="0,22,0,0" Text ="拖动状态" Foreground ="#617089" /> <TextBlock x:Name ="DragStatusText" Margin ="0,6,0,0" Text ="尚未开始" /> <TextBlock Margin ="0,18,0,0" Text ="对象位置" Foreground ="#617089" /> <TextBlock x:Name ="DragPositionText" Margin ="0,6,0,0" FontFamily ="Consolas" Text ="(90, 80)" /> </StackPanel > <Border Grid.Column ="1" Width ="820" Height ="590" HorizontalAlignment ="Left" VerticalAlignment ="Top" Background ="#FBFCFE" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="10" ClipToBounds ="True" > <Canvas x:Name ="DragCanvas" Background ="#FBFCFE" > <Border x:Name ="DragCard" Width ="170" Height ="104" Canvas.Left ="90" Canvas.Top ="80" Background ="#2456A6" CornerRadius ="10" Cursor ="SizeAll" PreviewMouseLeftButtonDown ="OnDragCardDown" PreviewMouseMove ="OnDragCardMove" PreviewMouseLeftButtonUp ="OnDragCardUp" > <StackPanel HorizontalAlignment ="Center" VerticalAlignment ="Center" > <TextBlock Text ="拖动卡片" FontSize ="18" FontWeight ="Bold" Foreground ="White" /> <TextBlock Margin ="0,5,0,0" Text ="按住并移动" HorizontalAlignment ="Center" Foreground ="#D9E7FF" /> </StackPanel > </Border > </Canvas > </Border > </Grid >
后台代码:
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 49 50 51 52 53 54 55 using System.Windows;using System.Windows.Controls;using System.Windows.Input;private Point _dragOffset;private bool _isDragging;private void OnDragCardDown ( object sender, MouseButtonEventArgs e ){ _dragOffset = e.GetPosition(DragCard); _isDragging = true ; DragCard.CaptureMouse(); e.Handled = true ; DragStatusText.Text = "正在拖动" ; } private void OnDragCardMove (object sender, MouseEventArgs e ){ if (!_isDragging || e.LeftButton != MouseButtonState.Pressed) { return ; } var point = e.GetPosition(DragCanvas); var desired = new Point( point.X - _dragOffset.X, point.Y - _dragOffset.Y); var clamped = InteractionMath.ClampTopLeft( desired, new Size(DragCard.ActualWidth, DragCard.ActualHeight), new Size(DragCanvas.ActualWidth, DragCanvas.ActualHeight)); Canvas.SetLeft(DragCard, clamped.X); Canvas.SetTop(DragCard, clamped.Y); DragPositionText.Text = $"({clamped.X:N0} , {clamped.Y:N0} )" ; } private void OnDragCardUp ( object sender, MouseButtonEventArgs e ){ if (!_isDragging) { return ; } _isDragging = false ; DragCard.ReleaseMouseCapture(); DragStatusText.Text = "拖动结束" ; }
4.5 为什么按下和移动使用不同参照物 这里故意使用了两个参照物:
1 2 _dragOffset = e.GetPosition(DragCard); var point = e.GetPosition(DragCanvas);
偏移量是「按下点在卡片内部的位置」,必须相对卡片计算。移动后的指针位置要用来设置卡片在画布中的左上角,所以必须相对画布计算。两者分工不同,不是混用。
4.6 跟做检查点
在卡片中央按下并移动,卡片应跟随鼠标,不应跳到鼠标左上角。
快速拖到卡片外面再移动,卡片仍应继续跟随。
拖到画布左边界和上边界,位置应停在 0 附近,不能出现负坐标。
拖到右下边界,卡片应完整留在画布中。
在拖动过程中按 Esc 不会自动取消,因为本例没有实现取消逻辑,这是后续可扩展点。
4.7 常见错误
现象
原因
修正
卡片按下后跳到鼠标处
没有减偏移量
使用 point - offset
快速移动后拖动中断
没有鼠标捕获
按下时 CaptureMouse()
卡片能拖出边界
没有限制坐标
使用 Math.Clamp
卡片大小计算为 0
在布局完成前读取 ActualWidth
在 Loaded 之后读取
鼠标抬起后仍然跟随
没有重置 _isDragging
抬起时清理状态并释放捕获
4.8 小练习
增加键盘微调:选中卡片后,方向键每次移动 5 像素。
增加双击复位:双击卡片回到 (90, 80)。
在拖动开始时记录原位置,按 Esc 取消拖动并恢复原位。
5. 框选基本逻辑 本节目标
会记录框选起点和终点。
会用 Math.Min、Math.Abs 标准化矩形。
会用 Rect.IntersectsWith 判断项目是否与选框相交。
能处理从四个方向拖出的选框。
5.1 为什么矩形要标准化
图 7:从右下向左上拖动时,终点坐标可能小于起点。直接使用差值会产生负宽度。
Rect 的 Width 和 Height 应是非负数。标准化公式:
1 2 3 4 left = Min(start.X, end.X) top = Min(start.Y, end.Y) width = Abs(end.X - start.X) height = Abs(end.Y - start.Y)
5.2 相交和包含不是一回事
图 8:只要选框和项目有重叠就算相交;要求项目完全在选框内,是另一种产品规则。
本篇使用:
1 selection.IntersectsWith(item)
如果产品要求「完全包含才选中」,应改成类似:
1 selection.Contains(item)
先明确产品规则,再选 API。不要因为示例用了相交,就认为所有框选都必须这样。
5.3 跟做:补全几何工具 在 InteractionMath.cs 中继续加入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public static Rect NormalizeRect (Point start, Point end ){ var left = Math.Min(start.X, end.X); var top = Math.Min(start.Y, end.Y); var width = Math.Abs(end.X - start.X); var height = Math.Abs(end.Y - start.Y); return new Rect(left, top, width, height); } public static bool ShouldSelect (Rect selection, Rect item ){ return selection.Width >= 2 && selection.Height >= 2 && selection.IntersectsWith(item); }
Width >= 2 和 Height >= 2 是为了避免一次普通点击产生一个几乎看不见的选框,从而误选项目。这个阈值是业务规则,不是 WPF 强制要求。
5.4 跟做:框选画布 第 5 个页签的关键 XAML:
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 <Border Width ="820" Height ="590" Background ="#FBFCFE" BorderBrush ="#DCE5F2" BorderThickness ="1" ClipToBounds ="True" > <Canvas x:Name ="SelectionCanvas" Background ="Transparent" PreviewMouseLeftButtonDown ="OnSelectionCanvasDown" PreviewMouseMove ="OnSelectionCanvasMove" PreviewMouseLeftButtonUp ="OnSelectionCanvasUp" > <Border x:Name ="SelectionBox" Background ="#332456A6" BorderBrush ="#2456A6" BorderThickness ="1.5" Visibility ="Collapsed" IsHitTestVisible ="False" /> <Border x:Name ="SelectItemA" Width ="110" Height ="76" Canvas.Left ="60" Canvas.Top ="55" Tag ="项目 A" Background ="#DDEBFF" BorderBrush ="#8FB4E8" BorderThickness ="1" > <TextBlock Text ="项目 A" HorizontalAlignment ="Center" VerticalAlignment ="Center" /> </Border > <Border x:Name ="SelectItemB" Width ="130" Height ="88" Canvas.Left ="250" Canvas.Top ="90" Tag ="项目 B" Background ="#E8F6EF" BorderBrush ="#9ED2B8" BorderThickness ="1" > <TextBlock Text ="项目 B" HorizontalAlignment ="Center" VerticalAlignment ="Center" /> </Border > </Canvas > </Border >
SelectionCanvas 必须设置 Background="Transparent"。如果背景是 null,空白区域可能不参与命中测试,鼠标按在空白处不会触发画布事件。
后台代码:
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 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 using System.Windows;using System.Windows.Controls;using System.Windows.Input;using System.Windows.Media;private Point? _selectionStart;private bool _isSelecting;private readonly List<Border> _selectableItems = new ();private void InitializeSelectionItems (){ _selectableItems.Add(SelectItemA); _selectableItems.Add(SelectItemB); } private void OnSelectionCanvasDown ( object sender, MouseButtonEventArgs e ){ if (e.OriginalSource is not Canvas) { return ; } _selectionStart = e.GetPosition(SelectionCanvas); _isSelecting = true ; SelectionBox.Visibility = Visibility.Visible; SelectionCanvas.CaptureMouse(); UpdateSelectionBox(_selectionStart.Value, _selectionStart.Value); e.Handled = true ; } private void OnSelectionCanvasMove ( object sender, MouseEventArgs e ){ if (!_isSelecting || _selectionStart is null ) { return ; } UpdateSelectionBox( _selectionStart.Value, e.GetPosition(SelectionCanvas)); } private void OnSelectionCanvasUp ( object sender, MouseButtonEventArgs e ){ if (!_isSelecting || _selectionStart is null ) { return ; } var selection = InteractionMath.NormalizeRect( _selectionStart.Value, e.GetPosition(SelectionCanvas)); _isSelecting = false ; _selectionStart = null ; SelectionCanvas.ReleaseMouseCapture(); SelectionBox.Visibility = Visibility.Collapsed; foreach (var item in _selectableItems) { var itemBounds = GetCanvasBounds(item); var selected = InteractionMath.ShouldSelect( selection, itemBounds); item.BorderThickness = selected ? new Thickness(3 ) : new Thickness(1 ); item.BorderBrush = selected ? new SolidColorBrush(Color.FromRgb(36 , 86 , 166 )) : new SolidColorBrush(Color.FromRgb(185 , 205 , 238 )); } e.Handled = true ; } private void UpdateSelectionBox (Point start, Point end ){ var rect = InteractionMath.NormalizeRect(start, end); Canvas.SetLeft(SelectionBox, rect.Left); Canvas.SetTop(SelectionBox, rect.Top); SelectionBox.Width = rect.Width; SelectionBox.Height = rect.Height; } private static Rect GetCanvasBounds (FrameworkElement item ){ var left = Canvas.GetLeft(item); var top = Canvas.GetTop(item); return new Rect( double .IsNaN(left) ? 0 : left, double .IsNaN(top) ? 0 : top, item.ActualWidth, item.ActualHeight); }
在窗口构造函数中调用一次:
1 InitializeSelectionItems();
5.5 运行截图
图 9:示例中项目 A、B 与选框相交后被选中。选框在鼠标抬起后隐藏,选中结果保留。
5.6 跟做检查点
从空白处向右下拖,选框应随鼠标扩大。
从右下向左上拖,选框仍应是正常矩形,宽度和高度不能变成负数。
选框只碰到项目边缘时,应算相交;完全分离时,不应选中。
拖到画布外再抬起,捕获应让抬起事件仍然到达画布。
只点击空白处,不应误选项目。
5.7 常见错误
现象
原因
修正
从空白处按不下
画布背景为 null
设置 Background="Transparent"
反向拖动时选框消失
使用负数宽高
先标准化矩形
点一下项目就开始框选
没有判断 e.OriginalSource
只在画布空白处开始
快速拖出画布后选框卡住
没有捕获鼠标
按下时 CaptureMouse()
选中判断总是失败
使用窗口坐标和画布坐标混算
全部换算到画布坐标
5.8 小练习
增加项目 C 和 D,观察多项目框选。
把相交规则改成完全包含,并比较两种体验。
在结果区域显示被选中的项目名称,而不是只改变边框。
增加按住 Ctrl 追加选择的逻辑,先设计状态再写代码。
6. 路由事件的处理过程 本节目标
理解路由事件为什么需要「路由」。
能画出 PreviewMouseDown 的隧道路径和 MouseDown 的冒泡路径。
理解 sender、e.Source、e.OriginalSource 在路由中的不同角色。
知道 e.Handled 对后续处理器的影响。
会使用 AddHandler(..., handledEventsToo: true) 观察已被处理的事件。
6.1 三种路由策略 WPF 路由事件常见三种策略:
策略
传播方向
典型事件
隧道
从根节点向目标向下
PreviewMouseDown
冒泡
从目标向祖先向上
MouseDown
直接
只作用于来源本身
MouseEnter、部分控件自定义事件
鼠标按下通常先走隧道,再走冒泡:
1 2 3 4 5 6 7 8 Window.PreviewMouseDown → Outer.PreviewMouseDown → Middle.PreviewMouseDown → Target.PreviewMouseDown → Target.MouseDown → Middle.MouseDown → Outer.MouseDown → Window.MouseDown
图 10:同一棵元素树,Preview 阶段向下,普通 MouseDown 阶段向上。
6.2 跟做:四层嵌套元素 第 6 个页签放入四层 Border:
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 <Grid Margin ="24" > <Grid.RowDefinitions > <RowDefinition Height ="*" /> <RowDefinition Height ="220" /> </Grid.RowDefinitions > <Border x:Name ="RouteWindow" Background ="#F7F9FD" BorderBrush ="#B9CDEE" BorderThickness ="1" Padding ="28" PreviewMouseDown ="OnRouteWindowPreviewMouseDown" MouseDown ="OnRouteWindowMouseDown" > <Border x:Name ="RouteOuter" Background ="#EEF4FF" BorderBrush ="#B9CDEE" BorderThickness ="1" Padding ="28" PreviewMouseDown ="OnRouteOuterPreviewMouseDown" MouseDown ="OnRouteOuterMouseDown" > <Border x:Name ="RouteMiddle" Background ="#DDEBFF" BorderBrush ="#8FB4E8" BorderThickness ="1" Padding ="28" PreviewMouseDown ="OnRouteMiddlePreviewMouseDown" MouseDown ="OnRouteMiddleMouseDown" > <Border x:Name ="RouteTarget" Background ="#2456A6" Padding ="18,12" HorizontalAlignment ="Center" VerticalAlignment ="Center" PreviewMouseDown ="OnRouteTargetPreviewMouseDown" MouseDown ="OnRouteTargetMouseDown" > <TextBlock Text ="点击目标" Foreground ="White" /> </Border > </Border > </Border > </Border > <ListBox x:Name ="RouteLogList" Grid.Row ="1" Margin ="0,12,0,0" FontFamily ="Consolas" Background ="#0F172A" Foreground ="#DCE7F8" /> </Grid >
后台记录处理器:
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 private readonly List<string > _routeSequence = new ();private void RecordRoute (string stage, RoutedEventArgs e ){ _routeSequence.Add(stage); RouteLogList.Items.Add(stage); var sourceName = e.OriginalSource is FrameworkElement element ? element.Name : e.OriginalSource?.GetType().Name ?? "未知" ; EventLogStore.Write( "RoutedEvent" , $"{stage} ;OriginalSource={sourceName} " ); } private void OnRouteWindowPreviewMouseDown ( object sender, MouseButtonEventArgs e ) => RecordRoute("RouteWindow.PreviewMouseDown" , e); private void OnRouteWindowMouseDown ( object sender, MouseButtonEventArgs e ) => RecordRoute("RouteWindow.MouseDown" , e);
其余三个元素按同样方式挂载各自的 Preview 和普通 MouseDown 处理器。点击目标后,列表应按图 10 的顺序增长。
6.3 自动验证事件顺序 为了避免每次手动点击,可以创建一个鼠标事件参数并主动触发:
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 private void RunRouteTest (){ _routeSequence.Clear(); RouteLogList.Items.Clear(); var previewArgs = new MouseButtonEventArgs( Mouse.PrimaryDevice, Environment.TickCount, MouseButton.Left) { RoutedEvent = UIElement.PreviewMouseDownEvent, Source = RouteTarget }; RouteTarget.RaiseEvent(previewArgs); var bubbleArgs = new MouseButtonEventArgs( Mouse.PrimaryDevice, Environment.TickCount, MouseButton.Left) { RoutedEvent = UIElement.MouseDownEvent, Source = RouteTarget }; RouteTarget.RaiseEvent(bubbleArgs); }
预期顺序:
1 2 3 4 5 6 7 8 RouteWindow.PreviewMouseDown RouteOuter.PreviewMouseDown RouteMiddle.PreviewMouseDown RouteTarget.PreviewMouseDown RouteTarget.MouseDown RouteMiddle.MouseDown RouteOuter.MouseDown RouteWindow.MouseDown
6.4 Handled 怎样影响传播
图 11:普通处理器默认跳过已标记 Handled 的事件。
如果在 RouteMiddle.PreviewMouseDown 中设置:
后续普通 PreviewMouseDown 处理器可能不再被调用。注意:Handled 不是「事件停止对象级传播」那么简单,它影响的是后续处理器是否接收该事件。
如果你确实需要观察已处理事件,可以使用:
1 2 3 4 5 6 7 8 9 RouteWindow.AddHandler( UIElement.PreviewMouseDownEvent, new MouseButtonEventHandler((sender, e) => { EventLogStore.Write( "ObserveHandled" , $"Handled={e.Handled} " ); }), handledEventsToo: true );
只在诊断、日志或特殊交互中使用 handledEventsToo: true。普通业务逻辑随意绕过 Handled,会让事件流难以推理。
6.5 sender、Source、OriginalSource 的现场对照 假设用户点击了 RouteTarget 内部的 TextBlock:
1 2 3 sender = 当前挂载处理器的 Border e.Source = 路由事件设置的逻辑来源 e.OriginalSource = 最初命中的 TextBlock
因此日志中出现:
1 2 RouteWindow.PreviewMouseDown;OriginalSource=TextBlock RouteTarget.PreviewMouseDown;OriginalSource=TextBlock
这并不矛盾。处理器挂在不同层,sender 会变化;OriginalSource 仍然是同一个最初命中的元素。
6.6 跟做检查点
点击最内层“点击目标”,日志应从外层 Preview 开始,到内层 Preview,再向外冒泡。
点击外层空白区域,OriginalSource 不应仍然是最内层目标。
在中间层设置 e.Handled = true,观察后续处理器是否减少。
使用 handledEventsToo: true 注册观察器,确认它仍能看到已处理事件。
把 PreviewMouseDown 换成 PreviewMouseLeftButtonDown,比较右键点击时的差异。
6.7 常见错误
现象
原因
修正
只看到 Preview,没有普通 MouseDown
Preview 中设置了 Handled
检查 e.Handled
所有处理器都收到事件
没有设置 Handled
这是正常冒泡行为
以为 sender 是点击目标
处理器挂在外层
看 e.OriginalSource
路由测试顺序不稳定
混用了手动点击和自动触发
每次测试前清空日志
在错误元素上注册处理器
元素树层级不清楚
给每层命名并画路径
6.8 小练习
在每层显示 sender 的类型和名称。
只处理左键,右键点击时直接返回。
设计一个规则:最内层已处理事件,但最外层仍记录日志,使用 handledEventsToo 实现。
把 MouseDown 改成 PreviewMouseDown 再观察一次,确认自己不是靠事件名猜顺序。
完整项目结构 跟做完成后,项目可以整理成下面的结构:
1 2 3 4 5 6 7 8 9 WpfLifecycleEventsPractice ├─ App.xaml ├─ App.xaml.cs ├─ MainWindow.xaml ├─ MainWindow.xaml.cs ├─ EventLogStore.cs ├─ InteractionMath.cs └─ Models └─ EventRecord.cs
如果使用随附完整示例,还会看到:
1 2 3 4 5 6 7 8 9 WpfLifecycleEventsDemo ├─ Models │ ├─ AppOptions.cs │ └─ EventRecord.cs ├─ ViewModels │ └─ MainViewModel.cs ├─ SelfTestRunner.cs ├─ ScreenshotMath.cs └─ UiProbe.cs
AppOptions 负责命令行参数,SelfTestRunner 负责自动检查,UiProbe 负责截图。它们不是学习前五章的前置条件,可以最后再读。
完整示例截图 生命周期与全局异常
图 12:日志列表倒序显示,最新的 Application.Exit 会出现在最上面。
对象事件总结
图 13:普通事件观察页签。
鼠标输入事件
图 14:鼠标坐标、最近事件和滚轮增量。
对象拖动
图 15:卡片在画布内被拖动,并显示当前位置。
路由事件
图 16:点击目标后,路由日志按 Preview 和冒泡顺序显示。
常见错误速查 生命周期相关
问题
检查点
程序关闭后没有日志
日志目录是否有写权限,OnExit 是否被调用
Loaded 中读不到尺寸
控件是否已经完成布局,是否读取了 ActualWidth
Closing 取消后仍执行清理
清理逻辑是否错误地放在 Closed 之外
多个窗口关闭顺序混乱
检查 ShutdownMode 和 MainWindow
输入与拖动相关
问题
检查点
坐标偏移
两次 GetPosition 是否使用同一个参照物
拖动中断
是否在按下时捕获鼠标
卡片越界
是否计算 容器尺寸 - 对象尺寸
拖动结束后仍跟随
是否重置 _isDragging
框选相关
问题
检查点
反向框选失败
是否标准化矩形
空白区域不能开始
画布是否设置透明背景
点一下也选中
是否设置最小宽高阈值
选中范围偏移
项目坐标是否换算到画布坐标系
路由事件相关
问题
检查点
顺序与预期相反
Preview 是隧道,普通事件是冒泡
后续处理器不执行
是否有人设置了 e.Handled = true
分不清事件来源
同时打印 sender 和 e.OriginalSource
处理器重复执行
是否重复订阅了同一个事件
综合练习 练习一:做一个可取消的关闭流程 要求:
在窗口中放一个 TextBox。
文本框有内容且未保存时,关闭窗口弹出确认。
用户选择取消时,窗口保持打开。
用户选择不保存时,记录 Window.Closing 和 Window.Closed。
提示:取消关闭只能在 Closing 中做,Closed 已经来不及。
练习二:做一个受限制的拖动卡片 要求:
卡片只能在画布内移动。
拖动时显示实时坐标。
按 Esc 取消本次拖动并回到原位。
双击卡片复位。
提示:按下时同时记录 _dragStart 和 _cardStart,取消时恢复 _cardStart。
练习三:做一个框选统计器 要求:
画布中有至少四个项目。
支持从四个方向框选。
显示选中数量和名称。
按住 Ctrl 时追加选择,不按 Ctrl 时重新选择。
提示:先把「本次框选结果」和「累计选择集合」分开,再处理 Ctrl 状态。
练习四:观察路由事件 要求:
做四层嵌套容器。
记录 Preview 和普通 MouseDown。
在中间层设置 Handled。
使用 handledEventsToo 增加一个旁路观察器。
比较两次日志的差异。
提示:不要只看事件名,记录 sender.Name 和 OriginalSource。
结语 这一篇的六个主题看起来分散,其实都在回答同一个问题:一个界面操作是怎样在正确的时间、沿着正确的对象关系,改变正确的状态。
生命周期告诉你什么时候可以开始和收尾;对象事件告诉你谁在通知谁;鼠标事件告诉你输入从哪里来;拖动和框选把输入变成几何状态;路由事件则解释这些通知为什么能穿过一层层容器。
学习时不要只复制最终代码。每做完一章,都回到运行结果问自己三个问题:
1 2 3 这件事发生在什么时刻? 参与的对象分别是谁? 我保存的状态在按下、移动、抬起之间有没有被正确更新?
能回答这三个问题,再遇到新的 WPF 控件或交互需求时,就不会只靠背事件名了。