Introduction
Friday, August 30, 2002
In this topic, I'll show you how to create menues with icons that look like the ones in the new Visual Studio IDE. This continues the discussion in the previous topic (Actions): the menues created here will be based on (not technically) the CActionMenu class. Also, both NActions and CAction will be extended (again, not technically) to incorporate icons.
On the download page, get the UICore project, which contains everything discussed in this and the previous topics.
Preparation
Class NActions
This class gets new constructors that allow to associate an image list. Also, when CAction objects are added, we'll make sure they know about this collection so they can talk to the image list:
Private m_ImageList As WinForms.ImageList Public Overloads Sub New(ByVal il As WinForms.ImageList) MyBase.New m_ImageList = il End Sub ' ... Public Overloads Sub Add(ByVal action As CAction) If MyBase.BaseGet(action.Caption) Is Nothing Then MyBase.BaseAdd(action.Caption, action) action.SetActionList(Me) Else Throw New ArgumentException End If End Sub ' ... Public Property ImageList As WinForms.ImageList Get Return m_ImageList End Get Set m_ImageList = value End Set End Property
Class CAction
This class will get a reference to the NActions collection. The constructors will get a new buddy that takes an image index (note that all other constructors should initialize the image index to negative one).
Private m_ActionList As NActions Private m_Image As Integer ' ... Public Overloads Sub New(ByVal sText As String, ByVal sHint As String, ByVal execHandler As DExecHandler, ByVal hintHandler As DHintHandler, ByVal iImage As Integer) m_Text = sText m_Hint = sHint m_ExecHandler = execHandler m_HintHandler = HintHandler If iImage < -1 Then Throw New ArgumentException m_Image = iImage End Sub ' ... Public ReadOnly Property ActionList As NActions Get Return m_ActionList End Get End Property Friend Sub SetActionList(ByVal al As NActions) m_ActionList = al End Sub
CActionMenu class
Since CActionMenu is derived from MenuItem, we can override a couple of event-raiser subs that are fired when each menu item is drawn, provided the OwnerDraw property is set to True. We'll tell Windows that we want to draw our own menu items in the constructor. Note that top-level menues (like "File", or "Edit") do not get owner-drawn, so it's up to the user of this class to pass a flag:
Public Overloads Sub New(ByVal action As CAction, ByVal fOwnerDraw As Boolean) MyBase.New MyBase.Text = action.Caption MyBase.Enabled = action.Enabled m_Action = action m_Action.AddStatusHandler(AddressOf Me.HandleStatus) Me.OwnerDraw = fOwnerDraw End Sub ' ...
When the user presses a top-level menu, the system will first ask for the size of each owner-drawn menu item. We'll measure the string used for the caption, and add a few pixels for the icon:
Protected Overrides Sub OnMeasureItem(ByVal e As System.WinForms.MeasureItemEventArgs) If Me.Action.Caption = "-" Then e.ItemHeight = 5 Else e.ItemHeight = 20 End If Dim fs As FontStyle If Me.DefaultItem = True Then fs = fs BitOr FontStyle.Bold Dim fnt As New Font("Tahoma", 8, fs) Dim sf As SizeF = e.Graphics.MeasureString(Me.Action.Caption, fnt) fnt.Dispose e.ItemWidth = CInt(sf.Width) + 20 ' leave room for icon End Sub
After the system knows the size, our base class will fire the following protected sub, in which the actual drawing is done. We get a reference to a Graphics object that has all of the methods we need, and there's a rectangle member (Bounds property) in the event argument instance, too. We check for the state of the menu item (default item? enabled? selected?) to carry out our paint job accordingly.
Mihai Cvasnievschi suggested a method for the "hover" effect that on menu icons (new in VS7), as well as better color combinations and some other corrections (note that the screen shot at the bottom is still my old mess). Thanks a lot, Mihai!
Protected Overrides Sub OnDrawItem(ByVal e As System.WinForms.DrawItemEventArgs) ' colors, fonts Dim clrBgIcon, clrBgText, clrText As Color, fs As FontStyle, fnt As Font Dim b As SolidBrush, p As Pen Dim fEnabled As Boolean = Not CType(e.State BitAnd DrawItemState.Disabled, Boolean) Dim fSelected As Boolean = CType(e.State BitAnd DrawItemState.Selected, Boolean) Dim fDefault As Boolean = CType(e.State BitAnd DrawItemState.Default, Boolean) Dim fBreak As Boolean = (Me.Action.Caption = "-") If fEnabled And fSelected And Not fBreak Then clrBgIcon = Color.FromARGB(182, 189, 210) clrBgText = Color.FromARGB(182, 189, 210) clrText = Color.Black fs = fs BitOr FontStyle.Regular Else clrBgIcon = Color.FromARGB(219, 216, 209) clrBgText = Color.FromARGB(249, 248, 247) clrText = Color.Black End If If Not fEnabled Then clrText = Color.Gray End If If fDefault Then fs = fs BitOr FontStyle.Bold End If fnt = New Font("Tahoma", 8, fs) ' total background (partly to remain for icon) b = New SolidBrush(clrBgIcon) e.Graphics.FillRegion(b, New [Region](e.Bounds)) b.Dispose ' icon? If Not Me.Action.ActionList Is Nothing Then Dim il As ImageList = Me.Action.ActionList.ImageList If Not il Is Nothing Then Dim index As Integer = Me.Action.Image If index > -1 And index < il.Images.Count Then Dim rect As Rectangle = e.Bounds With rect .X += 4 .Y += 2 .Width = 16 .Height = 16 End With If fEnabled = False Or fEnabled And fSelected Then Dim cp As New ControlPaint cp.DrawImageDisabled(e.Graphics, il.Images.Item(index), rect.X, rect.Y, clrBgIcon) End If If fSelected Then rect.X -= 1 rect.Y -= 1 End If If fEnabled Then e.Graphics.DrawImage(il.Images.Item(index), rect) End If End If End If End If ' text background Dim rf As RectangleF With rf .X = 24 .Y = e.Bounds.Y .Width = e.Bounds.Width - .X .Height = e.Bounds.Height End With b = New SolidBrush(clrBgText) e.Graphics.FillRegion(b, New [Region](rf)) b.Dispose ' text/line rf.Y += 3 : rf.Height -= 3 If Not fBreak Then b = New SolidBrush(clrText) Dim sf As New StringFormat sf.HotkeyPrefix = Drawing.Text.HotkeyPrefix.Show e.Graphics.DrawString(Me.Action.Caption, fnt, b, rf, sf) fnt.Dispose b.Dispose Else p = New Pen(Color.Black) rf.Y -= 1 e.Graphics.DrawLine(p, rf.X, rf.Y, rf.Right, rf.Y) p.Dispose End If ' border If fEnabled And fSelected And Not fBreak Then p = New Pen(Color.FromARGB(10, 36, 106)) e.Graphics.DrawRectangle(p, e.Bounds) p.Dispose End If End Sub
Note that we access the image index saved in the CAction object, and we get a reference to the image list the same way. Remember to dispose of fonts, pens, and brushes to save system ressources. Also, this code isn't so generic: you could create a CMenu class (deriving from MainMenu) and add properties for fonts and colors to it (and access the instance via the Parent property of this class's base).
Client-side code
Here's what the client needs to do:
' create image list Dim ilStandard As New ImageList ilStandard.TransparentColor = Color.Silver With ilStandard.Images Dim sFolder As String = "C:\Pictures\" .Add(New Bitmap(sFolder & "Open.bmp")) .Add(New Bitmap(sFolder & "Save.bmp")) End With ' action list m_Actions = New NActions(ilStandard) ' add actions (note the image index) m_Actions.Add("File", "Contains commands for working with files", Nothing, AddressOf Me.HandleHint) m_Actions.Add("Open ...", "Opens a file", AddressOf Me.HandleAction, AddressOf Me.HandleHint, 0) m_Actions.Add("Save", "Saves the file", AddressOf Me.HandleAction, AddressOf Me.HandleHint, 1) 'the menu Me.Menu = New MainMenu With Me.Menu.MenuItems .Add(New CActionMenu(m_Actions("File"))) With .Item(.Count - 1).MenuItems .Add(New CActionMenu(m_Actions("Open ..."), True)) .Add(New CActionMenu(m_Actions("Save"), True)) End With End With
What it should look like
This is a screenshot from an example program (a little more complex - it does not
correspond to the above client-side code). Note that our CActionMenu class can be
used for context menues as well as toolbar button menus, too. The "Save" item is
disabled. And, of course, you can adjust colors to your heart's content. The code
to draw the file icons displayed in the "Recent" menu is not available in the
accompanying download.