面向已经理解 WPF 路由事件基础,准备继续学习「怎样控制事件」「键盘怎样输入」「拖放怎样实现」和「触摸怎样处理」的读者。
推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,当前电脑可直接编译;这里用到的 API 在 .NET 9 与 .NET 10 中没有变化。
最后更新:2026-10-22

目录

  1. 路由事件的拦截
  2. 路由拦截后的逻辑执行
  3. 键盘输入事件
  4. 拖拽事件
  5. Touch与多点触控事件

写在前面

这份笔记解决什么问题

上一篇笔记已经讲过 Preview 隧道、普通事件冒泡和 Handled。真正做界面时,还会遇到下面这些问题:

1
2
3
4
5
6
7
8
9
拦截应该写在 Preview 还是普通事件里?
设置 Handled 后,事件到底还在不在?
拦截之后还需要做哪些业务动作?
KeyDown、TextInput、TextChanged 分别在什么时候发生?
为什么 Ctrl+C 有时会被输入框自己处理掉?
拖放为什么不是只有一个 Drop 事件?
拖到目标上方时,鼠标光标为什么不变成可放下?
触摸事件和 Manipulation 事件应该选哪一种?
没有触屏的电脑,怎样学习多点触控?

这篇笔记不把「事件处理器」当成终点。每学一种输入,都会多问一句:

1
2
3
4
输入从哪里来?
事件在哪一层被接收?
接收后要保存什么状态?
业务规则放在哪里?

五章知识地图

图 1:从路由事件的控制权开始,再分别观察键盘、系统拖放和多点触控。

建议的学习顺序

  1. 先运行随附示例,依次点开五个页签。
  2. 第 1、2 章连着学:先理解「拦住」,再理解「拦住之后做什么」。
  3. 第 3 章在输入框里亲自按键,观察 Preview、TextInput 和 TextChanged。
  4. 第 4 章至少完成一次从左侧卡片拖到右侧目标的完整操作。
  5. 第 5 章先用按钮模拟,再尝试在触屏设备上做单指拖动和双指缩放。
  6. 每完成一章,都回到示例中复现一次,再做章末小练习。

示例项目位置

1
C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfRoutingInputDemo

在 PowerShell 中运行:

1
2
3
cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfRoutingInputDemo"
dotnet build
dotnet run

如果使用 Visual Studio:

  1. 打开 WpfRoutingInputDemo.csproj。
  2. 确认启动项目是 WpfRoutingInputDemo。
  3. 按 F5 启动。
  4. 打开“输出”窗口,将“显示输出来源”切到“调试”。
  5. 出现异常时,同时观察界面日志和“输出”窗口。

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 WpfRoutingInputPractice -o .\WpfRoutingInputPractice
cd .\WpfRoutingInputPractice
dotnet 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
<Window x:Class="WpfRoutingInputPractice.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="WPF 路由与输入练习"
Width="1320"
Height="900"
WindowStartupLocation="CenterScreen"
Background="#F3F6FB"
FontFamily="Microsoft YaHei UI">
<TabControl Margin="20">
<TabItem Header="1 路由拦截">
<!-- 第 1 章 -->
</TabItem>
<TabItem Header="2 拦截后逻辑">
<!-- 第 2 章 -->
</TabItem>
<TabItem Header="3 键盘输入">
<!-- 第 3 章 -->
</TabItem>
<TabItem Header="4 拖放">
<!-- 第 4 章 -->
</TabItem>
<TabItem Header="5 多点触控">
<!-- 第 5 章 -->
</TabItem>
</TabControl>
</Window>

再按一次 F5,看到五个空页签,说明跟做环境正常。

先准备可测试的规则类

输入事件很容易把界面逻辑、权限判断、格式判断混在一起。先准备一个不依赖界面的规则类,在项目根目录新建 InteractionRules.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
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
using System.Windows;
using System.Windows.Input;

namespace WpfRoutingInputPractice;

public static class InteractionRules
{
public static bool IsEditableShortcut(
bool isControlPressed,
Key key)
{
return isControlPressed
&& key is Key.A or Key.C or Key.V or Key.X or Key.Z;
}

public static bool IsAcceptedDrop(
string? dataFormat,
bool isAllowed)
{
return isAllowed
&& string.Equals(
dataFormat,
DragDropRules.TextFormat,
StringComparison.Ordinal);
}

public static Rect NormalizeRect(Point start, Point end)
{
var left = Math.Min(start.X, end.X);
var top = Math.Min(start.Y, end.Y);

return new Rect(
left,
top,
Math.Abs(end.X - start.X),
Math.Abs(end.Y - start.Y));
}

public static Point ClampCenter(
Point desired,
double radius,
Size bounds)
{
return new Point(
Math.Clamp(desired.X, radius, Math.Max(radius, bounds.Width - radius)),
Math.Clamp(desired.Y, radius, Math.Max(radius, bounds.Height - radius)));
}

public static double TwoFingerDistance(Point first, Point second)
{
var dx = second.X - first.X;
var dy = second.Y - first.Y;

return Math.Sqrt(dx * dx + dy * dy);
}

public static double ScaleFromFingerDistance(
double currentDistance,
double initialDistance)
{
if (initialDistance <= 0)
{
return 1;
}

return Math.Clamp(currentDistance / initialDistance, 0.6, 1.8);
}
}

