Drawing Your Own Controls Using GDI+

 

Duncan Mackenzie
Microsoft Developer Network

May 2002

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)

Download WinFormControls.exe.

This article is the fifth in a five-article series on developing controls in Microsoft® .NET:

Contents

Introduction
Supporting Complex Data Binding
Drawing the Images
Overriding OnPaint
Handling Key Presses and Mouse Clicks
The Sample
Summary

Introduction

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:

  1. Display a multi-column view of images that supports multiple pages, and allows an individual item to receive focus.
  2. The item text, image (image URL), and a corresponding value must all be data-bound properties.
  3. 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.

Overriding OnPaint

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.

The Sample

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.

Summary

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.