简介:这是一份面向WPF初中级开发者的ListBox拖放交互示例包,解决两个ListBox之间跨列表拖放、列表内上下移动及拖动时边框视觉反馈等问题。包内共37个文件、总大小94KB,包含11个cs源码文件和2个xaml界面文件,可直接查看拖放事件注册、DataObject设置、DragOver/Drop处理及ObservableCollection数据交换等完整实现;同时附带sln解决方案、csproj工程文件、exe可执行程序、pdb调试符号及cache缓存等编译运行所需配套文件。可快速理解事件绑定逻辑和命令绑定方式,便于直接运行或改写到自己项目中。该示例包已有822人学习,占用空间小、结构精简,适合需要快速上手WPF拖放功能的开发者下载参考,也可作为排序列表、数据管理等交互场景的入门模板。
1. WPF ListBox之间拖放:数据搬移比视觉实现更值得关注
WPF的ListBox之间拖拽(drag&drop)是个典型的“看着容易做起来碎”的功能。网上能找到的示例多半只是把某个ListBox设为AllowDrop、处理几个拖放事件,但一遇到跨ListBox搬移数据、同ListBox内重排序、集合绑定同步,就各种翻车。这篇文章从一个实际项目里拆出来的思路出发,把源侧如何发起拖动、目标侧如何接收放下、数据如何安全搬移、以及五个高频坑位逐一讲清楚。适合正在做WPF桌面端、准备把拖放集成到自己列表场景中的开发者,尤其是ListView、DataGrid里也已经验证过这套逻辑的。
2. 拖放的事件链路:从鼠标按下到数据落地
2.1 完整的拖放事件序列
WPF的拖放不是单事件,而是由一串事件协作完成的。整个流程可以拆成四段:
- 源侧捕获鼠标按下位置,在移动达到阈值后调用DragDrop.DoDragDrop发起拖放。
- 系统在整个拖放过程中维护一个数据对象DataObject,并且持续探测鼠标当前所在的控件。
- 目标控件(比如另一个ListBox)通过AllowDrop=true声明自己愿意接收放下,并且通过DragEnter、DragOver、DragLeave、Drop四个事件参与数据接收。
- 最终用户在目标上松开鼠标,Drop事件触发,源数据从DataObject中取出来做搬移。
先写一个最小的源侧发起拖动的代码:
private Point _dragStartPoint; private void SourceListBox_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { _dragStartPoint = e.GetPosition(null); } private void SourceListBox_PreviewMouseMove(object sender, MouseEventArgs e) { if (e.LeftButton != MouseButtonState.Pressed) return; Point currentPos = e.GetPosition(null); if (Math.Abs(currentPos.X - _dragStartPoint.X) < SystemParameters.MinimumHorizontalDragDistance && Math.Abs(currentPos.Y - _dragStartPoint.Y) < SystemParameters.MinimumVerticalDragDistance) { return; } ListBox listBox = sender as ListBox; object data = listBox.SelectedItem; if (data == null) return; DragDrop.DoDragDrop(listBox, data, DragDropEffects.Move); }这段代码的关键在于“移动阈值判断”。SystemParameters.MinimumHorizontalDragDistance和MinimumVerticalDragDistance是系统级的拖拽阈值,一般取4像素左右。直接用这两个参数的好处是不同DPI缩放下行为一致,不用自己硬编码一个“拖了5像素就算开始拖动”的魔法数字。有人喜欢写成if (distance < 5) return,这在100%缩放下凑合能用,但放到150%或200%缩放的屏幕上,拖动手感会明显变涩,因为物理距离没变,逻辑像素却变了。
注意这里是PreviewMouseLeftButtonDown和PreviewMouseMove,也就是隧道事件。如果你使用普通的MouseDown/MouseMove,会遇到ListBox自身的选择逻辑干扰,拖动手感会变得很别扭。隧道事件在处理上更早,方便你先把本次鼠标按下的坐标记住,避免被ListBox内部的Item容器消费掉这个按下事件。另外,这里用e.GetPosition(null)取的是相对于整个窗口的坐标,而不是相对于某个ListBox的坐标,原因是后续只要比较移动距离,并不需要知道具体位置在哪个控件上,用屏幕坐标最省事。
2.2 DataObject与数据格式:跨ListBox传递的根本
DoDragDrop的第二个参数是data,很多人直接传一个字符串或者一个对象就完事,这在同一个窗口内的两个ListBox之间传数据时通常可用,但随着场景复杂化就会出问题。比如当拖放源和目标在不同的ViewModel层,或者你希望拖放过程中携带额外的自定义信息(比如源ListBox的名字、源集合的标识)时,只传一个裸对象是远远不够的。
这时候应该使用DataObject来包装要传递的数据:
DataObject dataObject = new DataObject(); dataObject.SetData("MyCustomItem", data); dataObject.SetData(typeof(ListBox), listBox); dataObject.SetData(DataFormats.Text, data.ToString()); DragDrop.DoDragDrop(listBox, dataObject, DragDropEffects.Move);这里我设置了三种数据格式,各有用途:
- "MyCustomItem"是一种自定义字符串格式,用于在目标侧取出真正的业务数据对象,避免类型不匹配时抛出异常。
- typeof(ListBox)存的是拖放源本身。这样在Drop处理中能直接判断这次拖放是从哪个ListBox发起的,方便决定是移动还是复制。
- DataFormats.Text作为兜底,如果拖放目标不是我们自己的ListBox,而是外部程序(比如拖到文本编辑器里),至少能拿到一个字符串表示。
在目标侧取数据的时候,要按顺序尝试格式:
private void TargetListBox_Drop(object sender, DragEventArgs e) { if (e.Data.GetDataPresent("MyCustomItem")) { object item = e.Data.GetData("MyCustomItem"); // 对item做搬移 } else if (e.Data.GetDataPresent(DataFormats.Text)) { string text = (string)e.Data.GetData(DataFormats.Text); // 至少拿到了字符串,可以创建新条项 } }判断数据格式是否存在时用GetDataPresent而不是直接用GetData去取,是因为如果格式不存在,GetData返回null,你无法区分到底是没数据还是数据类型不对。实际项目里我一般会定义一个静态字符串常量来统一自定义格式的名字,比如const string MyFormat = "MyCustomFormat",避免散落在各个事件处理器里拼错大小写。
2.3 DragDropEffects怎么选:Move、Copy还是Link
DragDropEffects是拖放效果枚举,常用的有None、Copy、Move、Link。它不只是一个返回给系统的状态值,它直接影响拖放过程中的鼠标光标样式,以及Drop事件里你拿到的AllowedEffects。
| 枚举值 | 鼠标光标 | 实际含义 |
|---|---|---|
| None | 禁用图标 | 目标不能接收本次放落 |
| Copy | 加号光标 | 拖放后复制数据,源数据保留 |
| Move | 移动光标 | 拖放后移动数据,源数据移除 |
| Link | 链接光标 | 拖放后建立关联,源数据保持不变 |
| Scroll | 滚动光标 | 拖放过程中允许目标自动滚动 |
我常用的DragOver处理方式:
private void TargetListBox_DragOver(object sender, DragEventArgs e) { e.Effects = DragDropEffects.Move; e.Handled = true; }这里有个细节:如果拖放源和目标是在同一个窗口内,我通常直接返回Move效果。但如果拖放源是外部文件(比如从资源管理器拖文件到ListBox),这时候应该检查e.Data.GetDataPresent(DataFormats.FileDrop),有文件才返回Copy效果,没有就返回None。这样系统会在鼠标上显示一个禁止的图标,而不是允许你松开后才发现没有数据可以放。
严格来说,DragOver中设置e.Effects时应该与DoDragDrop传入的allowedEffects做按位与运算,但很多业务场景下拖放源和目标都受控于同一套代码,直接设成Move问题不大。不过如果你做的是通用控件库,传给外部使用,最好还是按位与一下,避免目标侧声明了Move而源侧只允许Copy,导致系统识别出矛盾效果。虽然WPF内部会做兼容处理,但这种不一致很容易让你Debug时摸不着头脑。
3. 两个ListBox之间的拖放实现:从能拖到能放
3.1 基础实现:源侧与目标侧的最小闭环
上一章把事件链路拆开了,这一章把它们合起来,做一个真正能跑的最小实现。界面上有两个ListBox,左边是可选列表,右边是已选列表,拖放方向是从左边到右边,数据搬移之后源集合移除、目标集合添加。
先看XAML:
<StackPanel Orientation="Horizontal" Margin="20"> <ListBox x:Name="SourceListBox" Width="200" Height="300" AllowDrop="True" PreviewMouseLeftButtonDown="SourceListBox_PreviewMouseLeftButtonDown" PreviewMouseMove="SourceListBox_PreviewMouseMove" DragOver="ListBox_DragOver" Drop="TargetListBox_Drop" /> <ListBox x:Name="TargetListBox" Width="200" Height="300" Margin="20,0,0,0" AllowDrop="True" DragOver="ListBox_DragOver" Drop="TargetListBox_Drop" /> </StackPanel>然后在后台代码里把VM中的数据源绑定上:
public partial class MainWindow : Window { public ObservableCollection<string> SourceItems { get; set; } public ObservableCollection<string> TargetItems { get; set; } public MainWindow() { InitializeComponent(); SourceItems = new ObservableCollection<string> { "条目A", "条目B", "条目C", "条目D" }; TargetItems = new ObservableCollection<string>(); SourceListBox.ItemsSource = SourceItems; TargetListBox.ItemsSource = TargetItems; } }接下来是目标侧的核心逻辑在Drop里:
private void TargetListBox_Drop(object sender, DragEventArgs e) { if (!e.Data.GetDataPresent("MyCustomItem")) return; object draggedItem = e.Data.GetData("MyCustomItem"); ListBox sourceListBox = e.Data.GetData(typeof(ListBox)) as ListBox; ObservableCollection<string> sourceCollection = sourceListBox.ItemsSource as ObservableCollection<string>; ObservableCollection<string> targetCollection = (sender as ListBox).ItemsSource as ObservableCollection<string>; if (sourceCollection != null && targetCollection != null) { int sourceIndex = sourceCollection.IndexOf(draggedItem as string); targetCollection.Add(draggedItem as string); if (sourceIndex >= 0) sourceCollection.RemoveAt(sourceIndex); } }这里在Drop事件里同时操作两个集合。因为绑定的集合是ObservableCollection ,ListBox会收到集合变化通知并自动刷新UI,不需要手动调用Items.Refresh()。这个设计是WPF数据绑定的基本功,但如果不使用可观察集合、而是给ItemsSource赋值一个普通List ,拖放后界面不会自动更新,必须重新给ItemsSource赋值,这是后面避坑章节要展开的一个坑。
注意数据搬移的顺序是“先加到目标集合,再从源集合删除”。如果先删除再添加,而源和目标是同一个集合(同ListBox内重排序场景),就会产生错误——先删再插就会导致对象被移除后索引失效,后续插入时可能插到错误位置甚至抛异常。所以这里先执行Add,后执行Remove。跨ListBox场景下这个顺序不是必须的,但统一写成先加后删,可以让同一套代码同时兼容两种场景,少一个隐患。
3.2 数据操作的双向同步:为什么不直接操作ListBox.Items
有部分网上示例是用listBox.Items.Add(item)直接操作ListBox.Items集合。这个做法在一次性窗口中确实能跑通,但它绕过了数据绑定体系。一旦你的ListBox的ItemsSource绑定到ViewModel集合,Items.Add出来的对象不会进入ViewModel的集合,下次ViewModel刷新数据时,你手动添加的这条数据会消失,或者更糟糕——ListBox上显示的东西和ViewModel里的数据不一致,排查起来相当痛苦。
我的建议是,只要ListBox用了ItemsSource绑定,拖放里对数据的增删一律以数据源集合为准,不要去碰Items。写一个辅助方法来做集合的搬移:
private void MoveItemBetweenCollections( ObservableCollection<string> sourceCollection, ObservableCollection<string> targetCollection, object item) { int sourceIndex = sourceCollection.IndexOf(item as string); if (sourceIndex < 0) return; targetCollection.Add(item as string); sourceCollection.RemoveAt(sourceIndex); }这个方法虽然简单,但把“从哪来、到哪去、怎么删”三个问题一次理清。调用的地方只需要传入两个集合和一个条目对象,剩下的逻辑不散落在事件处理中。如果后面要改成异步搬移,或者在搬移前加权限校验,只需要改这一个方法。
如果你的业务数据是复杂类型而不是字符串,IndexOf的比较逻辑会变成引用比较。对于ObservableCollection ,IndexOf默认使用EqualityComparer .Default,普通引用类型的实体如果没有重写Equals,比较的就是引用。这通常符合拖放场景的预期——你自己把选中的条目对象放到DataObject里,再从DataObject里取出来,拿到的就是同一个引用,IndexOf自然能命中。但如果你在拖放过程中做了序列化、深拷贝,或者从字典里重新取了一条“内容相同但实例不同”的数据,IndexOf就找不到,这是需要自己保证的。
3.3 视觉反馈:鼠标下的悬停Item高亮
拖放功能如果能拖了、能放了,接下来最影响体验的就是视觉反馈。默认情况下,WPF的ListBox在拖放过程中几乎没有任何提示——鼠标上有拖动的影子是系统提供的,但你根本不知道当前鼠标悬停在哪个条目上,也不知道放下之后会插到哪个位置。
一个常见的做法是在DragOver中根据鼠标位置找到当前悬停的ListBoxItem,然后给它一个高亮背景:
private void TargetListBox_DragOver(object sender, DragEventArgs e) { e.Effects = DragDropEffects.Move; ListBox listBox = sender as ListBox; Point dropPosition = e.GetPosition(listBox); ListBoxItem targetItem = GetNearestContainer(listBox, dropPosition); // 先清除上一次的高亮 RemoveHighLight(listBox); if (targetItem != null) { targetItem.Background = new SolidColorBrush(Color.FromArgb(50, 0, 120, 255)); } e.Handled = true; } private ListBoxItem GetNearestContainer(ListBox listBox, Point position) { // 遍历可视树里的ListBoxItem,找到包含当前坐标的那个 foreach (object item in listBox.Items) { ListBoxItem container = listBox.ItemContainerGenerator .ContainerFromItem(item) as ListBoxItem; if (container != null) { Rect bounds = new Rect( container.TransformToAncestor(listBox).Transform(new Point(0, 0)), container.RenderSize); if (bounds.Contains(position)) return container; } } return null; }GetNearestContainer的实现是遍历ListBox的所有条目,把每个条目的容器从可视树上取出来,然后判断鼠标坐标是否落在容器的矩形范围内。这里用到了TransformToAncestor,因为ListBoxItem的坐标是相对于它自身面板的,必须转换成ListBox坐标系才能和e.GetPosition返回的坐标比较。
这个小方法的性能和ListBox条目数量成正比。如果你的列表只有几十条,完全没问题。如果有几千条,每帧在DragOver里全量遍历可视树会卡顿。优化方案是改用VisualTreeHelper.HitTest,命中测试只需要对可视树做一次深度优先搜索,性能好得多:
private ListBoxItem GetNearestContainer(ListBox listBox, Point position) { HitTestResult result = VisualTreeHelper.HitTest(listBox, position); DependencyObject target = result?.VisualHit; while (target != null && !(target is ListBoxItem)) { target = VisualTreeHelper.GetParent(target); } return target as ListBoxItem; }这个方法先以鼠标坐标做一次命中测试,拿到被击中的可视节点,然后向上查找第一个ListBoxItem祖先。代码量少、效率高,推荐在正式项目里用这个版本。注意如果鼠标当前不在任何一个条目上,HitTest会命中ListBox本身或者它的ScrollViewer,向上查找祖先直到顶层也没有ListBoxItem,就会返回null,高亮自然就清除了。
4. 同ListBox重排序与跨ListBox移动:两项需求的处理差异
4.1 分清场景:拖放源与目标是否为同一个ListBox
在实际业务里,拖放有两种完全不同的需求:第一种是跨ListBox搬移数据,第二种是在同一个ListBox内重排序。两者的数据操作逻辑不同——跨ListBox是“从A删、向B加”,同ListBox是“从集合中移除后再插到新位置”。如果混在一起处理,重排序场景下会出现自己给自己搬家的错乱。
判断场景最直接的方式是看拖放源与Drop事件里的sender是否同一个控件:
private void ListBox_Drop(object sender, DragEventArgs e) { if (!e.Data.GetDataPresent("MyCustomItem")) return; ListBox targetListBox = sender as ListBox; ListBox sourceListBox = e.Data.GetData(typeof(ListBox)) as ListBox; if (sourceListBox == targetListBox) { // 同一个ListBox内重排序 ReorderWithinSameList(sourceListBox, e); } else { // 跨ListBox移动 MoveAcrossLists(sourceListBox, targetListBox, e); } }这个分支判断是整个拖放处理的核心骨架。把两种逻辑拆开,后续代码就不会互相干扰。注意拖放源是通过DataObject里的typeof(ListBox)格式取出来的,我们在第2章里存了它,现在派上了用场。
4.2 重排序实现:先计算出插入位置,再搬运数据
同一个ListBox内的重排序,难点在于确定“插入到哪个位置”。这个位置由Drop时鼠标所在的条目索引决定,有两种策略:
- 插到悬停条目的正上方(index位置)
- 插到悬停条目的正下方(index+1位置)
我习惯用悬停条目的正上方,因为视觉上更符合直觉——你把条目拖到某个条目的上边缘,它就出现在那个位置。实现上,通过ListBox的ItemContainerGenerator拿不到直接的索引,但可以遍历Items找到对应的ListBoxItem后,用listBox.Items.IndexOf(item)拿到索引。
private void ReorderWithinSameList(ListBox listBox, DragEventArgs e) { object draggedItem = e.Data.GetData("MyCustomItem"); ObservableCollection<string> collection = listBox.ItemsSource as ObservableCollection<string>; if (collection == null || draggedItem == null) return; Point dropPosition = e.GetPosition(listBox); ListBoxItem targetContainer = HitTestListBoxItem(listBox, dropPosition); if (targetContainer == null) return; object targetItem = listBox.ItemContainerGenerator.ItemFromContainer(targetContainer); int targetIndex = collection.IndexOf(targetItem); int sourceIndex = collection.IndexOf(draggedItem); // 重新插入前先移除源条目 collection.RemoveAt(sourceIndex); // 移除后索引会变化,需要校正 if (sourceIndex < targetIndex) targetIndex--; collection.Insert(targetIndex, draggedItem); }这里的索引校正很关键。从集合中移除源条目之后,目标索引如果大于源索引,就需要减一。举个例子:集合是[A, B, C, D],把A拖到D的位置。源索引是0,目标索引是3。先从集合移除A变成[B, C, D],此时目标位置原本的索引3指向的元素已经是集合末尾后面的位置了,如果直接Insert(3, A)结果反而变成[B, C, D, A],A跑到末尾了。正确做法是targetIndex--变成2,Insert(2, A)得到[B, C, A, D]。
这个索引偏移问题在代码评审里不怎么被注意,但一旦实际拖几次就会暴露出“拖过去位置总是不对”的怪异行为。而且它只在源索引小于目标索引时发生,属于典型的偶发性bug,最容易漏掉。
4.3 多选拖放:集合操作时的索引批量处理
如果ListBox启用了多选模式,一次拖放可能携带多个条目。此时上面的单条目逻辑就不够用了。多选拖放需要把所有选中的条目装入一个数组,放入DataObject:
private void SourceListBox_PreviewMouseMove(object sender, MouseEventArgs e) { // ...移动阈值判断省略... ListBox listBox = sender as ListBox; List<object> selectedItems = listBox.SelectedItems.Cast<object>().ToList(); if (selectedItems.Count == 0) return; DataObject dataObject = new DataObject(); dataObject.SetData("MultiSelectItems", selectedItems); dataObject.SetData(typeof(ListBox), listBox); DragDrop.DoDragDrop(listBox, dataObject, DragDropEffects.Move); }注意这里不能用listBox.SelectedItems直接作为DataObject的数据。因为SelectedItemCollection是对Live列表的引用,一旦拖放过程中集合发生变化,这个引用里的内容也跟着变,你无法拿到“拖动那一刻的选中快照”。这就是为什么我先用Cast
在Drop端接收多选数据时,批量插入和删除的顺序是另一个坑:
private void MoveMultiItemsAcrossLists(ListBox sourceList, ListBox targetList, DragEventArgs e) { List<object> items = e.Data.GetData("MultiSelectItems") as List<object>; ObservableCollection<string> sourceCollection = sourceList.ItemsSource as ObservableCollection<string>; ObservableCollection<string> targetCollection = targetList.ItemsSource as ObservableCollection<string>; if (items == null || sourceCollection == null || targetCollection == null) return; // 先把所有条目加到目标集合 foreach (object item in items) { targetCollection.Add(item as string); } // 再从源集合统一删除 foreach (object item in items) { sourceCollection.Remove(item as string); } }这段代码的关键是“先全部Add,再逐个Remove”。如果边删边加,而源与目标指向同一个集合(用户从同一个多选ListBox内拖动重排),遍历过程中集合长度变化会让Remove的索引错位。先Add后Remove在集合对象不同时是安全的;在集合相同时,先Add会把相同对象复制一份放进同一个集合,Remove时按对象引用删除只删一个副本,虽然逻辑上不是理想的原地重排,但至少不会因为索引变换导致越界异常。
实际的同集合多选重排,更稳妥的做法是对选中项按原索引排序,然后按从后往前的顺序依次Remove,再一次性Insert到目标位置。这个场景写起来比较复杂,如果业务上遇到,建议单独抽个方法处理,不要和跨ListBox的多选搬移共用一套逻辑。
5. 避坑:ListBox拖放中五个常见的翻车现场
5.1 拖拽完全没反应
现象:鼠标按住条目拖动,就是一个斜杠圆圈的禁用光标,或者鼠标下方完全没有拖动的影子,怎么拖都拖不起来。
原因:最常见的是没有判断鼠标按键状态。我见过有人在MouseMove里不管左键是否按下就直接调用DoDragDrop,这样的结果是列表里每个条目在鼠标悬浮移动时都会触发拖放初始化,不仅拖不起来,还造成UI卡顿。另一个常见原因是把事件绑定在了普通的MouseMove上而不是PreviewMouseMove,ListBox内部的Item容器捕获了鼠标事件,你的MouseMove根本没机会触发。
解决:在MouseMove的第一行检查e.LeftButton != MouseButtonState.Pressed就返回。然后把事件绑定到PreviewMouseMove上,记得在事件处理里判断移动距离是否超过系统阈值。代码参考第2.1节,这两个条件缺一不可。曾经有个项目里拖放时而灵时而不灵,查了半天发现是某个全局样式里把ListBoxItem的IsHitTestVisible设成了False,鼠标事件根本到不了ListBoxItem上,自然也就选不中条目,拖放无从谈起。
5.2 放下后UI不刷新
现象:拖放成功后,源集合里的条目依然显示在界面上,目标集合里也没看到新条目,或者要等下一次数据绑定刷新才出现。
原因:给ListBox的ItemsSource绑定的不是集合类型或其他可通知的集合,而是List 。List 的增删不会触发集合变化通知,WPF的ListBox无法感知数据变化,界面自然不刷新。另一种情况是绑定的是ObservableCollection,但你在后台代码里直接又把ItemsSource换成了一个新的集合实例,而绑定的DataContext还是旧实例,等于改错了对象。
解决:养成用ObservableCollection 作为列表绑定的习惯。如果项目里必须用List ,那就在拖放操作完成后显式刷新:sourceListBox.ItemsSource = null; sourceListBox.ItemsSource = sourceList; 这种手动刷新的方式能用,但在数据量大的时候会造成整个列表重建,性能差不说,还会丢失滚动位置和选中状态。所以我的建议是优先用ObservableCollection。
5.3 DragOver里频繁操作可视树导致闪烁
现象:拖放过程中鼠标在ListBox上移动时,界面一闪一闪的,特别是高亮背景变来变去,有时还会出现卡顿。
原因:DragOver事件在鼠标每移动一个像素就会触发一次。如果在DragOver里每次都做可视树遍历、创建新的画刷赋值给Background,开销就会非常大。另外如果先Clear高亮再设置高亮,但没有合理的去重逻辑,即使鼠标悬停在同一个条目上,也会不断执行“清除再设置”的操作,导致闪烁。
解决:在DragOver开头做一次简单的去重——如果当前高亮的条目和要设置高亮的条目是同一个,就直接返回:
private ListBoxItem _currentHighlightItem; private void TargetListBox_DragOver(object sender, DragEventArgs e) { e.Effects = DragDropEffects.Move; ListBox listBox = sender as ListBox; Point dropPosition = e.GetPosition(listBox); ListBoxItem targetItem = HitTestListBoxItem(listBox, dropPosition); if (_currentHighlightItem == targetItem) { e.Handled = true; return; } if (_currentHighlightItem != null) _currentHighlightItem.Background = Brushes.Transparent; _currentHighlightItem = targetItem; if (targetItem != null) targetItem.Background = new SolidColorBrush(Color.FromArgb(50, 0, 120, 255)); e.Handled = true; }用字段保存当前高亮的条目容器,每次先比较再操作,视觉反馈会平滑很多。注意这里用Brushes.Transparent来还原背景,如果你在ListBox的样式中定义了非默认的背景色,还原时应该用你原来的画刷,而不是硬编码Transparent。
5.4 触屏设备上拖不动
现象:在带触摸屏的电脑上,鼠标拖放正常,但用手指长按、滑动时列表无法拖起来,或者直接触发了系统的触摸长按右键菜单。
原因:WPF的拖放是基于鼠标消息的,触摸操作默认会被转译成鼠标消息,但拖放手势的识别策略不同。触屏上手指按住不动,系统会认为是一个长按操作,而不是拖放的开始,这个行为由StylusPlugin和触摸事件的手势识别机制控制。
解决:如果业务必须支持触屏拖放,不要试图去调WPF内部的触摸手势参数,直接在触摸事件上自己做拖放。核心还是那套DoDragDrop,只不过触发点从PreviewMouseMove换成PreviewTouchMove,起点记录从PreviewTouchDown开始。这里给一个简化版:
private Point _touchStartPoint; private void SourceListBox_PreviewTouchDown(object sender, TouchEventArgs e) { _touchStartPoint = e.GetTouchPoint(this).Position; } private void SourceListBox_PreviewTouchMove(object sender, TouchEventArgs e) { Point pos = e.GetTouchPoint(this).Position; double dx = Math.Abs(pos.X - _touchStartPoint.X); double dy = Math.Abs(pos.Y - _touchStartPoint.Y); if (dx < 16 && dy < 16) return; ListBox listBox = sender as ListBox; object data = listBox.SelectedItem; if (data == null) return; DragDrop.DoDragDrop(listBox, data, DragDropEffects.Move); }触摸场景的移动阈值我习惯放宽到16像素,因为手指接触面积比鼠标光标大,太小的阈值容易误触。这里的16也是经验值,如果你们的目标设备触屏采样率低,可能还要放宽到24甚至32。
5.5 拖放源ListBox容器在Drop时已经不可见
现象:从ListBox A拖到ListBox B,Drop事件里通过e.Data.GetData(typeof(ListBox))取源ListBox时发现是null,或者取到了但ItemsSource为空。
原因:如果拖放过程中源ListBox发生了重新渲染(比如ItemsSource被重建、列表被刷新、或者窗口关闭重建了控件),控件实例可能已经被替换,而DataObject里保存的还是旧实例的引用。还有一种情况是你在DataObject里只放了业务数据,忘记放源控件引用,而从别的地方获取源时已经拿不到了。
解决:在DoDragDrop的时候,把源ListBox和它的ItemsSource集合引用一起放进DataObject:
DataObject dataObject = new DataObject(); dataObject.SetData("MyCustomItem", data); dataObject.SetData("SourceListBox", listBox); dataObject.SetData("SourceCollection", listBox.ItemsSource);取的时候优先用SourceCollection而不是从ListBox.ItemsSource再取一次,因为后者可能因为控件状态变化而变成null。这也是为什么我在第3章里建议在Drop中把ItemsSource转成ObservableCollection后统一操作,而不是依赖ListBox控件本身的状态。
6. 把拖放逻辑收进附加行为:一个可复用的ListBox拖放方案
前面写的都是事件处理器散布在CodeBehind里的写法。真实项目里,一个窗口中可能有三四组ListBox,如果每组都复制粘贴一套事件处理,维护成本会快速上升。更优雅的方案是把拖放逻辑封装成附加行为(Attached Behavior),通过附加属性绑定到任意ListBox上。
这里给出一个完整的最小附加行为实现,支持跨ListBox移动和同ListBox重排序,数据源要求是ObservableCollection
public static class ListBoxDragDropBehavior { public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached( "IsEnabled", typeof(bool), typeof(ListBoxDragDropBehavior), new PropertyMetadata(false, OnIsEnabledChanged)); public static void SetIsEnabled(DependencyObject element, bool value) => element.SetValue(IsEnabledProperty, value); public static bool GetIsEnabled(DependencyObject element) => (bool)element.GetValue(IsEnabledProperty); private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is ListBox listBox) { if ((bool)e.NewValue) { listBox.AllowDrop = true; listBox.PreviewMouseLeftButtonDown += OnPreviewMouseLeftButtonDown; listBox.PreviewMouseMove += OnPreviewMouseMove; listBox.DragOver += OnDragOver; listBox.Drop += OnDrop; } else { // 卸载事件,避免重复挂载 listBox.AllowDrop = false; listBox.PreviewMouseLeftButtonDown -= OnPreviewMouseLeftButtonDown; listBox.PreviewMouseMove -= OnPreviewMouseMove; listBox.DragOver -= OnDragOver; listBox.Drop -= OnDrop; } } } // 事件处理与第2、3章的逻辑相同,略 }使用方式是在XAML里设置附加属性:
<ListBox ItemsSource="{Binding SourceItems}" local:ListBoxDragDropBehavior.IsEnabled="True" /> <ListBox ItemsSource="{Binding TargetItems}" local:ListBoxDragDropBehavior.IsEnabled="True" />引用附加行为之后,拖放逻辑不再需要任何单个ListBox的后台事件代码,在窗口里新增一组ListBox时只需要复制两行XAML就可以获得同样的拖放能力。
做好之后怎么验证?我一般会跑四类用例:第一,同列表内把首条拖到末尾,确认重排序索引正确;第二,跨列表连续拖三次以上,确认源集合和目标集合的条目数始终吻合;第三,在多选模式下选中三条任意顺序拖动,确认搬移后顺序保持不变;第四,把窗口放到150%显示缩放下重复拖放,确认坐标转换在高DPI下不出偏差。
这套东西我在实际项目里用了一两年,近期重构时把附加行为里的数据格式名抽成了常量类,又把GetData的类型转换统一换成了as安全转换,不再用强转。每一次拖放都在Drop事件里加调试日志记录源索引和目标索引,基本踩过一轮后就不再出幺蛾子。从那以后我每次新开一个涉及列表数据搬移的窗口,都会先问一句:这个拖放行为能不能直接复用附加行为,而不是再写一遍事件代码。希望帮到你。
本文还有配套的精品资源,点击获取