public static class DragDropRules
{
public const string TextFormat = "WpfRoutingInputDemo.Text";
public const string CustomFormat = "WpfRoutingInputDemo.Custom";
}

规则类只接收普通参数,不读取控件,也不弹出窗口,因此可以直接测试。

示例文件分工

1
2
3
4
5
6
7
8
9
10
11
WpfRoutingInputDemo
├─ App.xaml 应用启动配置
├─ App.xaml.cs 启动参数与测试入口
├─ MainWindow.xaml 五个实验页签
├─ MainWindow.xaml.cs 输入、拦截、拖放与触控处理
├─ InteractionRules.cs 可测试的输入规则与几何计算
├─ Models
│ └─ AppOptions.cs 启动参数
└─ ViewModels
├─ ObservableObject.cs 属性通知基类
└─ MainViewModel.cs 界面状态与日志

跟做时先让每一章单独运行,再把代码合并到同一个项目里。


1. 路由事件的拦截

本节目标

完成本章后,你应当能够:

  • 说清 Preview 阶段和普通冒泡阶段拦截的区别。
  • 知道 e.Handled = true 到底影响了什么。
  • 用日志证明拦截后哪些处理器没有执行。
  • 理解 handledEventsToo 为什么能看到已处理事件。
  • 知道什么时候该拦截、什么时候不该拦截。

1.1 拦截不是把事件「删掉」

拦截发生的阶段

图 2:Preview 从外层向目标下降;在 Inner 的预览阶段设置 Handled,后续普通处理器默认跳过。

鼠标事件已经由输入系统产生,路由事件对象也已经创建。e.Handled = true 改变的是后续处理器的默认行为:

1
2
3
4
5
事件已经发生
→ 当前处理器执行
→ 标记 Handled
→ 后续普通处理器默认跳过
→ 旁路观察器仍可看到

所以不要把它理解成「事件从未发生」。日志、审计或诊断处理器仍然可能观察到它。

1.2 在 Preview 阶段拦截

准备三层元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<Border x:Name="Outer"
Padding="28"
Background="#F7F9FD"
PreviewMouseDown="OnOuterPreviewMouseDown"
MouseDown="OnOuterMouseDown">
<Border x:Name="Middle"
Padding="28"
Background="#EEF4FF"
PreviewMouseDown="OnMiddlePreviewMouseDown"
MouseDown="OnMiddleMouseDown">
<Border x:Name="Inner"
Padding="28"
Background="#DDEBFF"
HorizontalAlignment="Center"
VerticalAlignment="Center"
PreviewMouseDown="OnInnerPreviewMouseDown"
MouseDown="OnInnerMouseDown">
<TextBlock Text="Inner 目标区域" />
</Border>
</Border>
</Border>

在 Inner 的预览处理器中拦截:

1
2
3
4
5
6
7
8
9
10
11
private void OnInnerPreviewMouseDown(
object sender,
MouseButtonEventArgs e)
{
Record("Inner.PreviewMouseDown", e);

if (InterceptEnabled)
{
e.Handled = true;
}
}

拦截开启时,实际执行顺序是:

1
2
3
4
5
6
7
Outer.PreviewMouseDown
Middle.PreviewMouseDown
Inner.PreviewMouseDown
(Handled=true)
Inner.MouseDown 不再执行
Middle.MouseDown 不再执行
Outer.MouseDown 不再执行

注意,外层 Preview 处理器仍然先执行。它们比 Inner 更早收到事件,所以拦截发生在 Inner 时,不能倒回去阻止已经执行过的外层 Preview。

1.3 跟做:五层日志

先准备一个日志集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
using System.Collections.ObjectModel;

public ObservableCollection<string> InterceptLog { get; } = new();

private void AddLog(string line)
{
InterceptLog.Insert(0, line);

while (InterceptLog.Count > 30)
{
InterceptLog.RemoveAt(InterceptLog.Count - 1);
}
}

处理器统一记录 sender、Source 和 Handled:

