没有可用的背景,无法粘贴根节点
我有一个Winforms应用,里面有一个treeview,它由多个根节点组成。只有一个根节点的数据看起来不会太好看。
我需要让用户通过鼠标点击来向treeview添加新节点。如果用户点击树中的某个节点,新节点会作为被点击节点的子节点添加进来。如果用户点击树中最后一个节点下方的空白区域,就会在树中添加一个新的根节点。我使用 MouseUp 事件来判断用户到底是点击了一个节点,还是点击了最后一个节点下方的空白区域。
TreeNode clickedNode = treeView1.GetNodeAt(e.X, e.Y);
if (clickedNode != null)
{
// User clicked node
clickedNode.Nodes.Add("New ChildNode");
}
else
{
// User clicked background
treeView1.Nodes.Insert(0, "New RootNode");
}
这段实现工作正常,直到树视图下方没有更多的空白区域可供点击(并且出现滚动条)。原因在于树视图中的一个节点占据整行的水平区域,而不仅仅是文本标签,这意味着控件中不再存在可点击来创建根节点的空白区域:
如果使用拖放来移动节点,同样的问题也会出现。一旦新添加的节点到达树的底部(出现滚动条),我就无法再把树中的子节点拖动为根节点。
如果底部有一条空白行就足够了(如果有这种可能的话)。
为让事情更清楚一点。
上面的图片是我创建的一个简单测试应用,用来演示如何绕过树视图的默认行为,在空白区域创建一个根节点,而不是在选定节点下添加一个子节点。
我的实际应用中,树里有很多节点,但只有两种类型:文件夹或文档。用户可以右键点击,出现上下文菜单,选项包括新增文档、新增根文件夹或新增子文件夹。我不喜欢这样,我更愿意只有“新增文档”或“新增文件夹”,不需要区分根文件夹和子文件夹,这样会带来混淆。因此我把上下文菜单改为仅包含新增文档和新增文件夹,并使用上面的代码(显然是修改过的)来根据是否点击了最后一个节点下方的空间来决定创建根目录还是子文件夹。这在树视图完全填充时效果很好,但此时我就无法再点击最后一个节点下方的空白区域来创建根节点。
我还碰到一个问题:上下文菜单允许用户剪切/复制并粘贴节点。这在树视图未完全填充时也工作良好,但当树视图完全填充时,我就无法在空白区域粘贴被剪切/复制的节点来创建根节点。
同样,拖放也一样,我可以把树视图中的节点拖到空白区域来创建根节点,但在树完全填充时就不能。
解决方案
调用 TreeView.HitTest 方法来确定被点击的是 TreeNode 的哪一区域(标签、图像、± 等等)还是 TreeView(客户端区域的位置)。该方法返回一个 TreeViewHitTestInfo 类,通过 Location 属性包含关于被点击区域的信息,该属性返回一个一个或按位组合的 TreeViewHitTestLocations 值。
假设你有一个 ContextMenuStrip,并且需要一个菜单项来根据哪个区域被右键单击来显示菜单,以添加根文件夹或子文件夹。也假设在本答复中如此,文档节点只能插入到父文件夹中,不能包含子节点。
要实现这一点,你需要在上下文菜单打开之前设置菜单项的 状态。根据被点击节点的 类型(若有)以及被点击区域的位置,启用/禁用或显示/隐藏相应项。我将使用 TreeNode.Tag 属性来保留类型,并实现菜单的 Opening 事件来设置状态。
using System.ComponentModel;
public partial class FrmTreeView : Form
{
private enum NodeType
{
None,
Folder,
Document
}
private readonly ImageList img16;
private static int folderCount = 0;
private static int docCount = 0;
public FrmTreeView()
{
InitializeComponent();
img16 = new(components!);
img16.Images.Add("Folder", Resources.Folder);
img16.Images.Add("Document", Resources.NewDocument);
tv.ImageList = img16;
}
private void tv_MouseDown(object sender, MouseEventArgs e)
{
// Let the Opening event of the ContextMenuStrip
// determine the selection...
tv.SelectedNode = null;
}
private void cmnuTree_Opening(object sender, CancelEventArgs e)
{
// Enable the relevant menu items and disable the rest...
if (cmnuTree.SourceControl is TreeView tv)
{
Point p = tv.PointToClient(MousePosition);
TreeViewHitTestInfo ht = tv.HitTest(p);
tv.SelectedNode = ht.Location != TreeViewHitTestLocations.Label
? null : ht.Node;
NodeType t = GetNodeType(tv.SelectedNode!);
cmnuTreeAddFolder.Enabled = t != NodeType.Document;
// If you want...
cmnuTreeAddFolder.Text = t == NodeType.None
? "Add Root"
: "Add Folder";
cmnuTreeAddDoc.Enabled = t == NodeType.Folder;
}
}
private void cmnuTreeAddFolder_Click(object sender, EventArgs e)
{
AddFolder(tv.SelectedNode);
}
private void cmnuTreeAddDoc_Click(object sender, EventArgs e)
{
AddDocument(tv.SelectedNode);
}
private static NodeType GetNodeType(TreeNode node)
{
NodeType t = NodeType.None;
if (node != null)
_ = Enum.TryParse(node.Tag?.ToString(), out t);
return t;
}
private void AddFolder(TreeNode node)
{
if (GetNodeType(node) == NodeType.Document)
throw new InvalidOperationException();
TreeNodeCollection nodes = node?.Nodes ?? tv.Nodes;
int imgIndex = img16.Images.IndexOfKey("Folder");
TreeNode newNode = new($"New Folder {++folderCount:00}", imgIndex, imgIndex)
{
Tag = NodeType.Folder,
ForeColor = tv.ForeColor,
BackColor = tv.BackColor
};
nodes.Add(newNode);
tv.SelectedNode = newNode;
newNode.EnsureVisible();
}
private void AddDocument(TreeNode node)
{
if (GetNodeType(node) != NodeType.Folder)
throw new InvalidOperationException();
TreeNodeCollection nodes = node?.Nodes ?? tv.Nodes;
int imgIndex = img16.Images.IndexOfKey("Document");
TreeNode newNode = new($"New Document {++docCount:00}", imgIndex, imgIndex)
{
Tag = NodeType.Document,
ForeColor = tv.ForeColor,
BackColor = tv.BackColor,
};
nodes.Add(newNode);
tv.SelectedNode = newNode;
newNode.EnsureVisible();
}
}
至于拖放部分,可以应用同样的逻辑,基于被拖动节点的类型以及放置的位置来允许或阻止放下。通过P/Invoke发送 WM_VSCROLL 消息,在拖动节点靠近客户端区域的上下边缘时强制控件进行垂直滚动。
以下示例仅涵盖拖放部分,以便简短。可以将其与前面的代码结合,或分离到一个部分类中。
using System.Reflection;
using System.Runtime.InteropServices;
public partial class FrmTreeView : Form
{
private TreeNode? overNode = null;
public FrmTreeView()
{
InitializeComponent();
tv.AllowDrop = true;
// To reduce flickering while dragging nodes...
tv.GetType().GetProperty("DoubleBuffered",
BindingFlags.Instance | BindingFlags.NonPublic)
?.SetValue(tv, true);
}
private void tv_ItemDrag(object sender, ItemDragEventArgs e)
{
if (e.Button == MouseButtons.Left &&
e.Item is TreeNode)
DoDragDrop(e.Item, DragDropEffects.Move);
}
private void tv_DragOver(object sender, DragEventArgs e)
{
// Reset the colors of the last drag-over node, if any...
if (overNode != null)
{
overNode.BackColor = tv.BackColor;
overNode.ForeColor = tv.ForeColor;
}
Point pt = tv.PointToClient(new Point(e.X, e.Y));
DragDropEffects effect = DragDropEffects.None;
if (e.Data?.GetData(typeof(TreeNode)) is TreeNode dragNode)
{
TreeViewHitTestInfo ht = tv.HitTest(pt);
overNode = ht.Node!;
if (dragNode != null && dragNode != overNode &&
!GetParentNodes(overNode).Any(n => n == dragNode))
{
NodeType dragNodeType = GetNodeType(dragNode);
NodeType selNodeType = GetNodeType(overNode);
if (dragNodeType == NodeType.Folder)
{
// Allow moving a folder to another folder OR
// making it a root by dropping the node in a
// client area.
if (selNodeType != NodeType.Document)
{
effect = DragDropEffects.Move;
}
}
else if (dragNodeType == NodeType.Document)
{
// Allow moving a document to another folder
// OR changing its node index (order) in the
// same folder...
if (selNodeType != NodeType.None)
{
effect = DragDropEffects.Move;
}
}
}
}
else
{
// Invalid drag-and-drop operation...
effect = DragDropEffects.None;
}
// Optional: highlight the current drag-over node...
if (effect == DragDropEffects.Move && overNode != null)
{
overNode.BackColor = Color.LightSteelBlue;
}
e.Effect = effect;
// Scroll vertically if needed...
ScrollDragOver(pt);
}
private void tv_DragDrop(object sender, DragEventArgs e)
{
if (e.AllowedEffect == DragDropEffects.Move &&
e.Data?.GetData(typeof(TreeNode)) is TreeNode dragNode)
{
Point pt = tv.PointToClient(new Point(e.X, e.Y));
TreeViewHitTestInfo ht = tv.HitTest(pt);
NodeType dragType = GetNodeType(dragNode);
NodeType dropType = GetNodeType(ht?.Node!);
if (ht?.Node != null)
{
dragNode.Remove();
// Folders or documents...
if (dragType == dropType)
{
// Determine the target node collection...
TreeNodeCollection nodes = dropType == NodeType.Folder
? ht.Node.Nodes
: ht.Node.Parent == null
? tv.Nodes
: ht.Node.Parent.Nodes;
if (dropType == NodeType.Folder)
nodes.Add(dragNode);
else
nodes.Insert(ht.Node.Index, dragNode);
}
else if (dropType == NodeType.Folder)
{
// Inserts the folder/document at the
// end of the collection...
ht.Node.Nodes.Add(dragNode);
}
}
else
{
// Top-level the folder (root)...
if (dragType == NodeType.Folder)
{
dragNode.Remove();
tv.Nodes.Add(dragNode);
}
}
tv.SelectedNode = dragNode;
dragNode.EnsureVisible();
}
// Reset the colors of the last drag-over node, if any...
if (overNode != null)
{
overNode.BackColor = tv.BackColor;
overNode = null;
}
}
// The TreeView.DragOver event calls this method to disallow
// moving a top-level node to one of its children of any depth...
private static IEnumerable<TreeNode> GetParentNodes(TreeNode node)
{
if (node is null) yield break;
var p = node.Parent;
while (p is not null)
{
yield return p;
p = p.Parent;
}
}
// Scroll while dragging over if needed...
private void ScrollDragOver(Point pt)
{
int wParam = -1;
if (pt.Y + tv.ItemHeight > tv.Height)
wParam = 1;
else if (pt.Y < tv.ItemHeight)
wParam = 0;
if (wParam >= 0)
SendMessage(tv.Handle, WM_VSCROLL, wParam, 0);
}
// P/Invoke...
const int WM_VSCROLL = 277;
[DllImport("user32.dll", CharSet = CharSet.Auto)]
static extern nint SendMessage(nint hWnd, int msg, int wParam, int lParam);
}
