Implementing a dynamic submenu in VB.Net
On a Windows Form in .Net 3.5 I have created a menu object and populated it with ToolStripMenuItems. One of these items has a DropDown object attached. The DropDown should appear when the mouse hovers over the parent ToolStripMenuItem and disappear when the mouse leaves the ToolStripMenuItem unless it is "leaving" the parent by entering the parent's DropDown.
Also, I don't want the DropDown to automatically close when the user makes a selection in it, so I have set its "AutoClose" property to False.
Getting the DropDown to appear was easy. I just set up a handler for a "MouseEnter" event on the parent ToolStripMenuItem. But I'm stuck trying to make the DropDown disappear at the right time. If I set up a handler to close the it when the mouse leaves the parent ToolStripMenuItem, i开发者_开发百科t becomes impossible to use the DropDown, because moving the mouse into the DropDown means "leaving" the parent ToolStripMenuItem, and so the DropDown closes as soon as the user tries to hover over it!
I haven't been able to figure out how to detect if the mouse has really left the whole ToolStripMenuItem / DropDown assembly (in which case the DropDown should close) or has only "left" the ToolStripMenuItem by entering the DropDown (in which case the DropDown should not close).
This seems like a common design - a drop down that appears / disappears when the mouse hovers over / leaves the parent element - so how is it normally done? Grateful for any suggestions.
Still surprised that this is apparently not a problem that was solved a long time ago, but here's the solution I came up with:
Quick summary
The class below inherits from ToolStripMenuItem. Use it if you want the item to have a child DropDown menu that appears when the user's mouse hovers over it.
Terms I use below
ToolStripMenuItem: an item in a ToolStripDropDownMenu. It is both a member of a ToolStripDropDownMenu (the "parent menu"), and it also has access to another ToolStripDropDownMenu via its "DropDown" property (the "child menu").
Statement of the problem and soltion
The child ToolStripDropDownMenu that appears when you hover over the ToolStripMenuItem should normally close when the mouse leaves that ToolStripMenuItem and/or when it leaves the parent ToolStripDropDownMenu that contains it. However, it should not close if the mouse leaves the parent menu by entering the child menu at the same time. In that case, the "MouseEnter" event on the child menu should cancel the normal behavior of the "MouseLeave" event on the parent menu (i.e., the DropDown should not close).
The problem when you try to set this up in a normal, straightforward way is that the "MouseLeave" event on the parent menu fires before the "MouseEnter" event on the child menu, and the child menu closes before the mouse can enter it.
The solution below shunts the call to DropDown.Close() into a separate thread, where the "Close" action is delayed by a few seconds. In that short window, the "MouseEnter" event on the child DropDown (which is still on the main thread) has a chance to set a globally accessible dictionary value to True. After the delay, the value of this dictionary entry is checked in the separate thread, and the child menu is either closed (by calling the thread-safe "Invoke" method) or not. The program then goes on to check whether the parent menu also needs to be closed, whether that menu's parent menu needs to be closed, and so on. This code allows floating submenus to be nested as deep as any reasonable person would want.
There are separate handlers for "MouseEnter" and "MouseLeave" events for the individual menu item, its parent menu, and its child menu. They all check on each other to decide on the right course of action.
In conclusion
In posting this I wanted to provide an elegant working solution to this problem for which I had previously been unable to find much help. Still, iIf anyone has any tweaks for it, I'd love to hear them. Until then, please use this class if it helps you. When you instantiate it you need to send it a string for the text that will appear on it, a pointer to the main form, and a pointer to the parent ToolStripDropDownMenu to which you are adding it. After that, just use it as you would a normal ToolStripMenuItem. I also added a flag that can be set to True if you want the child DropDown menu items to behave like radio buttons (only one selectable at a time). -- Noel T. Taylor
Public Class ToolStripMenuItemHov
Inherits ToolStripMenuItem
' A shared dictionary that reflects whether the mouse is currently
' inside the area of a given ToolStripDropDownMenu.
Shared dictContainsMouse As Dictionary(Of ToolStripDropDownMenu, Boolean) = New Dictionary(Of ToolStripDropDownMenu, Boolean)
' A shared dictionary that maps a given ToolStripDropDown menu to
' the ToolStripDropDownMenu one level above it.
Shared dictParents As Dictionary(Of ToolStripDropDownMenu, ToolStripDropDownMenu) = New Dictionary(Of ToolStripDropDownMenu, ToolStripDropDownMenu)
' This thread can be started from multiple places in the code; it is
' shared so we can check if it's already running before starting it.
Shared t As Threading.Thread = Nothing
' We need to pass this in so we can use the form's "Invoke" method.
Private oMasterForm As Form
' This is the DropDownMenu that contains this ToolStripMenu *item*
Private oParentToolStripDropDownMenu As ToolStripDropDownMenu
' A boolean to track of whether the mouse is currently inside this
' menu item, as distinct from whether it's inside this item's parent
' ToolStripDropDownMenu (for which we use "dictParents" above).
Private fContainsMouse As Boolean
' If true, only one option in the DropDown can be selected at a time.
Private p_fWorkLikeRadioButtons As Boolean
' We only need this because VB doesn't support anonymous subroutines
' (only functions). Silly really.
Private Delegate Sub subDelegate()
Public Sub New(ByVal text As String, ByRef form As Form, ByVal parentToolStripDropDownMenu As ToolStripDropDownMenu)
Me.Text = text
Me.oMasterForm = form
Me.oParentToolStripDropDownMenu = parentToolStripDropDownMenu
Me.fContainsMouse = False
Me.p_fWorkLikeRadioButtons = False
Me.DropDown.AutoClose = False
dictParents(Me.DropDown) = parentToolStripDropDownMenu
dictContainsMouse(parentToolStripDropDownMenu) = False
dictContainsMouse(Me.DropDown) = False
' Set the parent's "AutoClose" property to false for correct behavior.
Me.oParentToolStripDropDownMenu.AutoClose = False
' We need to know if the mouse enters or leaves this single menu item,
' this menu item's child DropDown, or this menu item's parent DropDown.
AddHandler (Me.MouseEnter), AddressOf MyMouseEnter
AddHandler (Me.MouseLeave), AddressOf MyMouseLeave
AddHandler (Me.DropDown.MouseEnter), AddressOf childDropDown_MouseEnter
AddHandler (Me.DropDown.MouseLeave), AddressOf childDropDown_MouseLeave
AddHandler (Me.oParentToolStripDropDownMenu.MouseEnter), AddressOf parentDropDown_MouseEnter
AddHandler (Me.oParentToolStripDropDownMenu.MouseLeave), AddressOf parentDropDown_MouseLeave
End Sub
Public ReadOnly Property checkedItem() As ToolStripMenuItem
' This is only useful if "p_fWorkLikeRadioButtons" is true
Get
Dim returnItem As ToolStripMenuItem = Nothing
For Each item As ToolStripMenuItem In Me.DropDown.Items
If item.Checked Then
returnItem = item
Exit For
End If
Next
Return returnItem
End Get
End Property
Public Property workLikeRadioButtons() As Boolean
Get
Return Me.p_fWorkLikeRadioButtons
End Get
Set(ByVal value As Boolean)
Me.p_fWorkLikeRadioButtons = value
End Set
End Property
Private Sub myDropDownItemClicked(ByVal source As ToolStripMenuItem, ByVal e As System.EventArgs) Handles Me.DropDownItemClicked
If Me.workLikeRadioButtons = True Then
For Each item As ToolStripMenuItem In Me.DropDown.Items
If item Is source Then
item.Checked = True
Else
item.Checked = False
End If
Next
End If
End Sub
Private Sub MyMouseEnter()
Me.fContainsMouse = True
If Me.DropDown.Items.Count > 0 Then
' Setting "DropDown.Left" causes the DropDown to always appear
' in the correct place. Without this, it can appear too far to
' the left or right depending on where the user clicks on the
' trigger link. Interestingly, it doesn't matter what value you
' set it to, as long as you set it to something, so I naturally
' chose 74384338.
Me.DropDown.Left = 74384338
Me.DropDown.Show()
End If
End Sub
Private Sub MyMouseLeave()
Me.fContainsMouse = False
If t Is Nothing Then
t = New Threading.Thread(AddressOf maybeCloseDropDown)
t.Start()
End If
End Sub
Private Sub childDropDown_MouseEnter()
dictContainsMouse(Me.DropDown) = True
End Sub
Private Sub childDropDown_MouseLeave()
dictContainsMouse(Me.DropDown) = False
If t Is Nothing Then
t = New Threading.Thread(AddressOf maybeCloseDropDown)
t.Start()
End If
End Sub
Private Sub parentDropDown_MouseEnter()
dictContainsMouse(Me.oParentToolStripDropDownMenu) = True
End Sub
Private Sub parentDropDown_MouseLeave()
dictContainsMouse(Me.oParentToolStripDropDownMenu) = False
If t Is Nothing Then
t = New Threading.Thread(AddressOf maybeCloseDropDown)
t.Start()
End If
End Sub
' Wait an instant and then check if the mouse is either in this
' menu item or in this menu item's child DropDown. If it's not
' in either close the child DropDown and maybe close the parent
' DropDown (i.e., the DropDown that contains this menu item).
Private Sub maybeCloseDropDown()
Threading.Thread.Sleep(100)
If Me.fContainsMouse = False And dictContainsMouse(Me.DropDown) = False Then
Me.oMasterForm.Invoke(New subDelegate(AddressOf Me.DropDown.Close))
maybeCloseParentDropDown(Me.oParentToolStripDropDownMenu)
End If
t = Nothing
End Sub
' Recursively close parent DropDowns as long as mouse is not inside.
Private Sub maybeCloseParentDropDown(ByRef parentDropDown As ToolStripDropDown)
If dictContainsMouse(parentDropDown) = False Then
Me.oMasterForm.Invoke(New subDelegate(AddressOf parentDropDown.Close))
If dictParents.Keys.Contains(parentDropDown) Then
maybeCloseParentDropDown(dictParents(parentDropDown))
End If
End If
t = Nothing
End Sub
End Class
精彩评论