1
2
3
4
5
6
7
8
9
private void Record(string stage, RoutedEventArgs e)
{
var source = e.Source is FrameworkElement element
? element.Name
: e.Source?.GetType().Name ?? "未知";

AddLog(
$"{stage} | Source={source} | Handled={e.Handled}");
}

这样能同时观察两件事:

  • 哪些阶段执行了。
  • 执行时 Handled 是 false 还是 true。

1.4 拦截关闭时会发生什么

把 InterceptEnabled 设为 false,同样的点击会继续冒泡:

1
2
3
4
5
6
Outer.PreviewMouseDown
Middle.PreviewMouseDown
Inner.PreviewMouseDown
Inner.MouseDown
Middle.MouseDown
Outer.MouseDown

这说明 Preview 是向下隧道,普通 MouseDown 是向上冒泡。拦截只改变后续处理器是否接收,不改变已经发生的路由阶段。

1.5 handledEventsToo:旁路观察

如果确实需要在已处理事件之后继续观察,可以注册:

1
2
3
4
5
6
7
8
Inner.AddHandler(
UIElement.PreviewMouseDownEvent,
new MouseButtonEventHandler((sender, e) =>
{
AddLog(
$"旁路观察 | Handled={e.Handled}");
}),
handledEventsToo: true);

handledEventsToo: true 适合:

  • 日志和诊断。
  • 全局快捷键监听。
  • 需要知道「事件确实被处理了」的监控。

不适合:

  • 绕过业务拦截,继续执行危险动作。
  • 在多个层重复做同一件事。
  • 用旁路观察代替清晰的业务设计。

1.6 跟做:第 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
34
35
36
<Grid Margin="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="440" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<StackPanel Margin="0,0,24,0">
<TextBlock Text="在哪个阶段拦住事件"
FontSize="22"
FontWeight="Bold" />
<CheckBox Margin="0,16,0,0"
Content="在 Inner.PreviewMouseDown 设置 Handled"
IsChecked="{Binding InterceptEnabled}" />
<Button Margin="0,16,0,0"
Padding="14,8"
HorizontalAlignment="Left"
Content="运行自动演示"
Click="OnRunInterceptDemoClick" />
</StackPanel>

<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="210" />
</Grid.RowDefinitions>

<!-- 这里放前面三层 Border -->

<ListBox Grid.Row="1"
Margin="0,12,0,0"
FontFamily="Consolas"
Background="#0F172A"
Foreground="#DCE7F8"
ItemsSource="{Binding InterceptLog}" />
</Grid>
</Grid>

1.7 运行截图

路由拦截页签

图 3:拦截开启时,日志停在 Inner.PreviewMouseDown;旁路观察仍能看到已处理事件。

1.8 跟做检查点

  1. 勾选拦截,点击 Inner,日志只有三个 Preview 阶段。
  2. 取消拦截,点击 Inner,日志应包含三个 Preview 和三个普通 MouseDown。
  3. 旁路观察日志应显示 Handled=True。
  4. 点击外层空白区域,日志的 Source 不应仍然显示 Inner。

1.9 常见错误

现象 常见原因 修正方法
外层 Preview 也被拦住 拦截点写在外层 先画清路由路径,再选拦截点
取消勾选后仍不冒泡 e.Handled 被其他处理器设置 搜索所有处理器,不要只看当前方法
日志重复出现两次 XAML 和代码都订阅了同一个事件 只保留一处订阅
认为事件被删除了 把 Handled 当成取消事件 用 handledEventsToo 验证事件仍存在

1.10 小练习

  1. 把拦截点移到 Middle,观察 Outer 和 Inner 哪些处理器会执行。
  2. 只拦截鼠标左键,右键继续冒泡。
  3. 增加一个 handledEventsToo 观察器,分别记录 Handled 的前后值。

2. 路由拦截后的逻辑执行

本节目标

  • 知道拦截处理器应当完成哪些收尾动作。
  • 会把「判断规则」和「界面处理」分开。
  • 能用 PreviewKeyDown 拦截快捷键。
  • 理解为什么拦截后还要给用户反馈。
  • 避免用 Handled 静默吞掉所有输入。

2.1 拦截后的四步

拦截后的处理流程

图 4:识别条件、记录证据、更新界面、阻止默认动作,四步缺一不可。

一个可维护的拦截处理器通常按顺序做四件事:

1
2
3
4
识别条件:是不是本次规则命中的输入?
记录证据:来源、时间、按键、用户或业务对象。
更新界面:告诉用户操作被阻止,或显示替代结果。
阻止默认动作:只阻止本次不需要的默认行为。

如果只做最后一步,用户会觉得程序「没有反应」;如果只做前三步,默认动作仍会发生,拦截就没有完成。

2.2 先判断,再拦截

错误做法:

