
Introduction
This is a quick article to show how to create a hybrid Windows.Forms.ComboBox
to solve the problem where an item's text width exceeds the width of the drop-down area.
Background
A couple of days ago, a client of mine asked me how to accomplish this task. I was initially surprised to discover that the control did not natively expose this functionality, and then more surprised at being unable to locate a simple online example.
Using the code
Simply add the code to your project, and use the ComboBoxToolTip
control in place of any drop-down Windows.Forms.ComboBox
.
To keep things brief, the example code does not support .DropDownStyle = ComboBoxStyle.Simple
or owner drawn combo items. It should be a straightforward exercise to extend the code to support it, but if anyone needs a little help on this, please shout.
You will need to import the System.Drawing
and System.Windows.Forms
namespaces.
Private Class ToolTipWindow
Inherits Control
Private Declare Function SetParent Lib "user32" _
(ByVal hWndChild As IntPtr, ByVal hWndNewParent As IntPtr) As Integer
Private Declare Function ShowWindow Lib "user32" _
(ByVal hWnd As IntPtr, ByVal nCmdShow As Integer) As Integer
Private Declare Function SendMessage Lib "user32" _
(ByVal hWnd As IntPtr, ByVal Msg As Integer, _
ByVal wParam As IntPtr, ByRef lParam As IntPtr) As IntPtr
Public Sub New()
MyBase.New()
Me.BackColor = SystemColors.Info
End Sub
Public Overloads Sub Show()
If (Me.Handle.Equals(IntPtr.Zero)) Then Me.CreateControl()
Dim h As IntPtr = Me.Handle
If Not Me.Visible Then SetParent(h, IntPtr.Zero)
ShowWindow(h, 1)
Me.Invalidate()
End Sub
Protected Overrides ReadOnly Property CreateParams() As _
System.Windows.Forms.CreateParams
Get
Const WS_EX_TOOLWINDOW As Integer = &H80
Const WS_EX_NOACTIVATE As Integer = &H8000000
Const WS_EX_TOPMOST As Integer = &H8
Dim p As CreateParams = MyBase.CreateParams
p.ExStyle = p.ExStyle Or (WS_EX_NOACTIVATE Or _
WS_EX_TOOLWINDOW Or WS_EX_TOPMOST)
p.Parent = IntPtr.Zero
Return p
End Get
End Property
Protected Overrides Sub OnPaint(ByVal e As PaintEventArgs)
MyBase.OnPaint(e)
If String.IsNullOrEmpty(Me.Text) Then Return
Using brush As Brush = New SolidBrush(Me.ForeColor)
e.Graphics.DrawString(Me.Text, Me.Font, brush, Me.ClientRectangle)
End Using
e.Graphics.DrawRectangle(SystemPens.ControlDarkDark, 0, 0, _
Me.ClientRectangle.Width - 1, Me.ClientRectangle.Height - 1)
End Sub
End Class
We have two classes in this project. The first one we will look at is the one that I used to replace the Windows.Forms.ToolTip
the framework provides us with.
It's all pretty selfexplanatory stuff, so I'll just quickly cover the basics. The class inherits from Windows.Forms.Control
. I did this because it was then a simple operation to describe an appropriate class of a window (CreateParams
) and paint it (OnPaint
) without calling out to native code. Advanced users can, of course, tweak the styles even more so it paints cleaner etc., but my aim here is to keep the code as simple to follow as possible.
The points of interest here are .....
- The parent window is set to
IntPtr.Zero
. This means our tooltip window can 'float' because the desktop owns it. Note that we have to do this in the Show
method (SetParent
) if the window has been hidden, otherwise the z-order falls below that of the active form.
- Our tooltip is owner-drawn, which means we can paint any style of tooltip we want.
The second class is our ComboBox control.
Public NotInheritable Class ComboBoxToolTip
Inherits ComboBox
<System.Runtime.InteropServices.StructLayout(_
Runtime.InteropServices.LayoutKind.Explicit)> _
Private Structure RECT
<System.Runtime.InteropServices.FieldOffset(0)> Friend Left As Integer
<System.Runtime.InteropServices.FieldOffset(4)> Friend Top As Integer
<System.Runtime.InteropServices.FieldOffset(8)> Friend Right As Integer
<System.Runtime.InteropServices.FieldOffset(12)> Friend Bottom As Integer
End Structure
Private Declare Function GetWindowRect Lib "user32" _
(ByVal hWnd As IntPtr, ByRef lpRect As RECT) As Integer
Private Declare Function GetScrollPos Lib "user32" _
(ByVal hWnd As IntPtr, ByVal nBar As Integer) As Integer
Private mToolTip As ToolTipWindow
Private Function PointOfInterest(ByVal pt As Drawing.Point, _
ByVal lbhWnd As IntPtr) As Boolean
Dim dropDownRect As RECT
If Not CType(GetWindowRect(lbhWnd, dropDownRect), Boolean) Then Return False
If dropDownRect.Left <= pt.X AndAlso dropDownRect.Right >= pt.X Then
If dropDownRect.Top <= pt.Y AndAlso dropDownRect.Bottom >= pt.Y Then
Return True
End If
End If
Me.mToolTip.Hide()
Return False
End Function
Private Function IndexFromPoint(ByVal Y As Integer, ByVal lbhWnd _
As IntPtr, ByRef offset As Integer) As Integer
offset = GetScrollPos(lbhWnd, 1)
Dim n As Integer = offset - 1
If Math.Sign(Y) = -1 Then
n += CType(Math.Ceiling((Me.DropDownHeight + Y) / Me.ItemHeight), Integer)
Else : n += CType(Math.Ceiling((Y - Me.ClientSize.Height) / _
Me.ItemHeight), Integer)
End If
If n >= 0 AndAlso n < Me.Items.Count Then Return n Else Return -1
End Function
Private Sub RefreshToolTip(ByVal lbhWnd As IntPtr)
If Me.mToolTip Is Nothing Then Me.mToolTip = New ToolTipWindow
Dim pt As Point = Control.MousePosition
If Not Me.PointOfInterest(pt, lbhWnd) Then Return
pt = Me.PointToClient(pt)
Dim offset As Integer = -1
Dim n As Integer = Me.IndexFromPoint(pt.Y, lbhWnd, offset)
If n = -1 Then Return
Dim s As String = Me.Items(n).ToString.Replace(Environment.NewLine, "X"c)
Using g As Graphics = Me.CreateGraphics
Dim size As Size = Me.ClientSize
If g.MeasureString(s, Me.Font).Width > size.Width Then
If Math.Sign(pt.Y) = 1 Then
pt = New Point(1, 1 + size.Height + (Me.ItemHeight * (n - offset)))
Else : pt = New Point(1, 1 - Me.DropDownHeight + _
(Me.ItemHeight * (n - offset)))
End If
pt = Me.PointToScreen(pt)
Me.mToolTip.Size = g.MeasureString(s, Me.mToolTip.Font).ToSize
Me.mToolTip.Width += 1
Me.mToolTip.Text = s
Me.mToolTip.Location = pt
Me.mToolTip.Show()
Else : mToolTip.Hide()
End If
End Using
End Sub
Protected Overrides Sub WndProc(ByRef m As System.Windows.Forms.Message)
Const WM_CTLCOLORLISTBOX As UInt32 = &H134
MyBase.WndProc(m)
If m.Msg = WM_CTLCOLORLISTBOX Then Me.RefreshToolTip(m.LParam)
End Sub
Protected Overrides Sub Dispose(ByVal disposing As Boolean)
If disposing AndAlso Me.mToolTip IsNot Nothing Then
Me.mToolTip.Dispose()
Me.mToolTip = Nothing
End If
MyBase.Dispose(disposing)
End Sub
Private Sub HideToolTip(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles Me.DropDownClosed
If Me.mToolTip IsNot Nothing Then Me.mToolTip.Hide()
End Sub
End Class
The hybrid essence of this class is to keep track of when the mouse pointer moves down the Y axis of the drop-down listbox control. As it does, the index of the item the mouse is over is determined, then the width of the item text is calculated. If the text width exceeds that of the drop down listbox, the tooltip is refreshed and shown. When the tooltip is no longer needed, it is simply hidden, rather than destroyed, and reused the next time.
Probably, the trickiest bit to follow is the method that calculates the index of the item under the mouse. I used the GetScrollPos
native method to consider scroll offsets, but this is only really supported on a 32 bit machine. 64 bit safe methods are available, but that unnecessarily complicated/extended the example code, hence my choice. I, then, do a little bit of manual math to calculate the final index.
I guess the only other interesting point is my choice of message ... WM_CTLCOLORLISTBOX
. This was a simple choice given that it is the message that fits most consistently with our model, and that the lParam
value of the message is actually the window handle for the drop down list box.
Possible improvements
There are no doubt many, but here are a few that spring to my mind ...
- I based the tooltip behaviour mainly on that of the treeview control. One thing that is missing is a timer that auto-hides the tooltip. I didn't put this in because I prefer it not to hide, but it would be nice for this to be an option for the developer.
- It might be a good idea to allow the developer the option to destroy the tooltip rather than simply hide it.
- Support for right to left locales.
Points of interest
The Windows.Forms.ComboBox
control is simply a wrapper for the native CComboBox
class. CComboBox
uses a CListBox
for the drop-down area. My initial approach was to subclass this window using the managed NativeWindow
class. Although this worked fine, I thought it would be a little more fun to try and keep things as much as possible in .NET managed code.
History
- Released to CodeProject May 2007.
- Hybrid version with owner-drawn super tooltips, article posted June 2007 here.