从零搭建半导体设备上位机(九):拖拽与交互,Adorner、DragDrop 与晶圆拖放信息

拖拽与交互:Adorner、DragDrop 与晶圆拖放信息

这是「从零搭建半导体设备上位机」系列第9篇,也是界面骨架这批的最后一篇。晶圆从一个腔拖到另一个腔,看起来是个纯 UI 动作,实际牵着"能不能拖、能拖到哪、拖过去之后设备认不认"三层判断。


一次"拖过去没反应"

第一版拖拽上线那天,操作员把一片晶圆从 Loadlock 拖到腔体 A,松手,什么都没发生。

不是报错,不是卡住,是完全没反应------晶圆还在原位,界面上连个提示都没有。

查的时候分了三步:

  1. 拖拽事件有没有触发?加了日志,DragOver 触发了,Drop 也触发了。
  2. Drop 里做了什么?读代码,它调了一个 async 方法去下发 PLC 命令。
  3. 命令有没有下发?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 多行,做四件事:

  1. 从 dropInfo.Data 取出拖拽信息(IWaferDragDropInfo)
  2. 判断目标腔体有没有空位(MaxWafers)
  3. 下发 PLC 命令
  4. 等设备反馈,更新界面

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),异常被它接住了,然后......什么都没做。

修法两条一起上:

  1. Drop 内部自己 try/catch ,把异常转成 Growl 提示 + 日志。
  2. 实现 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>),而不是静态字段。凡是"防止重入"的标志,作用域必须是单次操作,不能是全局。

本篇小结

  1. 拖拽用 GongSolutions 封装的 IDragSource / IDropTarget,比原生六事件省一半代码。
  2. 四个阶段各有职责 :能不能拖(CanStartDrag)、能不能放(DragOver)、落数据(Drop)、收尾(Finished / Cancelled)。
  3. async void + 被框架吞异常 = "什么都没发生" ,必须自己 try/catch 并实现 TryCatchOccurredException。
  4. Adorner 的坐标系 用 Mouse.GetPosition(AdornedElement),不是窗口坐标。

界面骨架这批(01-09)到这里结束。下一批开始写功能页面:主窗口导航、手动页面的判据链、参数页校验、日志页。


下期预告

第10篇:主窗口与导航:Auto / Manual 双模式切换

相关推荐
郝学胜-神的一滴1 天前
AI 编程智能体 27:吃透MCP通信机制
人工智能·python·langchain·wpf·numpy·scikit-learn
波力海苔夹心脆6752 天前
C# WPF 资源字典详解:ResourceDictionary、MergedDictionaries 与自定义控件案例
开发语言·c#·wpf·入门教程·resource·资源字典
陈仓主教2 天前
# WPF CheckBox 控件详解与使用—WPF 中的控件(6)--复选框控件(CheckBox)
wpf
光泽雨4 天前
WPF 音视频四大核心方案对比
wpf·音视频
浪扼飞舟5 天前
WPF(数据绑定)
wpf
samble6 天前
从零搭建半导体设备上位机(四):单实例、启动流程与配置文件,App 里那 526 行到底在干什么
wpf·配置文件·单实例
浪扼飞舟6 天前
WPF(控件的继承树、内容控件、布局控件)
wpf
avi91119 天前
电影开源Jevdo - 源码介绍(vue版)没有android 和 没有wpf
android·wpf·框架·数据爬虫·meego·电影管理
吾与谁归in10 天前
C#脚本工具,支持智能提示自动补全(WPF + Roslyn + AvalonEdit)
开发语言·c#·wpf