1
2
3
4
5
6
private void OnPreviewKeyDown(
object sender,
KeyEventArgs e)
{
e.Handled = true;
}

这段代码把输入框中的所有按键都锁死了。正确做法是先判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void OnShortcutPreviewKeyDown(
object sender,
KeyEventArgs e)
{
var isControl =
(Keyboard.Modifiers & ModifierKeys.Control) != 0;

if (!InteractionRules.IsEditableShortcut(isControl, e.Key))
{
return;
}

e.Handled = true;
AuditLog.Write($"拦截快捷键:Ctrl + {e.Key}");
StatusText.Text = $"Ctrl + {e.Key} 已拦截";
}

这里只处理 Ctrl + A/C/V/X/Z,其他按键继续按正常流程处理。

2.3 为什么 Ctrl+C 要在 Preview 阶段处理

输入框自己会处理复制、粘贴、撤销等命令。如果等到普通 KeyDown,输入框可能已经先执行了默认行为。

1
2
3
PreviewKeyDown:窗口或外层先看到
KeyDown:控件继续处理
TextInput / Command:执行输入或命令

在 Preview 阶段拦截,可以在默认命令执行前决定是否继续。

2.4 跟做:拦截后的三种动作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private void OnShortcutPreviewKeyDown(
object sender,
KeyEventArgs e)
{
var isControl =
(Keyboard.Modifiers & ModifierKeys.Control) != 0;

if (!InteractionRules.IsEditableShortcut(isControl, e.Key))
{
return;
}

// 1. 记录审计信息
AuditLog.Write(
$"{DateTime.Now:HH:mm:ss} Ctrl+{e.Key}");

// 2. 更新界面状态
StatusText.Text =
$"Ctrl + {e.Key} 已拦截,已记录审计日志";

// 3. 阻止默认行为
e.Handled = true;
}

Ctrl+V 如果不在拦截规则里,就正常执行粘贴。不要把「拦截一个快捷键」写成「禁止整个输入框」。

2.5 运行截图

拦截后的逻辑执行页签

图 5:Ctrl+C 被拦截后,界面显示审计结果,日志中记录拦截和阻止默认复制。

2.6 跟做检查点

  1. 在输入框按 Ctrl+C,界面显示已拦截。
  2. 日志中至少有「识别快捷键」「记录审计」「阻止默认复制」三类信息。
  3. 按 Ctrl+V,如果没有被规则命中,应正常粘贴。
  4. 按普通字母,不应被拦截。
  5. 取消拦截逻辑后,Ctrl+C 应恢复输入框默认行为。

2.7 常见错误

现象 原因 修正
所有按键都没反应 无条件 e.Handled = true 只在规则命中时拦截
输入框的复制失效 拦截了 Ctrl+C,但没有替代行为 明确产品规则,提供替代动作
日志写了但用户不知道 只写后台日志 同步更新界面提示
拦截写在 KeyUp 默认动作已经执行 改到 PreviewKeyDown 或 PreviewTextInput
快捷键判断漏掉修饰键 只判断 e.Key 同时检查 Keyboard.Modifiers

2.8 小练习

  1. 只拦截 Ctrl+S,并显示“已保存”的模拟状态。
  2. 拦截 Ctrl+C 时把复制内容写入自己的日志,不调用系统剪贴板。
  3. 给拦截增加开关,关闭后恢复默认输入框行为。
  4. 把规则函数移到 InteractionRules,写一个不需要启动界面的测试。

3. 键盘输入事件

本节目标

  • 分清按键事件和文本输入事件。
  • 理解 PreviewKeyDown、KeyDown、TextInput、TextChanged 的先后关系。
  • 会用 Keyboard.Modifiers 判断 Ctrl、Shift、Alt。
  • 知道输入法组合输入为什么不能只靠 KeyDown 处理。
  • 能做一个可观察的键盘输入实验框。

3.1 键盘事件不是只有 KeyDown

键盘输入事件链

图 6:先有按键,再有字符;文本控件最后还会报告文本已经变化。

常见顺序可以理解为:

1
2
3
4
5
PreviewKeyDown
→ KeyDown
→ PreviewTextInput
→ TextInput
→ TextChanged

但这不是每个按键都完全相同的固定流水线:

  • 方向键、F1、Esc 可能没有字符输入。
  • 输入法组合输入可能产生多个字符,KeyDown 与最终文字不一定一一对应。
  • 控件可能自己处理某些按键,导致普通事件不再执行。

3.2 按键和字符的区别

KeyDown 回答的是「哪个物理键被按下」:

1
2
3
4
5
6
7
private void OnPreviewKeyDown(
object sender,
KeyEventArgs e)
{
StatusText.Text = $"按键:{e.Key}";
ModifierText.Text = $"修饰键:{Keyboard.Modifiers}";
}

