Walkthrough: Developing Owner Drawn User Controls - Part 2 (TransLabel and TransPanel's Revenge)
Introduction
After seeing Microsoft Windows MediaCenter Edition, I really liked the the look of using semi-transparent elements to differentiate areas of the screen. I set off to figure out how to accomplish this sort of behavior using .NET functionality. The good news, it can be done. The bad new, it’s not nearly as fast as the Media Center Edition counterparts since it’s using DirectX behind the scenes. However, with some creating thing and careful layout, you can achieve impressive results with the components that we will develop over the next four articles. (This is part 2 of the series)
What will this article do for you?
Building upon the knowledge acquired while following along in the first article, we will build upon those techniques to add a TransLabel
control and fix a serious design flaw with the TransPanel
control. Along the way, you will encounter a couple of the gotchas that I ran into along the way and will also find a few additional pieces of information on how to clean up your GDI+ drawing objects to explicitly free the memory that used. If you haven’t done so, please work through the first article, as this one builds heavily upon it.
Initial Setup of our Project Environment
- Open the solution you created in the first part of this series.
-
Creating the
TransPanel
Component - Add a new Component to the
TransControls
project, call itTransLabel
. - View the source for the
TransLabel
class.
Now, remember there are a couple of things we need to do to allow our coding to be a little more production, not to mention, add some strongly typed checking (in addition, improve the overall performance).
- Add prior to
Public Class TransLabel
the following lines:
1
2
3
4
5
6
Option Explicit On
Option Strict On
Imports System.ComponentModel
Imports System.Drawing.Imaging
Imports System.Drawing.Drawing2D
Now, we need to modify the Inherits
to reflect the type of control we are basing this new control on. We could approach this a couple of ways. The component could inherit from our previously created Panel
control, however, since we don’t need it to be a container and the properties don’t make complete sense when discussing a Label
type control, we are going to inherit from a base Label
control instead and do some copy and paste to get our new component working.
- Modify the
Inherits
line to the following:
1
Inherits System.Windows.Forms.Label
- Next, we need to modify the label so that we will employ double buffering for the drawing code. Add the following to the
Sub New()
method (contained within the Component Designer generated code region after the Add any initialization after theInitalizeComponent()
call remark.
1
2
3
Me.SetStyle(ControlStyles.DoubleBuffer, True)
Me.SetStyle(ControlStyles.AllPaintingInWmPaint, True)
Me.SetStyle(ControlStyles.UserPaint, True)
Now copy all of the code from the TransPanel
component we built previously to the body of our new TransLabel
class (This includes the Opacity
property, the OnPaint
method, the OnMove
method and the member _opacity
variable declaration.):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
Private _opacity As Double = 0.1
< Category("Appearance"), DefaultValue(GetType(Double), "0.1"), Description("Gets or Sets the opacity level of the label.") > _
Public Property Opacity() As Double
Get
Return _opacity
End Get
Set(ByVal Value As Double)
If Value < 0 OrElse Value > 1 Then
Throw New ArgumentException("Value must be between 0.00 and 1.00")
End If
_opacity = Value
Me.Invalidate()
End Set
End Property
Protected Overrides Sub OnPaint(ByVal e As System.Windows.Forms.PaintEventArgs)
If Me.BackgroundImage Is Nothing Then
If Not (Parent.BackgroundImage Is Nothing) Then
e.Graphics.DrawImage(Parent.BackgroundImage, _
New Rectangle(0, 0, Me.Width, Me.Height), _
Me.Left, Me.Top, Me.Width, Me.Height, GraphicsUnit.Pixel)
Else
e.Graphics.FillRectangle(New SolidBrush(Parent.BackColor), _
New Rectangle(0, 0, Me.Width, Me.Height))
End If
Else
MyBase.OnPaint(e)
End If
Dim attributes As New ImageAttributes
Dim matrixElements As Single()() = { _
New Single() {1.0F, 0.0F, 0.0F, 0.0F, 0.0F}, _
New Single() {0.0F, 1.0F, 0.0F, 0.0F, 0.0F}, _
New Single() {0.0F, 0.0F, 1.0F, 0.0F, 0.0F}, _
New Single() {0.0F, 0.0F, 0.0F, CSng(_opacity), 0.0F}, _
New Single() {0.0F, 0.0F, 0.0F, 0.0F, 1.0F}}
Dim matrix As ColorMatrix
matrix = New ColorMatrix(matrixElements)
attributes.SetColorMatrix(matrix, ColorMatrixFlag.Default, ColorAdjustType.Bitmap)
Dim bitmap As Bitmap = New Bitmap(Me.Width, Me.Height, PixelFormat.Format24bppRgb)
Dim g As Graphics = Graphics.FromImage(bitmap)
Dim brush As Brush = New SolidBrush(Me.BackColor)
g.FillRectangle(brush, New RectangleF(0, 0, Me.Width, Me.Height))
brush.Dispose()
e.Graphics.DrawImage(bitmap, _
New Rectangle(0, 0, Me.Width, Me.Height), _
0, 0, Me.Width, Me.Height, GraphicsUnit.Pixel, attributes)
End Sub
Protected Overrides Sub OnMove(ByVal e As System.EventArgs)
Me.Invalidate()
End Sub
- Now we can build our
TransControls
project and do some quick testing. Right mouse on theTransControls
project in the solution explorer. Select build from the menu. - Open our test form (
Demo
project,Form1.vb
) in the designer. This will allow us to update the user controls list in the toolbox. - Select the current
TransPanel
control in theMy User Controls
list. - Press the delete key to remove it from the list.
- Right mouse in the
My User Controls
area and selectAdd/Remove Items
. - Select the
Browse
button. - Navigate to where your
TransControls
project is located and select theTransControls
.dll file located in the bin folder. - Select
OK
.
Now you should have TransCheckBox
, TransLabel
and TransPanel
listed in the My User Controls
toolbox.
- Double click the
TransLabel
component to add an instance to your test form.
As you will notice, the control looks amazingly like the TransPanel
control. So let’s jump back over to the TransControls
project and start the process of making our TransLabel
control come to life.
The first thing we need to do, obviously, is to get our label control to actually display a text string to the screen. (We are going to do a couple of things wrong along this process. I want to show the thinking process along with the possible trial and error that may be involved. So please bear with me.)
- Within the
OnPaint
method, we are going to add to the end of the code block the following:
1
2
e.Graphics.DrawString(MyBase.Text, MyBase.Font, _
New SolidBrush(MyBase.ForeColor), 0, 0)
- This should cause our label component to display the text value (the one handled by our base class (Label) using the base class font and fore color at the coordinates 0, 0 (within the control).
- Build the project and see how it looks in our test form.
As expected, the control now draws the text properly. Well, maybe not properly, since a Label control allows you to specify a TextAlign
property for how the text will be displayed within the control. Let’s fix this. First, we need to figure out the size of the text we are going to draw. We do this by using the MeasureString
method of the Graphics
object. Next, using the information about the size of the string and the size of the drawing area, we can determine where to draw the string on the screen.
- Add the following code prior to the
DrawString
method we added previously.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Dim x, y As Integer
Dim s As SizeF = e.Graphics.MeasureString(MyBase.Text, MyBase.Font)
Select Case MyBase.TextAlign
Case ContentAlignment.TopLeft, ContentAlignment.TopCenter, ContentAlignment.TopRight
y = 0
Case ContentAlignment.MiddleLeft, ContentAlignment.MiddleCenter, ContentAlignment.MiddleRight
y = CInt(Me.Height - s.Height) \ 2
Case Else
y = CInt(Me.Height - s.Height)
End Select
Select Case MyBase.TextAlign
Case ContentAlignment.TopLeft, ContentAlignment.BottomLeft, ContentAlignment.MiddleLeft
x = 0
Case ContentAlignment.TopCenter, ContentAlignment.MiddleCenter, ContentAlignment.BottomCenter
x = CInt(Me.Width - s.Width) \ 2
Case Else
x = CInt(Me.Width - s.Width)
End Select
- Modify the
DrawString
method by changing the two zeros at the end of the method to x and y). - Build the project and let’s do some more testing.
You should now be able to modify the TextAlign
property of the TransLabel
control and the text being drawn will move accordingly.This is all well and good, but your saying to yourself that we are creating a Semi-Transparent label control. I don’t see how this is any different than a regular Label control, other than our background can be set using the Opacity property. You would be correct in your observation. Now, there are a couple of things that we need to accomplish in order to flush our our TransLabel
control.
First, we may not want to have a semi-transparent background and secondly, we want to make the text being drawn to be semi-transparent (according to the Opacity property). Let’s get back to work.
Let’s first work with the issue of not placing the background portion of the label. To do this, we will take advantage of the fact that you can specify Transparent as the background color. We will modify the OnPaint
method to check for the backcolor property being set to Transparent
and draw the background accordingly. In order to accomplish this, all we need to do is remove the portion of the code that draws our semi-transparent bitmap over the base background image we copied from the controls parent region.
- Add the following
If/Then
construct around the code that handles our opacity drawing code (before theDim
attributes… and code thate.Graphics.DrawImage
…). The code will now look like the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
If Not MyBase.BackColor.Equals(Color.Transparent) Then
Dim attributes As New ImageAttributes
Dim matrixElements As Single()() = { _
New Single() {1.0F, 0.0F, 0.0F, 0.0F, 0.0F}, _
New Single() {0.0F, 1.0F, 0.0F, 0.0F, 0.0F}, _
New Single() {0.0F, 0.0F, 1.0F, 0.0F, 0.0F}, _
New Single() {0.0F, 0.0F, 0.0F, CSng(_opacity), 0.0F}, _
New Single() {0.0F, 0.0F, 0.0F, 0.0F, 1.0F}}
Dim matrix As ColorMatrix
matrix = New ColorMatrix(matrixElements)
attributes.SetColorMatrix(matrix, ColorMatrixFlag.Default, ColorAdjustType.Bitmap)
Dim bitmap As Bitmap = New Bitmap(Me.Width, Me.Height, PixelFormat.Format24bppRgb)
Dim g As Graphics = Graphics.FromImage(bitmap)
Dim brush As Brush = New SolidBrush(Me.BackColor)
g.FillRectangle(brush, New RectangleF(0, 0, Me.Width, Me.Height))
brush.Dispose()
e.Graphics.DrawImage(bitmap, New Rectangle(0, 0, Me.Width, Me.Height), _
0, 0, Me.Width, Me.Height, GraphicsUnit.Pixel, attributes)
End If
- Build the project and test the changes. By setting the
BackColor
toTransparent
, the opacity value is now ignored and the control will be truly transparent.
At this point, we really haven’t done anything that the existing Label control isn’t capable of doing already. Now, time to finish what we set out to do, that was to set out and create a semi-transparent label control.
- Modify the text drawing code to look like the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
If MyBase.Text.Length > 0 AndAlso _opacity > 0 Then
Dim transparency As Color
If MyBase.BackColor.Equals(Color.Cyan) Then
transparency = Color.Blue
Else
transparency = Color.Cyan
End If
Dim x, y As Integer
Dim s As SizeF = e.Graphics.MeasureString(MyBase.Text, MyBase.Font)
Select Case MyBase.TextAlign
Case ContentAlignment.TopLeft, ContentAlignment.TopCenter, ContentAlignment.TopRight
y = 0
Case ContentAlignment.MiddleLeft, ContentAlignment.MiddleCenter, ContentAlignment.MiddleRight
y = CInt(Me.Height - s.Height) \ 2
Case Else
y = CInt(Me.Height - s.Height)
End Select
Select Case MyBase.TextAlign
Case ContentAlignment.TopLeft, ContentAlignment.BottomLeft, ContentAlignment.MiddleLeft
x = 0
Case ContentAlignment.TopCenter, ContentAlignment.MiddleCenter, ContentAlignment.BottomCenter
x = CInt(Me.Width - s.Width) \ 2
Case Else
x = CInt(Me.Width - s.Width)
End Select
Dim textAttributes As New ImageAttributes
Dim colorMap As New ColorMap
colorMap.OldColor = transparency
colorMap.NewColor = Color.FromArgb(0, 0, 0, 0)
Dim remapTable() As ColorMap = {colorMap}
textAttributes.SetRemapTable(remapTable, ColorAdjustType.Bitmap)
Dim matrixElements As Single()() = { _
New Single() {1.0F, 0.0F, 0.0F, 0.0F, 0.0F}, _
New Single() {0.0F, 1.0F, 0.0F, 0.0F, 0.0F}, _
New Single() {0.0F, 0.0F, 1.0F, 0.0F, 0.0F}, _
New Single() {0.0F, 0.0F, 0.0F, CSng(_opacity), 0.0F}, _
New Single() {0.0F, 0.0F, 0.0F, 0.0F, 1.0F}}
Dim matrix As New ColorMatrix(matrixElements)
textAttributes.SetColorMatrix(matrix, ColorMatrixFlag.Default, ColorAdjustType.Bitmap)
Dim textLayer As Bitmap = New Bitmap(Me.Width, Me.Height, PixelFormat.Format24bppRgb)
Dim brush As Brush = New SolidBrush(transparency)
g.FillRectangle(brush, rect)
brush.Dispose()
brush = New SolidBrush(Me.ForeColor)
g.DrawString(MyBase.Text, Me.Font, brush, x, y)
brush.Dispose()
g.Dispose()
e.Graphics.DrawImage(textLayer, rect, 0, 0, Me.Width, Me.Height, GraphicsUnit.Pixel, textAttributes)
textLayer.Dispose()
textAttributes.Dispose()
End If
There’s a lot going on here. First, we are checking to see that the opacity value is greater than 0 and that we actually have some text to draw. Next, we need to figure out what out transparency color will be (to mask the area around the text being drawn, since we want to only deal with the text itself). The next portion of the code will be where we will be drawing the text on the control. Now here’s where it starts to get a little tricky. Just like we did previously with the background portion of the control, we will be creating an attribute that will be used to draw this image upon the control surface. In addition to the ColorMatrix
(which handles the semi-transparency drawing), we will also be handling a color remap in order to do our mask drawing (to only draw the text itself, removing the background). Once we’ve got our attribute figured out, we will then create a new bitmap, fill in it’s background with our transparency color and, finally, draw the text string using the specified ForeColor
. Now that we have our image created in memory, we will finally draw it upon the controls surface using the attribute we’ve created. Finally, let’s do some cleanup of our drawing tools by disposing those that expose a Dispose()
method.
To complete this control, we will make a few other modifications.
- Add the following line to the beginning of the
OnPaint
method.
1
Dim rect As New Rectangle(0, 0, Me.Width, Me.Height)
Since we need to know the rectangle area in several places within the code, we can save ourselves the creation of a rectangle structure (which is minimal, but let’s look at this from a maintenance standpoint). Now, everywhere in this method where we were creating a new rectangle of this size, replace it with our new rect
variable.
- Next, we need to address the fact that we are checking to see if this controls background image is being set and draw the control accordingly. Although we aren’t getting an error because of the non-existence of a
BackgroundImage
property (which I believe is because the base Control class has one), we need not check this since a Label control does not provide aBackgroundImage
property. So let’s remove this check. - Finally, let’s modify the section that draws the background layer to also check that the opacity value is greater than 0.
- Build the project and test the control.
Since we are testing our new TransLabel
control, let’s make a copy of it and past it within the TransPanel
control we built during the first article. Hmmm… it’s not working correctly. Why is this?
The reason is the fact that we are relying on the fact that our parent control has a BackgroundImage
set to something. The problem here is that our TransPanel
control doesn’t actually have a background image. We are just drawing on the controls surface in order to owner draw what we want it to look like. Your saying to yourself, this should be simple to fix… right?
Let’s jump back to the TransPanel
class and make a couple of changes.
- Add the following to the beginning of the
OnPaint()
method.
1
Dim rect As New Rectangle(0, 0, Me.Width, Me.Height)
- Next, make all the changes where we are creating a new rectangle on the drawing methods to use this new variable (where appropriate).
- And while where in cleanup mode, we need to add the following just prior to the End Sub:
1
2
3
g.Dispose()
bitmap.Dispose()
attributes.Dispose()
Before we continue, build the TransControls
project and test in our demo project.Now, let’s fix the problem. Since all we need to do is have the backgroundImage
property set to what we are drawing, we will need to modify the OnPaint
method to draw onto a memory bitmap and then set the BackgroundImage
property accordingly.
- Add the following to the top of the
OnPaint
method.
1
Dim memoryBitmap As New Bitmap(Me.Width, Me.Height, PixelFormat.Format24bppRgb)
This will provide us with a base surface to draw upon. Now, everywhere where we are using e.Graphics.xxxx
, we need to modify to draw upon a m
reference that will draw upon the memoryBitmap
image.
- Add the following code just after the creation of the
memoryBitmap
variable.
1
Dim m As Graphics = Graphics.FromImage(memoryBitmap)
- Now, modify all the usage of
e.Graphics.
tom.
within theOnPaint
method. - Finally, we will use our new
memoryBitmap
to set theMyBase.BackgroundImage
property. - Build the project and test our changes.
Add another TransPanel
to the test form. Hmm… why does it keep flickering? Wait for it… wait a little longer…. bham! a message box from Visual Studio .NET pops up and lets us know that we’ve run “Out of memory.” Well, isn’t that nice. So what did we do wrong?
The answer to that is the fact that we are setting the background image to a new value within the OnPaint
event. What’s the problem with that? Well, whenever the background image changes, the control becomes invalidated, which means the OnPaint
event will fire again. We’ve caused an infinite loop to occur, which causes a stack overflow and thus the out of memory exception. How doe we fix this?
Well, obviously, we need to move our drawing code outside of the OnPaint
method. Doing so, we will need to manually watch for specific events to occur in order to make sure that the background image is updated accordingly. The side effect to this is the fact that the BackgroundImage
will now pretty much be a read-only property, since we will be recreating it on the fly based on other property values. This means that consumers of this control will no longer be able to specify a background image, which shouldn’t be a problem, since the goal here is to create a semi-transparent control. So, we can now remove the check to see if there is a background image specified (since there now will be) and it allowing the base class to handle the drawing.
- Remove the
If/Then
check to see if theMyBase.BackgroundImage
is set. - Change the method definition from:
1
Protected Overrides Sub OnPaint(ByVal e As System.Windows.Forms.PaintEventArgs)
To:
1
Private Sub CreateBackgroundImage()
- Modify the OnMove method by removing
Me.Invalidate()
. - Add
CreateBackgroundImage()
to theOnMove
method. - Do the same within the
Property Set
forOpacity
. - Build the project and test.
Hmmm… we are getting an error saying that “Object reference not set to an instance of an object”. This actually makes sense in a twisted or of way. You have to first understand how controls work within .NET. Whenever a control is created it goes through a couple of stages before actually being displayed on the screen. What is happening here is that the Opacity property is being set prior to the control being added to the container. So what can we do to fix this?
Since we need to modify the background image whenever the opacity is set, we need to obviously leave this code in place. The easiest way (although it might not be the most elegant) is to wrap the CreateBackgroundImage()
method with a Try
/Catch
block. This way, whenever the component is created, it will still try to do the CreateBackgroundImage
(which will be ignored due to an error occurring) and once the component is created, the method will then work as expected.
- Add an empty
Try
/Catch
/End Try
block around all of the code within theCreateBackgroundImage
method. - Build the control and test.
You will find that the control is drawn using the background color and only is draw as we expect when we set the Opacity
property or actually move the control on the form. We now need to handle a couple of other events in order to update the background image upon these events occurring. We will need to handle Resize
, BackColorChanged
, and ParentBackgroundImageChanged
.
- Using the drop down lists at the top of the code pane, select TransPanels Overrides on the left and on the right, find and add
OnResize
,OnBackColorChanged
, andOnParentBackgroundImageChanged
. - Add a call to the
CreateBackgroundImage
within each of these methods. - You will also want to modify each of these methods so that they call upon the base version of the method. For example, the
OnMOve
method should look like this once you make all of the changes.
1
2
3
4
Protected Overrides Sub OnMove(ByVal e As System.EventArgs)
CreateBackgroundImage()
MyBase.OnMove(e)
End Sub
This gets us most of the way there. We also need to make sure the background image is created upon the creation of this control. The initial problem is the fact that we obviously can’t do this during New() since we will have the same problem as the one we had with the Opacity property being set. This all happens before the control is actually created and housed within a parent control. Luckily, the framework provides us with a override for this occasion.
- Add the following to the
TransPanel
class.
1
2
3
4
Protected Overrides Sub OnCreateControl()
CreateBackgroundImage()
MyBase.OnCreateControl()
End Sub
- Build the control and test.
Now, if you add a TransLabel
control to the TransPanel
control, it works as expected.
Conclusion
This wraps up the second installment to this series. As always, I’m open to any suggestions for improving this and future articles. The next article(s), we will build further upon this article by adding custom drawn check box and button controls. I will also be creating a TransImage
control which should wrap up the series nicely by creating a user interface that looks somewhat like the Windows Media Center Edition interface.
See you in the next installment.