Advanced Basics
Creating a Five-Star Rating Control
Duncan Mackenzie
Code download available at:AdvancedBasics0501.exe(144 KB)
Contents
Getting Started
Property Routines with Special Default Values
Drawing Out Custom and Standard Images
Handling and Raising Events
Designing for Inheritance
Code Organization
Accessibility
Ease-of-Use
The Triangles Class, an Inheritance Example
Conclusion
I have to admit it; most of my Windows® Forms controls are an attempt to copy something that already exists. In my October 2004 column I showed you how to create a progress bar that mimicked the one shown during the Windows XP setup routine, and this month I'm at it again. This time, the object of my desire is the cool five-star rating control in Windows Media® Player (see Figure 1).
Figure 1** The Five-Star Rating Control **
This control looks great and provides a nice visual way to view ratings, but it is the editing experience that I find especially cool. When the cursor hovers over this column, Windows Media Player highlights the stars to indicate the value you are currently floating over, providing a nice bit of visual feedback. This same type of user interface is found on various Web sites, including Netflix and Amazon, and I'd like to have the functionality in my own applications so I decided to create my own. I'll use a Windows Forms control to emulate this user interface element, while trying to make it customizable enough for use in a variety of situations.
Getting Started
The first step is to create a new Class Library project to hold the control and an empty Windows application to be my test project. The Windows Control Library project template may seem more appropriate, and it will work just fine, but by default that project includes User Controls (which are generally used for composite controls—Windows Forms controls that contain one or more other controls), and all I need is an empty Class file. Next, you must make your currently empty new Class inherit from System.Windows.Forms.Control, which is easily accomplished by adding a single line after the class declaration:
Public Class Ratings Inherits System.Windows.Forms.Control End Class
If you try to add the Inherits statement using only IntelliSense® you will notice a small problem: starting your project with a Class Library template doesn't add a reference to the System.Windows.Forms assembly, so you need to add it manually. At this point, I'd go ahead and also add a reference to the System.Drawing.dll, because a custom-drawn control will need to use it eventually.
From this point on, I generally follow these steps for all my control development:
- Add a standard constructor for any custom-drawn control, setting up all the control styles required for the control to draw correctly and as smoothly as possible.
Public Sub New() Me.SetStyle(ControlStyles.AllPaintingInWmPaint, True) Me.SetStyle(ControlStyles.DoubleBuffer, True) Me.SetStyle(ControlStyles.ResizeRedraw, True) Me.SetStyle(ControlStyles.UserPaint, True) ... 'add any additional initialization code here End Sub
- Working on paper, figure out the list of public properties you would need to configure the control's behavior and appearance.
- Add all those properties as private member variables (I like using Hungarian notation to indicate that they are internal variables by prefixing each with "m_"), and including default values where appropriate, as shown in Figure 2.
- Turn those into property procedures, most of which are pretty straightforward (get value, set value), but a few of which will require some additional code that I'll cover in a little while.
- Start planning out and writing the custom drawing code.
- Finally, I add any new events, like click handling or whatever special events are needed for this particular control.
Figure 2 Control Member Variables
Private m_FilledImage As Image Private m_EmptyImage As Image Private m_HoverImage As Image Private m_ImageCount As Integer = 5 Private m_TopMargin As Integer = 2 Private m_LeftMargin As Integer = 4 Private m_BottomMargin As Integer = 2 Private m_RightMargin As Integer = 4 Private m_ImageSpacing As Integer = 8 Private m_ImageToDraw As Integer = 1 Private m_SelectedColor As Color = Color.Empty Private m_HoverColor As Color = Color.Empty Private m_EmptyColor As Color = Color.Empty Private m_OutlineColor As Color = Color.Empty Private m_selectedItem As Integer = 3 Private m_hoverItem As Integer = 1 Private m_hovering As Boolean = False Private ItemAreas() As Rectangle
Property Routines with Special Default Values
I want a few of my properties—those dealing with colors—to default to values that reflect other properties on the control (such as ForeColor), and others that reflect the user's system colors. Taking just one of these colors, HoverColor, as an example, let's take a look at the different ways I could produce a default value.
The first way is the most obvious, simply set the default value in the variable declaration (or in the constructor):
Private m_HoverColor As Color = _ Color.FromKnownColor(KnownColor.Highlight)
This will work fine in most cases, but it has two problems. First, what if the user changes their system colors while the application is running? The control will reflect the correct colors after restarting the program, but not until then. Second, what if the user wants to programmatically set the color back to the default? There is no real way to clear the color setting and have it use the appropriate system color. The user could certainly set it to the appropriate system color directly, but then you'd be back to the first problem again.
Another option is to trap when the user's system colors change, and change your property values accordingly:
Protected Overrides Sub OnSystemColorsChanged( _ ByVal e As System.EventArgs) Me.HoverColor = Color.FromKnownColor(KnownColor.Highlight) Me.Invalidate() End Sub
This solution doesn't really work, unless you have some way to know whether or not the property is set to the default or if it was set to a specific color by the developer using the control. The overhead of tracking that information would probably not be worth the effort. As an alternative, I decided to use a null/empty default value and then return the appropriate default in the property routine itself, as shown in Figure 3.
Figure 3 Default Property Values
Public Property HoverColor() As Color Get If m_HoverColor.Equals(Color.Empty) Then Return Color.FromKnownColor(KnownColor.Highlight) Else Return m_HoverColor End If End Get Set(ByVal Value As Color) If Not Value.Equals(m_HoverColor) Then m_HoverColor = Value Me.Invalidate() End If End Set End Property
This addresses the issues I have raised so far, including the handling of changes to the system colors, knowing when it is supposed to return the default versus when the user has set it, and allowing the user to reset the value to the default value (when myControl.HoverControl = Color.Empty).
Drawing Out Custom and Standard Images
In the control I'm building, I decided to allow two main categories of images: standard and user-supplied. The initial control only supports two standard images (circles and squares), but later I'll discuss one way to add custom images to this list.
All of the drawing for this control is handled in the OnPaint routine, which I overrode to provide my own rendering code (see Figure 4). Within that routine, I calculate the position for each image (using the ImageCount property to determine the number of images that should be drawn) and then call either DrawStandardImage (to draw a circle or a square) or DrawUserSuppliedImage (to draw images that the user provided).
Figure 4 Rendering the Control
Protected Overrides Sub OnPaint(ByVal e As PaintEventArgs) e.Graphics.Clear(Me.BackColor) Dim imageWidth, imageHeight As Integer imageWidth = (Me.Width-(LeftMargin + RightMargin + ( _ Me.m_ImageSpacing * (Me.m_ImageCount-1)))) \ Me.m_ImageCount imageHeight = (Me.Height-(TopMargin + BottomMargin)) Dim start As New Point(Me.LeftMargin, Me.TopMargin) For i As Integer = 0 To Me.ImageCount-1 Me.ItemAreas(i).X = start.X-Me.ImageSpacing \ 2 Me.ItemAreas(i).Y = start.Y Me.ItemAreas(i).Width = imageWidth + Me.ImageSpacing \ 2 Me.ItemAreas(i).Height = imageHeight If Me.ImageToDraw = UserSuppliedImage Then DrawUserSuppliedImage(e.Graphics, _ start.X, start.Y, imageWidth, imageHeight, i) Else DrawStandardImage(e.Graphics, Me.ImageToDraw, _ start.X, start.Y, imageWidth, imageHeight, i) End If start.X += imageWidth + Me.ImageSpacing Next MyBase.OnPaint(e) End Sub Protected Overridable Sub DrawUserSuppliedImage( _ ByVal g As Graphics, _ ByVal x As Integer, ByVal y As Integer, _ ByVal w As Integer, ByVal h As Integer, _ ByVal currentPos As Integer) Dim img As Image If m_hovering And m_hoverItem > currentPos Then img = Me.HoverImage ElseIf Not m_hovering And m_selectedItem > currentPos Then img = Me.FilledImage Else img = Me.EmptyImage End If If Not img Is Nothing Then g.DrawImage(img, x, y, w, h) Else Me.DrawStandardImage(g, Me.Circle, x, y, w, h, currentPos) End If End Sub Protected Overridable Sub DrawStandardImage( _ ByVal g As Graphics, ByVal ImageType As Integer, _ ByVal x As Integer, ByVal y As Integer, _ ByVal w As Integer, ByVal h As Integer, _ ByVal currentPos As Integer) Dim fillBrush As Brush Dim outlinePen As Pen = New Pen(Me.OutlineColor, 1) If m_hovering And m_hoverItem > currentPos Then fillBrush = New SolidBrush(Me.HoverColor) ElseIf Not m_hovering And m_selectedItem > currentPos Then fillBrush = New SolidBrush(Me.SelectedColor) Else fillBrush = New SolidBrush(Me.EmptyColor) End If Select Case ImageType Case Me.Square g.FillRectangle(fillBrush, x, y, w, h) g.DrawRectangle(outlinePen, x, y, w, h) Case Me.Circle g.FillEllipse(fillBrush, x, y, w, h) g.DrawEllipse(outlinePen, x, y, w, h) End Select End Sub
These routines are not the most efficient (I always redraw the entire control, for example, rather than invalidating only those regions affected by the specific update), but they take care of drawing the appropriate images (or appropriately colored images, in the case of the standard options) whenever necessary. Throughout the rest of the control's code, whenever a property or state change occurs that would result in a change in the control's appearance, a complete redraw is triggered by calling Me.Invalidate. The override routine for OnMouseMove is an example of this type of code:
Protected Overrides Sub OnMouseMove(ByVal e As MouseEventArgs) For i As Integer = 0 To Me.ImageCount-1 If Me.ItemAreas(i).Contains(e.X, e.Y) Then Me.m_hoverItem = i + 1 Me.Invalidate() Exit For End If Next MyBase.OnMouseMove(e) End Sub
Handling and Raising Events
At this point, the control is functional, mostly due to all of the wonderful functionality obtained by inheriting from System.Windows.Forms.Control. This inheritance relationship gives our control a bunch of free features, including a Click event and the ability to be dragged onto the design surface of a Windows Form. More than just those standard features are needed though, so I'll add a new Event and code into a few key areas (see Figure 5).
Figure 5 SelectedItem and SelectedItemChanged
Public Event SelectedItemChanged As EventHandler Protected Overridable Sub OnSelectedItemChanged() RaiseEvent SelectedItemChanged(Me, EventArgs.Empty) End Sub Public Property SelectedItem() As Integer Get Return m_selectedItem End Get Set(ByVal Value As Integer) If Value >= 0 And Value <= Me.ImageCount + 1 Then m_selectedItem = Value OnSelectedItemChanged() Else Value = 0 End If End Set End Property
This new Event, SelectedItemChanged, is more than just a convenience; it also has the nice side effect of improving data binding performance. If the Windows Forms data binding code sees an event with a name following the pattern of <bound property name>Changed, and with a signature defined as System.EventHandler, then it will use that event as notification of a change to the bound property. Monitoring this event is much less work than polling the property for any changes, so the end result is more efficient data binding.
The only other routines I need to add to my control are overrides for the OnMouseEnter and OnMouseLeave routines to ensure that I am correctly displaying the control when the user hovers over it. As shown in Figure 6, I also need to override the OnClick routine so that I can correctly update the currently selected item when the user picks a new rating value.
Figure 6 OnClick Override
Protected Overrides Sub OnClick(ByVal e As System.EventArgs) Dim pt As Point = Me.PointToClient(Me.MousePosition) For i As Integer = 0 To Me.ImageCount-1 If Me.ItemAreas(i).Contains(pt) Then Me.m_hoverItem = i + 1 Me.SelectedItem = i + 1 Me.Invalidate() Exit For End If Next MyBase.OnClick(e) End Sub
At this point, there is a lot of "lipstick" I could add, such as attributes to specify a toolbox bitmap and categorize my properties, but the control is basically finished and works fine. The next trick, though, would be to allow another developer to extend my work to support additional shapes.
Designing for Inheritance
Inheritance only works when classes are designed to be inherited from. Okay, so maybe that is a bit of a strong statement. Inheritance will always work (as long as the class you are inheriting from is not marked as NotInheritable), but when the possibility of future inheritance is considered during the design of a base class, it is generally easier for others to add the functionality they want to add later. To design a class that will be easily inherited from, the first step is to determine how other developers will want to extend it. You can't predict everything another developer might want to do, but you can certainly surmise what some of the most obvious modifications might be. With that in mind, you can now look at your code for organizational issues, accessibility, and ease of use.
Note whether your code is broken up into functions that best encapsulate the areas in which someone might want to extend the class, or whether others would have to rewrite a lot of unrelated code just to add something new. Also note whether you've set the access modifiers (Public, Private, Protected) on your variables and routines appropriately. Remember that your goal is to make the end user's experience as seamless as possible.
There are other concerns when designing your classes to participate in inheritance, but these are the ones I thought about when looking at this particular sample. Taking them in order, I'll discuss the changes I made to my "base" class (Ratings) in order to make it easier to extend.
Code Organization
In order to organize my code most efficiently (although I probably would have done this for clarity anyway), I didn't put the image and shape drawing code right into OnPaint. By leaving them as their own routines, a developer can override one of those routines without having to worry about all the item positioning and graphics setup that occurs in OnPaint. I also was careful to mark those two drawing routines (along with the majority of my routines in this class) as Overridable because they seemed like a likely target for extension.
Accessibility
To make my code somewhat accessible, I made my two drawing routines Protected, instead of Private, enabling them to be used by classes inheriting from my class, but still hiding them from the public interface. I also marked several additional routines, including OnSelectedItemChanged, as Protected, enabling a child class to call those routines if it proves necessary.
Ease-of-Use
The fuzziest of my three points, ease-of-use, is about making an extended version of your class as easy to use as the base class. Of course, you don't control what the inheriting developer will do with your class, so you can't guarantee that it will be easy to use in the end, but you can try to improve the odds. In the case of my class, I had originally created the ImageType property as an Enum (with UserDefined, Square, and Circle included), which led to code like the following:
sr.ImageToDraw = ImageType.Circle
When I try to imagine this in an inheritance situation where the child class has added a new ImageType, there's a problem. The enum can't be extended, so you get this:
sr.ImageToDraw = 3 'some number not in our original enum
This would be a problem in terms of strong typing (although you can make it work because Enums are generally Int32 types under the covers), and it just isn't as pretty. To fix this problem, I ditched the Enum and defined my ImageTypes as public constants on my control class, making the code look like this:
sr.ImageToDraw = Ratings.Circle
And in the case of the child class with the new shape type, like this:
sr.ImageToDraw = myNewClass.NewShape
The Triangles Class, an Inheritance Example
Because of the work I have done so far, I was able to inherit from my control and extend it with a new image type with just a few minutes worth of coding. Figure 7 shows the end result—a class that supports a single new shape type (triangle).
Figure 7** MTS **
The only code I needed was the override of the DrawStandardImage and a new constant, as shown in Figure 8.
Figure 8 Adding a New Ratings Shape
Public Class Triangles Inherits Ratings Public Const Triangle = 3 Protected Overrides Sub DrawStandardImage( _ ByVal g As System.Drawing.Graphics, _ ByVal ImageType As Integer, _ ByVal x As Integer, ByVal y As Integer, _ ByVal w As Integer, ByVal h As Integer, _ ByVal currentPos As Integer) Select Case ImageType Case Triangles.Triangle Dim fillBrush As Brush Dim outlinePen As Pen = New Pen(Me.OutlineColor, 1) If IsHovering AndAlso HoverItem > currentPos Then fillBrush = New SolidBrush(Me.HoverColor) ElseIf Not IsHovering AndAlso _ SelectedItem > currentPos Then fillBrush = New SolidBrush(Me.SelectedColor) Else fillBrush = New SolidBrush(Me.EmptyColor) End If Dim pts(2) As PointF pts(0).X = (x + (w / 2)) pts(0).Y = y pts(1).X = x + w pts(1).Y = y + h pts(2).X = x pts(2).Y = y + h g.FillPolygon(fillBrush, pts) g.DrawPolygon(outlinePen, pts) Case Else MyBase.DrawStandardImage(g, ImageType, _ x, y, w, h, currentPos) End Select End Sub End Class
Control development, whether for Windows or the Web, is a great way to make reusable pieces of code, but if you want to allow other developers to build upon your work then you need to plan carefully for inheritance.
Conclusion
The finished product this month is a simple ratings control, but it would be truly useful if you could use it as a column inside a DataGrid. In my next installment of Advanced Basics, I'll take you through the process of writing your own custom control for use as a DataGrid column, using this rating control as the example. I'll see you then.
Send your questions and comments to basics@microsoft.com.
Duncan Mackenzie is the MSDN Content Strategist for Visual Basic and C# and the author of the Coding 4 Fun column on MSDN online. Duncan can be reached through his personal site, www.duncanmackenzie.net.