TextInput 回答的是「这次输入产生了什么文本」:

1
2
3
4
5
6
private void OnPreviewTextInput(
object sender,
TextCompositionEventArgs e)
{
StatusText.Text = $"输入文本:{e.Text}";
}

如果是快捷键,通常看 Key 和 Keyboard.Modifiers;如果是文字内容,通常看 TextInput 或控件最终文本。

3.3 跟做:键盘观察框

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<StackPanel Margin="24">
<TextBlock Text="键盘输入事件"
FontSize="22"
FontWeight="Bold" />
<TextBox x:Name="KeyInputBox"
Margin="0,14,0,0"
Padding="10,8"
AcceptsReturn="True"
PreviewKeyDown="OnKeyInputPreviewKeyDown"
PreviewTextInput="OnKeyInputPreviewTextInput"
TextChanged="OnKeyInputTextChanged" />

<TextBlock x:Name="StatusText"
Margin="0,12,0,0"
Text="等待键盘输入" />
<ListBox x:Name="KeyLogList"
Margin="0,14,0,0"
Height="360"
FontFamily="Consolas" />
</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
private void OnKeyInputPreviewKeyDown(
object sender,
KeyEventArgs e)
{
StatusText.Text =
$"PreviewKeyDown:{e.Key},修饰键={Keyboard.Modifiers}";

KeyLogList.Items.Insert(
0,
$"PreviewKeyDown | {e.Key} | {Keyboard.Modifiers}");
}

private void OnKeyInputPreviewTextInput(
object sender,
TextCompositionEventArgs e)
{
KeyLogList.Items.Insert(
0,
$"PreviewTextInput | \"{e.Text}\"");
}

private void OnKeyInputTextChanged(
object sender,
TextChangedEventArgs e)
{
KeyLogList.Items.Insert(
0,
$"TextChanged | 长度={KeyInputBox.Text.Length}");
}

3.4 运行截图

键盘输入事件页签

图 7:示例在输入框中记录 PreviewKeyDown 和 TextChanged;真实操作时还可以观察字符输入。

3.5 输入法与组合输入

中文输入法经常经历:

1
2
3
4
按键
→ 组合文本
→ 候选词选择
→ 最终提交

因此:

  • 不要假设每输入一个汉字就只有一个 KeyDown。
  • 不要只根据 KeyDown 拼接最终文本。
  • 需要最终文本时,优先读取控件的 Text 或处理 TextChanged。
  • 需要知道输入是否正在组合时,可以进一步了解 TextCompositionManager 和输入法相关事件。

3.6 跟做检查点

  1. 点击输入框,按普通字母,日志出现按键和文本变化。
  2. 按方向键,观察是否产生 PreviewTextInput。
  3. 按住 Shift 再按字母,观察 Keyboard.Modifiers。
  4. 按 Ctrl+C,观察第 2 章的快捷键拦截是否生效。
  5. 切换中文输入法,输入一个词,观察 TextChanged 的最终长度。

3.7 常见错误

现象 原因 修正
快捷键不生效 输入框先处理了按键 在 PreviewKeyDown 处理
中文输入日志很乱 用 KeyDown 逐键拼文本 读取最终 Text 或 TextChanged
所有按键都触发 TextInput 把按键事件和文本事件混为一谈 方向键通常没有字符文本
修饰键判断错误 只看 e.Key 同时看 Keyboard.Modifiers
重复日志 TextChanged 可能多次触发 记录内容变化,不要假设只触发一次

3.8 小练习

  1. 在输入框下方显示当前文本长度和选区范围。
  2. 按 Enter 时记录一次提交,但不要阻止换行,观察两者的差异。
  3. 做一个只允许输入数字的输入框,先思考应该拦截 KeyDown 还是处理 TextInput。

4. 拖拽事件

本节目标

  • 理解拖放为什么需要拖动源、数据对象和目标区域共同配合。
  • 会设置 AllowDrop 和拖放效果。
  • 会使用 DataObject 传递数据。
  • 会在 DragEnter、DragOver、Drop 中判断格式。
  • 知道拖放失败时应该怎样排查。

4.1 拖放不是单个事件

拖放状态变化

图 8:从按下、移动、进入目标到松开,拖放是一条状态链,不是单个 Drop。

拖放至少涉及两个角色:

1
2
拖动源:准备数据,调用 DoDragDrop
目标区域:声明 AllowDrop,判断格式,接收数据

目标区域通常要回答三个问题:

  1. 我允许接收拖放吗?
  2. 这份数据是我认识的格式吗?
  3. 接收后是复制、移动,还是拒绝?

4.2 拖动源:先判断移动阈值

