拖拽与交互:Adorner、DragDrop 与晶圆拖放信息
这是「从零搭建半导体设备上位机」系列第9篇,也是界面骨架这批的最后一篇。晶圆从一个腔拖到另一个腔,看起来是个纯 UI 动作,实际牵着"能不能拖、能拖到哪、拖过去之后设备认不认"三层判断。
一次"拖过去没反应"
第一版拖拽上线那天,操作员把一片晶圆从 Loadlock 拖到腔体 A,松手,什么都没发生。
不是报错,不是卡住,是完全没反应------晶圆还在原位,界面上连个提示都没有。
查的时候分了三步:
- 拖拽事件有没有触发?加了日志,
DragOver触发了,Drop也触发了。 Drop里做了什么?读代码,它调了一个async方法去下发 PLC 命令。- 命令有没有下发?PLC 侧没有收到。
原因是 Drop 是 async void,里面 await 了一个方法,而那个方法抛了异常------异常被 async void 吞掉了,界面上什么都不会显示。
这一篇就从这里开始。
一、为什么用 GongSolutions 而不是原生 DragDrop
WPF 原生拖拽要手写一串事件:
text
PreviewMouseLeftButtonDown → 记录起点
PreviewMouseMove → 判断超过阈值,调 DoDragDrop
DragEnter / DragOver → 目标端判断能不能放
Drop → 真正处理
DragLeave → 清理
六个事件、两端各写一遍,还要自己管"拖拽数据放在哪"。
项目用的是 GongSolutions.Wpf.DragDrop (NuGet 包 gong-wpf-dragdrop),它把两端封成两个接口:
csharp
public class WaferDragDropHandler : IDropTarget, IDragSource
IDragSource:管"我这边的数据怎么被拖走"。IDropTarget:管"别处拖来的东西我收不收"。
一个类 237 行,把两侧都实现了。在 XAML 里挂上去:
xml
<ItemsControl dd:DragDrop.IsDragSource="True"
dd:DragDrop.IsDropTarget="True"
dd:DragDrop.DragDropHandler="{Binding DragDropHandler}" />
比原生的写法少一半代码,而且不用管事件顺序。
二、拖拽的四个阶段

