Drawing Your Own Controls Using GDI+
Microsoft Developer Network
Summary: Details development of a data-bound, owner-drawn control using GDI+ as one of series of Microsoft Windows control development samples to be read in conjunction with an associated overview article. (15 printed pages)
This article is the fifth in a five-article series on developing controls in Microsoft® .NET:
- Developing Custom Windows Controls Using Visual Basic .NET (overview)
- Adding Regular Expression Validation
- Combining Multiple Controls into One
- Extending the TreeView Control
- Drawing Your Own Controls Using GDI+
Supporting Complex Data Binding
Drawing the Images
Handling Key Presses and Mouse Clicks
Before you begin, let me give you a small warning: building your own control from scratch and doing your own drawing should only be done when you have exhausted all your other options. It isn't extremely difficult from a technical standpoint (although it isn't simple), but there is such an enormous number of details to be handled that you are likely to produce a control that is missing at least some of the design and usability features of the controls that ship with Microsoft® Visual Studio® .NET. By starting out with one of these provided controls, inheriting from it, and building your additional functionality on top of that base class, you can get almost all of the control functionality for free. Now that I have warned you, I would like to also say that I find it a lot of fun to build my own controls, especially when I get to draw my own interface using GDI+, and I hope you will give it a try.
There is really no specific type of control that is best built from scratch, except that it is typically a control unusual enough that there is no way you could build on an existing item. The sample I am going to build is a data-bound thumbnail view, and it is definitely different enough from any other Microsoft Windows® Form control that I need to start from scratch.
Note When you encounter a requirement for a control like this, something that isn't handled by one of the Windows Forms controls that ship with Microsoft .NET and doesn't appear to be something you could easily accomplish, then there is one more step you should follow before deciding to build it yourself: Check out the third-party control vendors! In the overview article on control development, I mentioned that the success of Microsoft Visual Basic® was due in part to the large number of third-party controls available, and that will be true for Visual Basic .NET as well.
I originally built this control to meet the following set of requirements:
- Display a multi-column view of images that supports multiple pages, and allows an individual item to receive focus.
- The item text, image (image URL), and a corresponding value must all be data-bound properties.
- The user of the control should be able to specify a size for the images, and the control should handle resizing and positioning of the images automatically.
And the result allowed me to construct a visual browser for my home CD library (see Figure 1) with minimal effort.
Figure 1. Possible use for a data-bound thumbnail view
I had to build my own interface for this control using GDI+ because of the origins of the closest Windows Form control, the ListView. This control, and several others such as the TextBox, ComboBox, and ListBox, is actually a Windows Common Control that has just been wrapped with .NET code to allow its use within Windows Forms applications. These controls do not allow me to completely override their drawing routines, so I did not have enough customization possibilities for my requirements.
Supporting Complex Data Binding
Even though I didn't think that I could base my new control on an existing class, I still wanted to build my control in such a way that it would be as reusable as possible, so I decided to create an underlying base class that encapsulated the basic work for a complex data bound control. I went ahead and built that class and used it for my first version of this control. Building my control in this way worked really well, and I was able to reuse my base class when I built a GDI+-based list box and several other controls, resulting in a lot less code required to build each one. It turns out I could have saved even more time—though it occurred to me (after I was done, of course) that if this was such a good idea, then the Windows Forms programmers would have thought of it themselves, and sure enough I found that the ListBox and ComboBox controls were based on a common ListControl class that was almost exactly what I had created. My version works fine, but I have since rewritten my controls to use this class, and I will use it for this article as well.
By using the ListControl class, almost all the data-binding work is taken care of, which greatly reduces the code I will have to write. In my control, I'll add a new property to specify the field that holds the path to the image, but DataSource, DataMember, SelectedIndex, DisplayMember, and ValueMember properties are all provided for me.
I still need to work with the data inside my control (to loop through the items in my drawing routines, for instance) but the ListControl class makes this easy by providing me with an instance of the CurrencyManager class as Me.DataManager. Through this object, I have access to the list of items (Me.DataManager.List), the current position within the list (Me.DataManager.Position) and a PropertyDescriptorCollection class that allows me to access any field of a list item (Me.DataManager.GetItemProperties).
Drawing the Images
By overriding the OnPaint method of the base class, I can take over the drawing for this control, and it is in this code that I will need to draw out my page full of thumbnail images. Before I can actually do the drawing, though, I need to determine the position of each individual image (and the associated text), the color and font information to use, and how many rows and columns I should have visible at any one time.
Determining a Customizable Layout
To make this control useful, the positioning and sizing of the thumbnails needs to be configurable, so when determining my drawing routine it was very helpful to think in terms of variables (see Figure 2).
Figure 2. When drawing your own control, it helps to determine the layout before starting to code.
Each of these variables can be configured through a public property on the control:
- HorizontalSpacing (x)
- VerticalSpacing (y)
- ImageHeight (h)
- ImageWidth (w)
Additionally, the appearance of the control can be customized through the default properties ForeColor, BackColor, and Font, each of which is referenced appropriately by the graphics code. This isn't automatic, so you need to make sure that you are referencing these standard properties when you do your graphic work if you want your control to behave as expected.
Calculating the Number of Rows and Columns Per Page
Since there could be more images than will fit onto a single page, I have to keep track of which item is currently at the upper left of the control, and draw items relative to that particular list item. The determination of how many rows and columns will fit onto my canvas is accomplished in the control's resize event, since these values would need to be recalculated whenever the control grows or shrinks:
Private Sub imageList_Resize(ByVal sender As Object, _ ByVal e As System.EventArgs) Handles MyBase.Resize Dim new_rowsPerPage As Integer = (Me.Height - y) \ ((2 * y) + h) Dim new_colsPerPage As Integer = Me.Width \ ((2 * x) + w) If (new_rowsPerPage <> rowsPerPage) _ OrElse (new_colsPerPage <> colsPerPage) Then rowsPerPage = new_rowsPerPage colsPerPage = new_colsPerPage End If End Sub
To avoid both unnecessary work and flicker, you want the minimum amount of control redrawing, but in this case every resize will require a redraw. Sometimes you cannot avoid redrawing due to the nature of your UI design, as is the case with this control. I am drawing two arrows (one at the top and one at the bottom of the control) to indicate when there are more items available off screen. To force a redraw upon every resize, I could add a call to Me.Invalidate within this Resize routine, but Windows Forms provides another method through the use of control styles. By adding calls to the SetStyle method in the constructor (New method) of our control, we can control how it is drawn and refreshed by the Windows Forms engine. In this case, setting the ResizeRedraw style will force a refresh whenever the control is resized, but I will also set the DoubleBuffer style, as it is an excellent way to remove flicker from a custom drawn control:
Public Sub New() Me.SetStyle(ControlStyles.DoubleBuffer, True) Me.SetStyle(ControlStyles.ResizeRedraw, True) Me.SetStyle(ControlStyles.AllPaintingInWmPaint, True) Me.SetStyle(ControlStyles.UserPaint, True) End Sub
Note Double buffering is a graphic technique where a complete image of the user interface is drawn into a buffer (such as an Image object in memory) and then drawn out to the window as a single image. This greatly reduces flicker compared to executing all the individual graphic commands directly onto the window one at a time. According to the .NET Framework documentation for the ControlStyles options, I need to set UserPaint and AllPaintingInWmPaint to gain the full benefits of double buffering.
The actual graphic work is accomplished by overriding OnPaint, which is passed an argument object that includes a Graphics object that you can use to draw onto the canvas of the control. In my OnPaint routine, I loop through the items in my data source, keeping track of row and column positions as I go, and draw each item:
Protected Overrides Sub OnPaint(ByVal e As PaintEventArgs) Dim myList As IList Dim gr As Graphics = e.Graphics gr.FillRectangle(New SolidBrush(Me.BackColor), e.ClipRectangle) gr.InterpolationMode = scalingMode gr.SmoothingMode = SmoothingMode.Default ControlPaint.DrawBorder3D(gr, Me.DisplayRectangle, m_borderStyle) If Me.DataManager Is Nothing Then myList = Nothing Else myList = Me.DataManager.List End If If Not myList Is Nothing Then 'if there is any data Dim itemCount As Integer itemCount = myList.Count Dim itemsDisplayed As Integer 'current position in the list itemsDisplayed = currentTopLeftItem Dim i, j As Integer 'loop indexes Dim height, width As Integer For i = 0 To rowsPerPage - 1 For j = 0 To colsPerPage - 1 If itemsDisplayed < itemCount Then DrawOneItem(itemsDisplayed, i, j, gr) itemsDisplayed += 1 End If Next Next 'draw page down / page up indicators Dim webdingsFont As New Font("Webdings", 20, _ FontStyle.Regular, GraphicsUnit.Pixel) Dim textBrush As New SolidBrush(Me.ForeColor) If itemsDisplayed < itemCount - 1 Then 'draw down arrow gr.DrawString("6", _ webdingsFont, textBrush, 0, Me.Height - 24) End If If currentTopLeftItem > 0 Then 'draw up arrow gr.DrawString("5", webdingsFont, textBrush, 0, 0) End If End If End Sub
Most of the code within this paint routine involves determining the position (row and column) of each particular list item, and only a small amount of the code is actually handling the drawing. This routine is made a lot cleaner by breaking the code for drawing a single item out into its own procedure:
Private Sub DrawOneItem(ByVal index As Integer, _ ByVal row As Integer, _ ByVal col As Integer, _ ByVal gr As Graphics) Dim textFont As Font = Me.Font Dim textBrush As New SolidBrush(Me.ForeColor) Dim myStringFormat As StringFormat = New StringFormat() myStringFormat.Alignment = StringAlignment.Center myStringFormat.FormatFlags = StringFormatFlags.LineLimit Dim imageURL As String = GetListItemImage(index) If imageURL = "" Then imageURL = m_GenericImage If imageURL <> "" Then If IO.File.Exists(imageURL) Then Dim myNewImage As New Bitmap(imageURL) 'scale image to fit into defined size With myNewImage If .Height > h Then Height = h Width = CInt((h / .Height) * .Width) Else Height = .Height Width = .Width End If If Width > w Then Height = CInt((w / Width) * Height) Width = w End If End With Dim imageRect _ As New Rectangle((2 * x) + _ (col * ((2 * x) + w)) + ((w - Width) \ 2), _ (1 * y) + (row * ((2 * y) + h)) _ + ((h - Height) \ 2), _ Width, Height) gr.DrawImage(myNewImage, imageRect) Dim myNewPen As Pen If index = Me.DataManager.Position Then 'selected myNewPen = New Pen(Color.Yellow) myNewPen.Width = 4 Else myNewPen = New Pen(Color.Black) myNewPen.Width = 1 End If gr.DrawRectangle(myNewPen, imageRect) End If End If Dim textHeight As Integer = y * 2 gr.DrawString(Me.GetItemText(Me.DataManager.List.Item(index)), _ textFont, textBrush, _ New RectangleF((x) + (col * ((2 * x) + w)), _ 2 + (1 * y) + h + (row * ((2 * y) + h)), _ w + (2 * x), textHeight), myStringFormat) End Sub
With this routine removed from the main OnPaint code, it is easier to discuss the actual GDI+ work that is done. First, if the path for the image refers to a real file, a new Bitmap object is created using that path as a constructor, and then the image itself is drawn onto the control using the DrawImage method of the Graphics object. Before the actual image is drawn, a little bit of funky math is done to proportionately scale the image into its target space.
Note A Graphics object represents a drawing surface, so these same methods could be used in several different situations, including creating your own image files, such as bitmaps or jpegs.
The next step in drawing an item is to draw a rectangle around the image, using color and line thickness to indicate focus. The border is drawn after drawing the image so that it will be on top, and no part of it will be covered up by the image. Next, the text string is drawn under the image using the DrawString method. By using the overload of DrawString that accepts a layout rectangle, the text can automatically wrap as needed within the specified area. DrawString is also passed a StringFormat object, which allows you to configure the details of how the text is drawn, such as the use of word wrap. In this example, the StringFormat object is configured with the LineLimit flag, which prevents text from being drawn if it would be partially clipped, so only text that fits completely within the layout rectangle will appear.
Handling Key Presses and Mouse Clicks
I need to handle all the navigation within this control myself, since it is not based on any existing control like a ListView, so I have decided to support the following navigational behaviors:
- Arrow Keys to move amongst the images. Trying to move past the bottom or top of the control will have the same effect as a PageUp or PageDown.
- PageUp or PageDown to move by an entire screen of information each time.
- Select a single item with a mouse-click. No multiple select.
- Double-clicking an image or selecting an image while pressing Return or Enter will fire a special ItemPicked event.
- Attempting to navigate past the edges of the control (left or right at any time, up or down when there are no more items available in that direction) will fire another custom event, LeaveControl. This will allow programmers using the control on their forms to control navigation from this control to other controls on the same form.
Handling Key Presses
The code to support keyboard navigation is pretty straightforward; it just involves a bit of math to determine what row and column is currently selected, and it is all encapsulated into a routine called KeyPressed (which is called from the KeyDown event of the control):
Private Sub imageList_KeyDown(ByVal sender As Object, _ ByVal e As System.Windows.Forms.KeyEventArgs) _ Handles MyBase.KeyDown KeyPressed(e.KeyCode) End Sub Private Sub KeyPressed(ByVal Key As System.Windows.Forms.Keys) Try Dim m_oldTopItem As Integer = currentTopLeftItem Dim m_oldSelectedItem As Integer = Me.DataManager.Position Dim newPosition As Integer = m_oldSelectedItem Dim selectedRow As Integer Dim selectedColumn As Integer selectedRow = System.Math.Floor( _ (m_oldSelectedItem - currentTopLeftItem) / colsPerPage) selectedColumn = (m_oldSelectedItem - currentTopLeftItem) _ Mod colsPerPage If Not Me.DataManager.List Is Nothing Then Select Case Key Case Keys.Up If newPosition >= colsPerPage Then newPosition -= colsPerPage End If Case Keys.Down If Me.DataManager.Count - colsPerPage > _ newPosition Then newPosition += colsPerPage End If Case Keys.Left If selectedColumn = 0 Then RaiseEvent LeaveControl(Direction.Left) Else newPosition -= 1 End If Case Keys.Right If selectedColumn = (colsPerPage - 1) Then RaiseEvent LeaveControl(Direction.Right) Else newPosition += 1 End If Case Keys.PageDown If newPosition < Me.DataManager.Count Then newPosition += (rowsPerPage * colsPerPage) If newPosition >= Me.DataManager.Count Then newPosition = Me.DataManager.Count - 1 End If Else RaiseEvent LeaveControl(Direction.Down) End If Case Keys.PageUp If newPosition > 0 Then newPosition -= (rowsPerPage * colsPerPage) If newPosition < 0 Then newPosition = 0 End If Else RaiseEvent LeaveControl(Direction.Down) End If Case Keys.Enter, Keys.Return RaiseEvent ItemChosen(newPosition) End Select If newPosition < 0 Then newPosition = 0 If newPosition >= Me.DataManager.Count Then newPosition = Me.DataManager.Count - 1 End If If newPosition <> m_oldSelectedItem Then Me.DataManager.Position = newPosition End If End If Catch except As Exception Debug.WriteLine(except) End Try End Sub
Supporting the Mouse
Making the thumbnail view control work well with the mouse involved writing only two event handlers: a public event (ItemChosen, also called from KeyPressed when the user presses Enter or Return) that can be raised and a utility function to handle hit testing:
Private Function HitTest(ByVal loc As Point) As Integer Dim i As Integer Dim found As Boolean = False i = 0 Do While i < Me.DataManager.Count And Not found If GetItemRect(i).Contains(loc) Then found = True Else i += 1 End If Loop If found Then Return i Else Return -1 End If End Function Private Sub dbThumbnailView_Click(ByVal sender As Object, _ ByVal e As System.EventArgs) Handles MyBase.Click Dim mouseLoc As Point = Me.PointToClient(Me.MousePosition()) Dim itemHit As Integer = HitTest(mouseLoc) If itemHit <> -1 Then Me.DataManager.Position = itemHit End If End Sub Private Sub dbThumbnailView_DoubleClick(ByVal sender As Object, _ ByVal e As System.EventArgs) Handles MyBase.DoubleClick Dim mouseLoc As Point = Me.PointToClient(Me.MousePosition()) Dim itemHit As Integer = HitTest(mouseLoc) If itemHit <> -1 Then RaiseEvent ItemChosen(itemHit) End If End Sub
Note that I do not need to explicitly raise a Click event from within the dbThumbnailView_Click event handler; the control will automatically raise a standard Click event on its own that can be handled by the user of the control. A Double-click event will be raised as well for every double-click, but the ItemChosen event will only occur if an actual item is double-clicked.
Specifying a Default Event
While I am on the subject of events I thought I would mention one of those "finishing touches" that make your control easy to use. When your control is double-clicked in design view (in the Visual Studio .NET IDE), the programmer will automatically be taken to the event handler for one of your object's events. This IDE feature is very useful in general, but it only works well if it takes the programmer to the most common event handler. By adding the DefaultEvent attribute to your control's class you can specify which event will be selected by the IDE when a programmer double-clicks your control in design view:
<DefaultEvent("ItemChosen")> _ Public Class dbThumbnailView Inherits ListControl
Without this DefaultEvent attribute, the IDE will use whatever DefaultEvent attribute has been defined by your base class or by other classes further up the inheritance chain. In the case of my thumbnail control, the Click event is the default since it is the default event of the Control class, and my base (ListControl) inherits from Control.
Some Issues and Notes
My original control wasn't designed to be used with a keyboard or mouse, so I didn't include a scroll bar. Just the visual indicators (the arrows drawn in the lower- and upper-left corners) were sufficient, but in an environment where a mouse is available you may wish to add a scroll bar. I also didn't use a border or support any use of the mouse in my first version of this control, but I have added both of those features to the version available with this article.
This control is demonstrated as part of the same sample as the data-bound TreeView, and is used to display all of the books for a particular author or publisher (see Figure 3). The code for that sample application is included in the download for this article.
Figure 3. This sample application is used to demonstrate the thumbnail control and the data-bound tree control from Sample 3.
Sometimes you just have to build exactly what you need. With control development you must build your own control completely from scratch, including coding your own graphics work. If you are creating a complex data-bound control like a Grid or some form of ListBox, then you can save yourself a great deal of work by basing your control on the ListControl class, as described in this article.