不要一按下就启动拖放。鼠标轻微抖动也会被当成拖动:

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 Point _dragStart;
private bool _dragArmed;

private void OnDragSourceMouseDown(
object sender,
MouseButtonEventArgs e)
{
_dragStart = e.GetPosition(this);
_dragArmed = true;
}

private void OnDragSourceMouseMove(
object sender,
MouseEventArgs e)
{
if (!_dragArmed
|| e.LeftButton != MouseButtonState.Pressed)
{
return;
}

var current = e.GetPosition(this);

if (Math.Abs(current.X - _dragStart.X)
< SystemParameters.MinimumHorizontalDragDistance
&& Math.Abs(current.Y - _dragStart.Y)
< SystemParameters.MinimumVerticalDragDistance)
{
return;
}

_dragArmed = false;
StartDrag();
}

4.3 拖动源:准备数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void StartDrag()
{
var data = new DataObject();
data.SetData(DragDropRules.TextFormat, "WPF 拖放实验");
data.SetText("WPF 拖放实验");

var result = DragDrop.DoDragDrop(
DragSourceCard,
data,
DragDropEffects.Copy);

StatusText.Text = result == DragDropEffects.Copy
? "目标已接收"
: "目标没有接收";
}

DataObject 可以同时放多种格式:

1
2
自定义格式:给本程序精确识别
纯文本格式:方便其他程序接收

不要只放一个自定义格式,却期待记事本也能接收。

4.4 目标区域:AllowDrop 和格式判断

1
2
3
4
5
6
7
8
9
10
11
12
<Border x:Name="DropZone"
AllowDrop="True"
Background="#F7F9FD"
BorderBrush="#B9CDEE"
BorderThickness="2"
DragEnter="OnDropZoneDragEnter"
DragOver="OnDropZoneDragOver"
Drop="OnDropZoneDrop">
<TextBlock Text="拖到这里"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>

DragEnter 负责第一次判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
private void OnDropZoneDragEnter(
object sender,
DragEventArgs e)
{
var accepted = InteractionRules.IsAcceptedDrop(
DragDropRules.TextFormat,
e.Data.GetDataPresent(DragDropRules.TextFormat));

e.Effects = accepted
? DragDropEffects.Copy
: DragDropEffects.None;
e.Handled = true;
}

Drop 负责真正读取数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void OnDropZoneDrop(
object sender,
DragEventArgs e)
{
if (!e.Data.GetDataPresent(DragDropRules.TextFormat))
{
e.Effects = DragDropEffects.None;
e.Handled = true;
return;
}

var text =
e.Data.GetData(DragDropRules.TextFormat) as string
?? "(空文本)";

DropResultText.Text = $"已接收:{text}";
e.Effects = DragDropEffects.Copy;
e.Handled = true;
}

4.5 运行截图

拖放页签

图 9:拖动源提供自定义格式和文本格式,目标区域接收后显示结果。

4.6 跟做检查点

  1. 鼠标在拖动源按下后轻微移动,不应启动拖放。
  2. 移动超过系统阈值,鼠标应进入拖放状态。
  3. 拖到目标区域,目标边框或状态应显示允许复制。
  4. 松开鼠标,目标显示接收到的文本。
  5. 拖到不支持的区域,效果应为 None。

4.7 常见错误

现象 原因 修正
目标不响应 忘记设置 AllowDrop="True" 在目标元素上开启
光标一直显示禁止 DragEnter 没有设置 e.Effects 设置 Copy 或 None
能进入但不能放下 Drop 没有读取正确格式 用 GetDataPresent 检查
一按下就启动拖放 没有移动阈值 使用系统拖动阈值
拖放数据为空 格式字符串不一致 统一使用常量

4.8 小练习

  1. 把复制效果改成移动效果,观察 DoDragDrop 的返回值。
  2. 同时放入自定义格式和文本格式,分别拖到两个目标区域。
  3. 增加 DragLeave,在离开目标时恢复边框颜色。
  4. 给拖动源增加“数据摘要”,练习从对象中提取显示文本。

5. Touch与多点触控事件

本节目标

  • 分清原始触摸事件和操作事件。
  • 会用 TouchDevice.Id 区分不同触点。
  • 会记录每个触点的位置。
  • 理解 ManipulationStarting、ManipulationDelta、ManipulationCompleted 的作用。
  • 没有触屏时也能通过模拟和规则测试学习多点触控。
  • 会计算双指距离和缩放比例。

5.1 Touch 与 Manipulation 是两层

Touch 与操作事件分层

图 10:原始触摸事件跟踪每根手指,操作事件把多根手指看成一个整体手势。

原始触摸事件

1
2
3
TouchDown
TouchMove
TouchUp

适合:

  • 画板、手写。
  • 跟踪每个触点的编号和位置。
  • 需要知道「具体是哪根手指」。