2.1 CanStartDrag:能不能拖
csharp
public bool CanStartDrag(IDragInfo dragInfo)
判断的是"这个元素允许被拖吗"。晶圆不能随便拖------正在被机械手抓着的不能拖,工艺运行中的不能拖。
这里返回 false 之后,鼠标按下去拖不动,没有任何提示。这是个体验问题:操作员不知道自己为什么拖不动。
后来在 CanStartDrag 里加了一句提示消息,通过 Growl 弹出来:"工艺运行中,禁止拖动晶圆"。
2.2 DragOver:能不能放
csharp
void IDropTarget.DragOver(IDropInfo dropInfo)
{
// ... 判断目标容器是否接受这片晶圆
dropInfo.Effects = DragDropEffects.Move;
dropInfo.DropTargetAdorner = DropTargetAdorners.Highlight;
}
这一步决定三件事:
- 能不能放 (
Effects设成None就是禁止,鼠标会变成禁放图标) - 放下去是什么操作 (
Move/Copy) - 目标端给什么视觉反馈 (
DropTargetAdorner)
代码里 DropTargetAdorner 旁边压着三行注释:
csharp
//dropInfo.DropTargetAdorner = typeof(DropTargetHighlightAdorner);
//dropInfo.DropTargetAdorner = typeof(SimpleCircleAdorner);
dropInfo.DropTargetAdorner = DropTargetAdorners.Highlight;
这是三次尝试的痕迹:先想用官方的高亮 Adorner,再试自己写的圆形 Adorner,最后回到内置的 Highlight。注释留着没删,是这段代码里少有的"历史存档",虽然按规矩应该删掉。
2.3 Drop:真正落数据
csharp
async void IDropTarget.Drop(IDropInfo dropInfo)
这一个方法里有 130 多行,做四件事:
- 从
dropInfo.Data取出拖拽信息(IWaferDragDropInfo) - 判断目标腔体有没有空位(
MaxWafers) - 下发 PLC 命令
- 等设备反馈,更新界面
2.4 DragDropOperationFinished / DragCancelled
收尾用的。这两个方法里各有一句 MessageBox.Show ,其中 DragDropOperationFinished 里是:
csharp
MessageBox.Show("DragDropOperationFinished");
调试时留下的,忘了删。
三、IWaferDragDropInfo:拖拽要传什么
拖拽时两端交换的数据是 object,需要约定一个类型:
csharp
public interface IWaferDragDropInfo
{
int WaferNo { get; set; }
BContainer CurCharber { get; set; }
int MaxWafers { get; set; }
}
三个字段:
WaferNo:晶圆编号CurCharber:当前所在容器MaxWafers:目标容器的容量上限
为什么用接口不用具体类:源端和目标端可能是不同的容器类型(Loadlock、Buffer、Chamber),用接口可以只传"目标端关心的那部分信息"。
为什么不用 object 强转 :强转失败时抛 InvalidCastException,在 Drop 这种 async void 里会被吞掉,表现就是"拖过去没反应"。用接口 + as 判断,失败时能给一句人话。
四、Adorner:跟随鼠标的那层皮
拖拽时跟着鼠标走的那张小图,是 Adorner 干的。
4.1 WaferDragAdorner
79 行 XAML + 29 行代码------代码部分只有 InitializeComponent(),纯视觉。
它继承的是 UserControl 而不是 Adorner。这是 GongSolutions 的用法:拖拽跟随视觉用 UserControl 定义内容,由框架负责挂到 Adorner 层上。
4.2 SimpleCircleAdorner
114 行,自己实现的 Adorner。重写两个方法:
csharp
protected override int VisualChildrenCount => 1;
protected override Visual GetVisualChild(int index) => _child;
protected override Size MeasureOverride(Size constraint) { ... }
protected override Size ArrangeOverride(Size finalSize) { ... }
关键是 ArrangeOverride------它决定 Adorner 画在哪儿。这里最容易错的是坐标系。
AdornerLayer 的坐标是相对于被装饰元素的,而鼠标位置是相对于窗口的。两者不转换,表现就是"那个圈跟着鼠标跑,但永远偏移一段固定距离"。
正确做法:
csharp
var pos = Mouse.GetPosition(AdornedElement);
_child.Arrange(new Rect(pos.X + _offsetX, pos.Y + _offsetY, _width, _height));
Mouse.GetPosition(AdornedElement) 而不是 Mouse.GetPosition(null)。
五、为什么不用右键菜单
有人问过:拖拽这么麻烦,为什么不做个右键菜单("移动到腔体A")?
三个理由:
拖拽能表达"目标还没定"。 右键菜单要先选目标,选完才知道行不行;拖拽是"拖过去,不行就不让放",反馈是即时的------鼠标移到不接受的腔体上,光标直接变成禁放图标。
误操作代价不同。 右键菜单点错一项就直接执行了;拖拽要经历"按下 → 移动 → 松开",中途发现不对可以移回去松手。
现场的手感。 操作员戴手套,右键菜单的字号很难点准,拖拽是粗粒度操作,容错高。
但拖拽有个真实缺点:不可发现。新来的操作员不知道这东西能拖。
所以界面上同时保留了右键菜单作为兜底入口,两者共用同一套命令和校验------菜单只是另一个入口,不是另一套逻辑。这一点很重要:如果菜单和拖拽各写一套判断,迟早会出现"菜单能点但拖不动"或者反过来。
六、两个 Behavior:选择同步与滚动跟随
拖拽相关的交互里,还有两个小东西值得单独说。它们不是控件,是 Behavior------给已有控件加行为,不改控件本身。
6.1 SelectedItemsBehavior:让 ListBox 的多选能双向绑定
WPF 的 ListBox.SelectedItems 是只读属性,不能直接绑到 ViewModel 的集合。标准解法是附加属性:
csharp
public static readonly DependencyProperty SelectedItemsProperty =
DependencyProperty.RegisterAttached("SelectedItems", typeof(IList),
typeof(SelectedItemsBehavior), new PropertyMetadata(null, OnSelectedItemsChanged));
关键在于防止双向同步无限循环:
ViewModel 改集合 → 同步到 ListBox → ListBox 触发 SelectionChanged → 又同步回 ViewModel → 死循环。
代码里用一个标志位挡:
csharp
private static bool _isUpdating = false;
private static void ListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is ListBox listBox && !_isUpdating)
{
_isUpdating = true;
// 把 listBox.SelectedItems 写回附加属性
_isUpdating = false;
}
}
思路是对的,实现有一个坑,见下面踩坑记录第四条。
6.2 ListBoxScrollToBottomBehavior:日志列表自动跟到底
csharp
public class ListBoxScrollToBottomBehavior : Behavior<ListBox>
{
protected override void OnAttached()
{
base.OnAttached();
((ICollectionView)AssociatedObject.Items).CollectionChanged += ListBoxScrollToBottomBehavior_CollectionChanged!;
}
protected override void OnDetaching()
{
base.OnDetaching();
((ICollectionView)AssociatedObject.Items).CollectionChanged -= ListBoxScrollToBottomBehavior_CollectionChanged!;
}
private void ListBoxScrollToBottomBehavior_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
if (AssociatedObject.HasItems)
AssociatedObject.ScrollIntoView(AssociatedObject.Items[^1]);
}
}
新日志进来就滚到最后一条,操作员不用手动拖滚动条。
这个 Behavior 做得对的地方是 OnAttached / OnDetaching 成对写。订阅和退订必须成对,否则控件销毁后事件还挂着------日志列表这种高频刷新的场景,漏一次退订就是内存一路涨。
用 Behavior 而不是继承 ListBox 的好处:同一个 ListBox 可以挂多个 Behavior,互不干扰;而继承只能有一个父类。
踩坑记录
坑一:async void 的 Drop,异常被吞
开头那个问题的根因。
async void 方法里的异常不会被调用方捕获,会直接抛到 SynchronizationContext 上------在 WPF 里就是 DispatcherUnhandledException。但 GongSolutions 的 Drop 调用链里包了 try/catch(TryCatchOccurredException),异常被它接住了,然后......什么都没做。
修法两条一起上:
Drop内部自己 try/catch ,把异常转成Growl提示 + 日志。- 实现
TryCatchOccurredException,让它至少写一条日志:
csharp
public bool TryCatchOccurredException(Exception exception)
{
AppLog.Error("拖拽过程异常", exception);
return true; // true = 已处理,不再往上抛
}
工控界面里,"什么都没发生"是最坏的错误表现。 宁可用红字骂一句,也别静默。
坑二:残留的 MessageBox.Show
DragDropOperationFinished 里那句 MessageBox.Show("DragDropOperationFinished"),在生产环境会弹一个模态框。
后果:操作员拖完一片晶圆,弹个框,必须点确定。工艺还在跑,界面被模态框挡住,报警看不见。
这类调试残留应该由编译期检查兜住 ------至少加一条 #if DEBUG,或者干脆在 CI 里 grep MessageBox.Show 拦下来。
坑三:CurCharber 拼写错误扩散
接口里那个字段叫 CurCharber,应该是 CurChamber(腔体)。
拼错之后没人改,因为它"能用"。等到后来有人写 CurChamber 去访问,编译不过,才发现有两个拼法在代码里共存。
公共接口里的拼写错误,成本比私有字段高一个数量级------因为改它要动所有实现类。
坑四:_isUpdating 是静态字段,两个列表会互相干扰
SelectedItemsBehavior 里那个标志位是 private static bool,全局唯一。
页面上有两个 ListBox 都用这个 Behavior 时,A 正在同步的过程中 B 也触发同步,B 看到 _isUpdating == true 就把自己的同步跳过了。
表现是"多选偶尔丢一项"------而且只在两个列表同时操作时出现,极难复现。
正确做法是把标志挂在控件上(用附加属性,或者 ConditionalWeakTable<DependencyObject, object>),而不是静态字段。凡是"防止重入"的标志,作用域必须是单次操作,不能是全局。
本篇小结
- 拖拽用 GongSolutions 封装的
IDragSource/IDropTarget,比原生六事件省一半代码。 - 四个阶段各有职责 :能不能拖(
CanStartDrag)、能不能放(DragOver)、落数据(Drop)、收尾(Finished/Cancelled)。 async void+ 被框架吞异常 = "什么都没发生" ,必须自己 try/catch 并实现TryCatchOccurredException。- Adorner 的坐标系 用
Mouse.GetPosition(AdornedElement),不是窗口坐标。
界面骨架这批(01-09)到这里结束。下一批开始写功能页面:主窗口导航、手动页面的判据链、参数页校验、日志页。
下期预告
第10篇:主窗口与导航:Auto / Manual 双模式切换