向 Explorer Tree 控件添加拖放功能






4.70/5 (26投票s)
ExpTree 第二部分:为 ExpTree 控件添加拖放功能。
目录
引言
将拖放功能添加到表示文件系统的控件似乎很简单。但实际上并非如此,即使是对于普通的文件系统对象也是如此。如果你的控件的功能超出了文件系统,包含了 Shell 命名空间中的所有对象,那么情况会更加复杂。这里提供的代码并未完成所有工作,但它几乎涵盖了所有方面。所有文件系统对象都可以被拖放到任何接受它们的文件夹上,包括虚拟文件夹。完全支持移动、复制以及“在此处创建快捷方式”等操作,并且遵循熟悉的 Windows 风格,同时完全支持左右键拖动、键盘修饰符和默认操作。这段代码通过让 Windows 来处理,从而获得了正常的 Windows 拖放行为。此处介绍的类充当了拖动源的 IDataObject 和目标文件夹的 IDropTarget 接口之间的代理,后者提供了 Windows 行为,就像在 Win Explorer 中一样。
背景
在第一部分 使用 ImageList 管理的 VB.NET Explorer 树控件 中,我介绍了一个包含类似 Windows Explorer 的 TreeView 控件的类库。该控件提供了类似 Win Explorer 的 Shell 命名空间对象视图。该库还包含一个功能齐全的类,它替代了 .NET 的文件和目录类,并支持虚拟文件夹。此外,还有一个类可以轻松访问 Windows 用于显示文件、文件夹和其他对象(正如 Windows Explorer 所显示的那样)的图标。在第二部分,我将向该控件添加拖放功能。
此添加提供了必要的拖放功能。
- 在拖动进入、移过、离开或放置到控件时,为用户提供视觉反馈。
- 执行放置操作。
- 更新控件以反映由放置操作引起的变化。
我假设本文的读者已经阅读了第一部分。特别有助于理解其中介绍的 CShItem 类的作用。关于 CShItem 的完整解释将在第三部分介绍,但除非有需求,否则我并不打算编写。
拖放有什么难点,以及这段代码有哪些不足之处?
乍一看,.NET 提供了许多支持拖放的良好工具。NET 的 DataObject 支持多种格式的拖动项,而 File 和 Directory 类允许进行 File.Move、File.Copy 和 Directory.Move 操作,那问题出在哪里呢?这又是另一篇文章的主题了,但请考虑,并没有 Directory.Copy,无法创建“在此处创建快捷方式”,File 和 Directory 类只处理文件系统,右键支持意味着需要拦截放置操作,并且 ContextMenu 总是在你的 DragDrop 事件处理程序退出后返回。当然,还需要处理只读、系统和已存在的文件。如果你需要与用户交互来处理这些情况,应该考虑系统的区域设置,以便用户能理解你的话……还有很多。对于许多这些问题都有解决方法。然而,这里提出的技术只是将大部分问题转交给被拖动或放置到的 Shell 文件夹的 IDropTarget 接口。该接口会处理所有这些问题,而且无需任何成本……嗯,成本不高。
这里介绍的类将*不能*正确处理来自不包含必要数据格式的拖动源的非文件系统对象的拖动。特别是,NET 的 DataObject 无法处理某些数据格式,因此,NET 拖动源(无论是同一应用程序还是不同 .NET 应用程序中的控件)将无法提供这些格式。严格来说,这不是我的类的问題,而是拖动源的问題。我的类可以接受几乎任何可以从 Windows Explorer 拖动的对象 - 但不是所有可以从 .NET 源拖动的对象。不幸的是,这包括从控件本身拖动。最坏的情况是拖动一个路径为“https:///somedirectory/somefile.htm”的项目,该项目来自我的网络位置。另一方面,路径为“\\somemachine\somedirectory\somefile.xyz”的项目,即使来自我的网络位置,也能正常工作。稍后将介绍其中一些原因。
概述
ExpTree 控件是一个 UserControl,它仅包含一个 TreeView,外观类似于 Windows Explorer 的左侧窗格。像任何控件一样,它可以放置在窗体上。有关详细信息,请参见第一部分。要激活其接受放置的功能,请将其 AllowDrop 属性设置为 True
。初始化时,控件会检查此设置,如果为 True
,则会创建一个 TVDragWrapper 类的实例,并调用 API 例程 RegisterDragDrop 将该实例注册为 TreeView 的 DragDropHandler。TVDragWrapper 实例现在将接收并处理 TreeView 的 DragEnter、DragOver、DragLeave 和 DragDrop 事件。不需要 .NET 风格的拖动相关事件处理程序。
TVDragWrapper 和 ExpTree 之间存在分工。TVDragWrapper 负责处理拖放的机制,并在拖动的 IDataObject 和 DropTarget 的 IDropTarget 接口之间进行代理。ExpTree 负责管理 TreeView 在拖动过程中的外观。与 ExpTree 的通信通过 TVDragWrapper 中声明的四个事件完成:ShDragEnter、ShDragOver、ShDragLeave 和 ShDragDrop。
拖动过程中 TVDragWrapper 事件的顺序是:
- 拖动进入控件时会引发 DragEnter。处理程序会提取关于正在拖动的内容的信息。如果可接受,它会检查是否存在 Shell 的 IDList 数组数据。如果没有,它会创建该数据并将其添加到正在拖动的 IDataObject 中。如果一切正常,它会引发 ShDragEnter 事件,让 TreeView 了解情况。大部分繁琐的工作由 CProcDataObject 类完成,稍后将讨论。
- 当拖动在控件表面移动时,会多次引发 DragOver。为了最大限度地减少处理,该类会记住拖动最后所在的 TreeNode。如果拖动不在节点上,则清除记住的状态并退出。如果在同一节点上,则退出。如果在一个新的、有效的 DropTarget 节点上,则获取该节点所代表的文件夹的 IDropTarget 接口,调用该接口的 DragEnter 和 DragOver 事件,保存文件夹返回的 DragDropEffect,为 TreeView 引发 ShDragOver 事件,然后退出,将 DragDropEffect 报告给 Shell 的 DragDrop 处理器,后者将提供用户反馈。
- 如果拖动离开控件,则会引发 DragLeave。TVDragWrapper 需要进行清理,并引发 ShDragLeave 事件,以便控件也可以进行清理。
- 当拖动放置其 IDataObject 时,会引发 DragDrop。由于之前的事件已经设置好了一切,TVDragWrapper 几乎没有什么可做的。在进行错误检查后,它会调用文件夹的 IDropTarget 的 DragDrop 方法,并将 IDataObject 传递进去。然后,它会引发 ShDragDrop 事件,通知控件拖放操作已发生。
拖动过程中 ExpTree 事件的顺序是:
- ShDragEnter 不执行任何操作。
- ShDragOver 通过更改当前 TreeNode 的背景颜色来为用户提供视觉提示,并在拖动移到其他位置时将其恢复。它还会启动和停止一个 Timer,该 Timer 会在拖动悬停在一个节点上一段时间(目前为 1200 毫秒)后展开一个折叠的节点。
- ShDragLeave 会停止 Timer 并重置任何更改的背景颜色。
- ShDragDrop 会停止 Timer,重置节点背景颜色,并在适当时,对拖放的源节点和目标节点调用 RefreshNode。如果放置的目标是 TreeView 当前选定的节点,它还会伪造一个重选该节点的操作,以通知 ExpTreeNodeSelected 事件的任何侦听者,其内容可能已更改。
细节决定成败
拖放的三个主要方面是:IDropTarget 接口、IDataObject 接口以及反映应用程序显示的变化。每个方面都有其细节。
IDropTarget 接口
TVDragWrapper 类在控件初始化时被注册为控件的 COM IDropTarget。一个小事件处理程序会处理 tv1.HandleDestroyed 事件,在控件被销毁时调用 API 例程 RevokeDragDrop 进行清理。这*不是*标准的 .NET 拖放接口。类中各个事件处理程序接收的信息与相应的 .NET 事件处理程序类似,但*不*相同。
如上所述,TVDragWrapper 接收所有与拖动相关的事件通知。当拖动在控件上移动时,会反复调用 DragOver。以下代码片段来自 DragOver。在确定拖动当前位于一个新节点(在代码中称为 tn)之上后,会执行此代码。
'Drag is now over a new node with new capabilities
Dim CSI As CShItem = tn.Tag
If CSI.IsDropTarget Then
m_LastTarget = CSI.GetDropTargetOf(m_View)
If Not IsNothing(m_LastTarget) Then
pdwEffect = m_Original_Effect
Dim res As Integer = _
m_LastTarget.DragEnter(m_DragDataObj, _
grfKeyState, pt, pdwEffect)
If res = 0 Then
res = m_LastTarget.DragOver(grfKeyState, _
pt, pdwEffect)
End If
If res <> 0 Then
Marshal.ThrowExceptionForHR(res)
End If
Else
pdwEffect = 0 'couldn't get IDropTarget,
'so report effect None
End If
Else
pdwEffect = 0 'CSI not a drop target,
'so report effect None
End If
RaiseEvent ShDragOver(tn, ptClient, _
grfKeyState, pdwEffect)
End If
Return 0
鉴于 CShItem 类中已有的信息,实现 GetDropTargetOf 方法(参见源文件下载)相对容易。它返回当前节点所代表的文件夹的 IDropTarget COM 接口。拥有并使用文件夹的 IDropTarget 消除了“拖放有什么难点”部分中提到的许多困难。注意:DragOver 是控件的 IDropTarget 的一个方法。在此代码中,它获取文件夹的 IDropTarget。假设我们确实拥有文件夹的 IDropTarget,现在我们通过调用其 DragEnter 和 DragOver 方法与其进行交互。文件夹本身提供了它将支持何种放置操作(针对拖动的数据)的最终决定。这是通过 ByRef 参数 pdweffect 完成的,它与 .NET 的 DragDropEffects 枚举完全等效。
IDataObject 接口
IDropTarget 接口实际上很容易使用,并带来了巨大的优势。IDataObject 接口则要困难得多,而且只有一个回报……没有它就没有要拖动的数据。代码处理三种 IDataObject。NET 的 IDataObject 是 .NET 应用程序特有的。它与“普通”COM IDataObject 不同,例如从 Windows Explorer 拖动时你会得到的。第三种变体是从 .NET 应用程序(而非拖动所在的应用程序)拖动的 .NET IDataObject。
.NET DataObject 和它包装的 .NET IDataObject 很容易使用,但存在致命缺陷。它无法提供所有必需的数据格式!大多数(或所有)真正的命名空间扩展文件夹(因此不属于文件系统)都需要 FileContents 和 FileGroupDescriptorW 格式的数据。这些格式被定义为支持每个拖动多个项目,并有一个索引值用于获取数据。.NET 的 IDataObject 没有索引的设置。在 Framework 1.0 和 1.1 中,无法通过标准方法使用 .NET IDataObject 从此类文件夹中拖动项目。我不确定,但有理由相信 Framework 2.0 将解决这个问题。
COM IDataObject 是一个已定义的接口(参见 MSDN 获取详细信息),用于支持 COM 实体之间的数据交换。我的代码将接受 .NET IDataObject 或 COM IDataObject 作为被拖动和放置的数据。
CProcDataObject 类
CProcDataObject 类负责解码拖动的 IDataObject 并确定其对控件的有效性。其构造函数接受一个指向某种 IDataObject 的 IntPtr。它确定了它拥有哪种 IDataObject。然后,它确定正在拖动的数据是否满足某些标准,构建一个 CShItems 的 ArrayList 来表示拖动的项目,确保 IDataObject 具有 Shell IDList Array 格式的数据,并在所有工作都完成后将一个属性设置为 True
。
被拖动的 IDataObject 必须具有以下一种或多种格式的数据:
- CShItems 的 ArrayList - 仅可能来自控件的同一应用程序实例。如果可能,这是首选格式。
- Shell IDList 数组 - 首选用于源自应用程序实例之外的拖动。MakeShellIDArray 例程可公开使用,以便在控件是拖动的源时创建它。如果 IDataObject 中不存在,CProcDataObject 将尝试创建一个。
- FileDrop 格式。最后一个选择,原因充分,但也是 .NET 应用程序最容易创建的格式。
该类生成、保存并作为属性提供的 CShItems 的 ArrayList,用于确定放置操作执行时 GUI 应如何更新。
这是谁的 IDataObject,无论如何
CProcDataObject 的构造函数会弄清楚它正在处理哪种 IDataObjects。首先,请注意,TVDragWrapper 的 DragEnter 和 DragDrop 方法不会收到一个漂亮的 .NET DataObject。它们收到的是一个 IntPtr,它指向一个 COM 接口,该接口可能是 .NET IDataObject 或其他东西。
Sub New(ByRef pDataObj As IntPtr) 'Assumed to be a
'pointer to an
'IDataObject
m_DataObject = pDataObj
Dim HadError As Boolean = False 'used for various
'error conditions
Try
IDO = Marshal.GetTypedObjectForIUnknown(pDataObj, _
GetType(ShellDll.IDataObject))
Catch ex As Exception
HadError = True
End Try
'If it is really a .Net IDataObject,
'then treat it as such
If HadError Then
Try
NetIDO = Marshal.GetTypedObjectForIUnknown(pDataObj, _
GetType(System.Windows.Forms.IDataObject))
IsNet = True
Catch
IsNet = False
End Try
End If
If IsNet Then
'Any error in ProcessNetDataObject will leave
'm_IsValid as False -- our only Error Indicator
ProcessNetDataObject(NetIDO)
Else 'IDataObject not from Net, Do it the hard way
If HadError Then Exit Sub 'can do no more
ProcessCOMIDataObject(IDO)
'It either worked or not. m_IsValid is
'set accordingly, so we are done
End If
End Sub
这段代码确定了我们正在处理的 IDataObject 的类型。然后,它调用其他例程来执行有用的操作。不明显的是,从另一个 .NET 应用程序拖动的 IDataObject 将最终走 ProcessCOMIDataObject 路径。
什么是 Shell IDList 数组,为什么我需要关心它?
Shell IDList 数组是 CIDA 结构的另一个名称。它之所以重要,有两个原因:
- 它是拖动中表示 Shell 对象的首选方式。
- 如果对文件夹执行了右键放置操作,并且没有 CIDA,则文件夹会假定你的链接选项是“创建文档快捷方式”。这对 ExpTree 来说*永远*不是正确的选择,因为 ExpTree 希望链接选项是“在此处创建快捷方式”。因此,CProcDataObject 会确保被拖动的 IDataObject 包含 Shell IDList 数组。如果不存在,它会创建一个并将其添加到 IDataObject 中。
Shell IDList 数组是一种对 VB 不友好的结构。它对控件至关重要,而且很难处理。CProcDataObject 提供了三个与 CIDA 相关的例程来处理它。它们是:MakeShellIDArray(从 CShItems 的 ArrayList 创建一个)、MakeDragListFromCIDA(从表示为 MemoryStream 的 CIDA 创建一个 CShItems 的 ArrayList)和 MakeStreamFromCIDA(接受指向 CIDA 的 IntPtr 并返回 MemoryStream)。最后一个是必需的,因为 IDataObject.GetData 返回的 CIDA 的形式是指向实际 CIDA 的 IntPtr 的 IntPtr。以下代码是这些例程之一,并说明了处理这个至关重要的数据结构的乐趣。
Private Function MakeStreamFromCIDA(ByVal ptr As IntPtr) As MemoryStream
MakeStreamFromCIDA = Nothing 'assume failure
If ptr.Equals(IntPtr.Zero) Then Exit Function
Dim nrItems As Integer = Marshal.ReadInt32(ptr, 0)
If Not (nrItems > 0) Then Exit Function
Dim offsets(nrItems) As Integer
Dim curB As Integer = 4 'already read first 4
Dim i As Integer
For i = 0 To nrItems
offsets(i) = Marshal.ReadInt32(ptr, curB)
curB += 4
Next
Dim pidlLen As Integer
Dim pidlobjs(nrItems) As Object
For i = 0 To nrItems
Dim ipt As New IntPtr(ptr.ToInt32 + offsets(i))
Dim cp As New cPidl(ipt)
pidlobjs(i) = cp.PidlBytes
pidlLen += CType(pidlobjs(i), Byte()).Length
Next
MakeStreamFromCIDA = New MemoryStream(_
pidlLen + (4 * offsets.Length) + 4)
Dim BW As New BinaryWriter(MakeStreamFromCIDA)
With BW
.Write(nrItems)
For i = 0 To nrItems
.Write(offsets(i))
Next
For i = 0 To nrItems
.Write(CType(pidlobjs(i), Byte()))
Next
End With
MakeStreamFromCIDA.Seek(0, SeekOrigin.Begin)
End Function
我忘了提 CIDA 通过 PIDL 表示项目。幸运的是,CShItem 类基于 Shell 文件夹和 PIDL,并且包含一个名为 cPidl 的方便类,用于将 PIDL 表示为 Byte()。
显示拖放操作的结果
鉴于 DragEnter 和 DragOver 所做的工作,在 DragDrop 中执行实际的放置操作非常简单。
Public Function DragDrop(ByVal pDataObj As IntPtr, _
ByVal pt As POINT, _
ByRef pdwEffect As Integer) As Integer _
Implements IDropTarget.DragDrop
Dim res As Integer
If Not IsNothing(m_LastTarget) Then
res = m_LastTarget.DragDrop(pDataObj, _
grfKeyState, pt, pdwEffect)
If res <> 0 Then
Debug.WriteLine("Error in dropping on " +
"DropTarget. res = " & Hex(res))
Else 'No error on drop
' it is quite possible that the
' actual Drop has not completed.
' in fact it could be Canceled
' with nothing happening.
' All we are going to do is hope for the best
' The documented norm for Optimized
' Moves is pdwEffect=None, so leave it
RaiseEvent ShDragDrop(m_DropList, _
m_LastNode, grfKeyState, pdwEffect)
End If
End If
ResetPrevTarget()
'get rid of cnt added in DragEnter
Dim cnt As Integer = Marshal.Release(m_DragDataObj)
m_DragDataObj = IntPtr.Zero
Return 0
End Function
我们所做的实际上是将调用传递给接收放置的文件夹的 IDropTarget 接口。该文件夹会处理所有细节,例如处理文件覆盖和各种用户交互。
然而,我们的工作尚未完成,而且可能无法完成。线索就在代码中的注释里。如果拖动操作是复制或“在此处创建快捷方式”,则操作在文件夹的 IDropTarget.DragDrop 例程返回时通常已完成。在这种情况下,我们可以合理地期望更新控件以反映新的实际情况。但是,如果拖动操作是*移动*,Shell 文件夹几乎总会执行*优化移动*。这意味着移动(复制然后删除原始文件,或逻辑等价操作)将在不同的线程中启动,并且独立于控件运行的线程。在极端但常见的情况下,移动可能会被用户取消,即使在 IDropTarget.DragDrop 例程返回很久之后。例如:用户启动了一个拖动移动然后去吃午饭。回来后,用户注意到一条消息说移动操作将覆盖现有目录。用户意识到他拖动到了错误的位置并取消了移动。与此同时,IDropTarget.DragDrop 例程已经返回,并且控件中处理拖动的代码早已完成。
此问题并非 ExpTree 所独有。IDataObject 支持许多剪贴板格式,其中许多最初是为了解决此问题而设计的。其中最好的格式是 CFSTR_LOGICALPERFORMEDDROPEFFECT(在 .NET 中称为“Logical Performed Drop Effect”)。该格式可以可靠地指示移动是用户的最终选择,但也会在用户有机会取消某些移动操作*之前*返回。
鉴于拖放操作实际做了什么的-不确定性,ExpTree*假设*拖放操作已完成,并调用 RefreshNode(它会调用 CShITem.RefreshDirectories)来更新放置目标,以及(如果涉及目录)放置源。如果 TreeView.SelectedNode 是放置目标或拖动源,它还会伪造一个 AfterSelect 事件,以便其他控件(侦听 ExpTreeNodeSelected 事件)有机会刷新其 GUI。.NET 的所有这些结果(双关语)是,在*移动*之后,控件及其 ExpTreeNodeSelected 事件的订阅者可能无法反映底层数据的实际状态。由于所有复制和“在此处创建快捷方式”的操作,以及至少一些*移动*操作,都可能及时完成以便进行此更新,因此 GUI 在大多数情况下(但并非所有情况)都是正确的。
改进空间
注意!在非常特殊的情况下(在 IDE 中运行(调试模式)、在 XP 上,移动大文件时)树控件会挂起。此挂起已修复,但根本原因尚未解决。人们可能做的几乎任何诊断该问题的方法都会导致其不发生。在 IDE 之外从未观察到此问题。如果问题发生,我已包含一个 Debug.WriteLine 来捕获信息。如果您遇到此问题,请将该信息发送给我。
对于*优化移动*所描述的行为,对于 .NET IDataObjects,它实际上已得到尽可能好的处理。.NET DataObject 似乎在后台与“Logical Performed Drop Effect”进行交互,并在其能力范围内尽可能提供正确的信息。如果拖动来自 Win Explorer,我的类可以与 CFSTR_LOGICALPERFORMEDDROPEFFECT 格式交互,以模拟 .NET DataObject 的行为,从而提供更好的更新。由于我预计大多数拖动将实际源自控件所在的应用程序,因此我目前选择不这样做。
我认为唯一能够让非文件系统项通过拖动实际复制或移动(“在此处创建快捷方式”实际上有效)的方法是,从包含拖动项的文件夹中获取一个完全工作的 IDataObject。我已尝试过此方法,但未成功。我将来可能会再次尝试,但需要更多知识,这些知识可以由本文的读者提供(暗示,暗示)。
致谢
我在网上花费了大量时间研究这项工作。我确信有些代码和不少想法来自于那些名字和网站我没有足够详细地记录下来,以至于无法在致谢中找到的人。然而,主要的帮助者(排名不分先后)是:
- Dave Anderson,他提供了一个 C# 版本的 MakeShellIDArray。
- Cory Smith,他的 TreeView 颜色化技术和代码。
- James Brown 在 catch22,如果我早点找到他的教程,它们会更有帮助。
历史
- 2012/04/20 - 下载版本 2.12.1 更新。添加了 Windows Explorer 风格的文件/文件夹名称排序。xx11xx 现在在 xx101xx 之前排序。与本文第一部分相同的下载。
- 2012/04/19 - 版本 2.12 更新。这是与本系列文章第一部分相同的下载包。此处更新是为了确保两篇文章包含相同的下载。有关与先前版本相比的更改的描述,请参见该文章。两个版本之间的更改均不影响本文所述的拖放功能。
- 2006/03/11 - 更新以包含下载中的 VS2005 版本。各种小修复。从 CShItem.GetContents 中移除了 Application.DoEvents。有关 VS2002/VS2005 的信息,请参见Readme文件。
- 2005/09/16 - 本文的原始发布,包括 ExpTreeLib 的 2.1 版本。