操作事件

1
2
3
ManipulationStarting
ManipulationDelta
ManipulationCompleted

适合:

  • 平移、缩放、旋转。
  • 把多根手指的动作合成为一个整体。
  • 使用 WPF 提供的惯性、边界和手势参数。

5.2 触点编号和位置

每个触点都有自己的设备编号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private readonly Dictionary<int, Point> _touchPoints = new();

private void OnTouchDown(
object sender,
TouchEventArgs e)
{
var point = e.GetTouchPoint(TouchCanvas).Position;
_touchPoints[e.TouchDevice.Id] = point;

TouchCanvas.CaptureTouch(e.TouchDevice);
UpdateMarkers();

TouchCountText.Text = _touchPoints.Count.ToString();
e.Handled = true;
}

e.TouchDevice.Id 用来区分手指。不能只用一个 Point 保存当前位置,否则第二根手指会覆盖第一根手指。

5.3 跟做:单指触摸画布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<Canvas x:Name="TouchCanvas"
Background="Transparent"
IsManipulationEnabled="True"
TouchDown="OnTouchDown"
TouchMove="OnTouchMove"
TouchUp="OnTouchUp"
ManipulationStarting="OnManipulationStarting"
ManipulationDelta="OnManipulationDelta"
ManipulationCompleted="OnManipulationCompleted">
<Ellipse x:Name="TouchMarkerOne"
Width="42"
Height="42"
Fill="#E8F6EF"
Stroke="#176B4D"
StrokeThickness="2"
Visibility="Collapsed" />
</Canvas>

移动处理器只更新当前触点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void OnTouchMove(
object sender,
TouchEventArgs e)
{
if (!_touchPoints.ContainsKey(e.TouchDevice.Id))
{
return;
}

_touchPoints[e.TouchDevice.Id] =
e.GetTouchPoint(TouchCanvas).Position;

UpdateMarkers();
e.Handled = true;
}

抬起时移除:

1
2
3
4
5
6
7
8
private void OnTouchUp(
object sender,
TouchEventArgs e)
{
_touchPoints.Remove(e.TouchDevice.Id);
UpdateMarkers();
e.Handled = true;
}

5.4 双指距离

双指缩放几何

图 11:先计算两指距离,再计算相对比例;缩放范围是产品规则,不是 WPF 强制值。

1
2
3
4
5
6
7
8
9
public static double TwoFingerDistance(
Point first,
Point second)
{
var dx = second.X - first.X;
var dy = second.Y - first.Y;

return Math.Sqrt(dx * dx + dy * dy);
}

距离比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static double ScaleFromFingerDistance(
double currentDistance,
double initialDistance)
{
if (initialDistance <= 0)
{
return 1;
}

return Math.Clamp(
currentDistance / initialDistance,
0.6,
1.8);
}

0.6 和 1.8 是示例的产品限制。真实项目要根据控件大小、业务需求和可用空间调整。

5.5 ManipulationDelta:缩放和移动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void OnManipulationDelta(
object sender,
ManipulationDeltaEventArgs e)
{
var scale = e.DeltaManipulation.Scale;
var factor = Math.Max(scale.X, scale.Y);

if (factor > 0)
{
var next = Math.Clamp(
ScaleTransform.ScaleX * factor,
0.6,
1.8);

ScaleTransform.ScaleX = next;
ScaleTransform.ScaleY = next;
}

var translation = e.DeltaManipulation.Translation;
Canvas.SetLeft(TargetCard, Canvas.GetLeft(TargetCard) + translation.X);
Canvas.SetTop(TargetCard, Canvas.GetTop(TargetCard) + translation.Y);
}

ManipulationDelta 是增量,不是最终值。每次收到一点移动,就累加到当前状态。

5.6 没有触屏怎么办

示例提供两个层次:

  1. 点击“模拟双指张开”,程序显示两个触点和缩放结果。
  2. 点击“放大 0.10”和“缩小 0.10”,真实修改缩放变换。

另外,InteractionRules 中的距离和比例函数可以直接测试,不需要触摸设备:

1
2
3
4
5
6
7
var distance = InteractionRules.TwoFingerDistance(
new Point(10, 10),
new Point(40, 50));

var scale = InteractionRules.ScaleFromFingerDistance(
currentDistance: 216,
initialDistance: 160);

5.7 运行截图

Touch 与多点触控页签

图 12:示例用两个触点和缩放结果模拟双指张开,触屏设备上可直接操作真实触点。

5.8 跟做检查点

  1. 在触屏上单指按下,触点数量变为 1。
  2. 第二根手指按下,触点数量变为 2。
  3. 两指拉开,缩放值增大;两指靠近,缩放值减小。
  4. 任意一根手指抬起,触点数量减少。
  5. 没有触屏时,模拟按钮能改变缩放值,日志记录距离与比例。
  6. 缩放值始终在 0.6 到 1.8 之间。

5.9 常见错误

现象 原因 修正
第二根手指覆盖第一根 只保存一个 Point 用触点 Id 做字典键
TouchUp 收不到 没有捕获触点 调用 CaptureTouch
Manipulation 不触发 没有设置 IsManipulationEnabled 在元素上开启
缩放跳变 把增量当成最终值 累加并限制范围
双指距离总为 0 两个触点位置相同或没有更新 检查触点集合
触屏正常、鼠标无效 Touch 与鼠标是不同输入设备 需要鼠标操作时另写 Mouse 处理

5.10 小练习

  1. 把缩放范围改成 0.8 到 1.2,观察手感。
  2. 增加两指旋转角度,先只显示角度,不修改对象。
  3. 在触屏上画两个触点的移动轨迹。
  4. 把触点数量、距离、缩放值写入日志,排查手势不稳定的问题。

完整项目结构

跟做完成后,项目可以整理成:

1
2
3
4
5
6
7
8
9
10
11
WpfRoutingInputPractice
├─ App.xaml
├─ App.xaml.cs
├─ MainWindow.xaml
├─ MainWindow.xaml.cs
├─ InteractionRules.cs
├─ Models
│ └─ AppOptions.cs
└─ ViewModels
├─ ObservableObject.cs
└─ MainViewModel.cs

随附完整示例还包含:

1
2
3
4
5
WpfRoutingInputDemo
├─ SelfTestRunner.cs 自动检查规则与事件顺序
├─ UiProbe.cs 页签截图
└─ Models
└─ AppOptions.cs 测试、截图、自动退出参数

常见错误速查

路由拦截

问题 检查点
拦截后日志还在增加 检查是否注册了 handledEventsToo
外层 Preview 仍执行 拦截点在它之后,无法倒回阻止
取消拦截仍不执行 检查其他处理器是否设置了 Handled
事件处理器执行两次 检查 XAML 和代码是否重复订阅

键盘输入

问题 检查点
快捷键不生效 是否在 PreviewKeyDown 处理
中文输入不完整 是否只依赖 KeyDown
修饰键判断错误 是否读取 Keyboard.Modifiers
输入框无法输入 是否无条件设置了 e.Handled

拖放

问题 检查点
目标不响应 AllowDrop 是否为 true
光标显示禁止 DragEnter 是否设置了 e.Effects
Drop 没有数据 数据格式字符串是否一致
拖动源没有反应 是否超过系统拖动阈值

触摸

问题 检查点
多指覆盖 是否按 TouchDevice.Id 保存
Manipulation 不触发 是否设置 IsManipulationEnabled
缩放跳动 是否把增量当最终值
触点丢失 是否捕获并正确释放触点

综合练习

练习一:做一个安全的快捷键面板

要求:

  1. 只拦截 Ctrl+S。
  2. 拦截后显示“模拟保存成功”。
  3. 记录操作时间。
  4. 关闭拦截开关后,恢复输入框默认行为。

练习二:做一个拖放排序区

要求:

  1. 左侧有多个卡片。
  2. 拖到右侧时显示允许放置。
  3. 放置后保留卡片名称。
  4. 不支持的数据格式显示拒绝状态。

练习三:做一个触摸缩放画布

要求:

  1. 单指拖动对象。
  2. 双指缩放对象。
  3. 缩放范围限制在合理区间。
  4. 没有触屏时,用按钮模拟缩放。

练习四:做一次输入链路复盘

要求:

  1. 分别记录键盘、拖放和触摸的日志。
  2. 在日志中写清事件阶段、对象名称和关键状态。
  3. 找出一次操作中重复执行的处理器。
  4. 把能抽出的判断移到 InteractionRules。

结语

这一篇的五个主题最终都在回答同一个问题:输入怎样安全、清楚地变成界面行为。

路由拦截决定事件是否继续传递;拦截后的逻辑负责把控制权转成可解释的业务结果;键盘事件处理按键和文本;拖放事件把数据从一个控件交给另一个控件;触摸和 Manipulation 把多根手指变成可计算的状态。

从输入到业务动作的完整链路

图 13:事件处理器负责连接输入与规则,纯业务判断尽量放进可独立测试的方法。

每次实现一个输入功能,都可以按下面顺序检查:

1
2
3
4
5
输入设备是什么?
事件在哪个阶段发生?
我要保存哪些状态?
规则是否可以独立测试?
用户能否看到结果和失败原因?

能回答这五个问题,再遇到新的输入方式,就不需要靠背事件名了。