5.2. Implementing the Control


Now, you are ready to create your control. First, add the following members to the ClockFaceCtrl class. (You'll implement the StringDraw class shortly.)

 Private Shared offset As Integer = 0 Private b24Hours As Boolean = False Private Const FaceRadius As Integer = 700 Private Const DateRadius As Integer = 900 Private currentTime As DateTime Private myFont As New Font("Arial", 80) Private sdToday As StringDraw Private bForceDraw As Boolean = True 

Second, you'll need a timer to drive the automatic updating of the clock. Drag a Timer control onto the ClockFaceCtrl window. Visual Basic automatically creates an object of the System.Windows.Forms.Timer class, and names it Timer1. You don't need to do anything else with it for now; you'll set its Elapsed event in the initialization code.

Third, there are two helper methods you'll need to handle some trigonometry for you, GetCos and GetSin. I'll explain what they do later; for now, you can just type them in.

 Private Shared Function _    GetCos(ByVal degAngle As Single) As Single     Return CSng(Math.Cos((Math.PI * degAngle / 180.0F))) End Function 'GetCos Private Shared Function _    GetSin(ByVal degAngle As Single) As Single     Return CSng(Math.Sin((Math.PI * degAngle / 180.0F))) End Function 'GetSin 

Partial Classes

The definition of a class in two files is an example of using partial classes that allow you, as the developer of the class, to divide the definition of the class into more than one file.

The keyword Partial is not required in each part of the class, although it does make for good documentation.

You'll notice that just about every class generated by the designer is split into two files: the Designer file and the file you see when you right-click on the form and choose "View Code." Partial classes allow for a clean separation of tool-generated code from programmer-created code, and are a great advantage in creating maintainable code.


Add a constructor to the ClockFaceCtrl class. The easiest way to do so is to click on the class in the upper-left drop down, and to click on the method you want to override (New) in the upper-right drop down, as shown in Figure 5-4.

Figure 5-4. Overriding the constructor


This creates a default constructor (one with no arguments). The first two lines of your new constructor must invoke the base class' constructor and also call InitializeComponent.

     MyBase.New(  )     InitializeComponent(  ) 

A default constructor is any constructor (Sub New) that has no arguments. If you don't create a constructor in your own code, the compiler will create a default constructor for you. Just because it is created by the compiler does not make it the default constructor; it is because the constructor takes no arguments (no parameters) that makes it a default constructor. This is, alas, the source of endless confusion, because the compiler creates a default constructor for you, by default!


The next two lines set the BackColor and ForeColor for your control. When you draw the clock face you'll need to tell the CLR what color to use for the numbers. You might be tempted to use black, which is perfectly appropriate, but it does raise a problem. The user may have changed the color scheme to a very dark background (even to black) which would make your clock face invisible.

A better alternative is to set the BackColor and ForeColor for ClockFaceCtrl to the Window and WindowText colors the user has chosen:

 BackColor = SystemColors.Window ForeColor = SystemColors.WindowText 

You can now create a brush that uses the foreground color and feel comfortable that this is a good choice.

Here are the next several lines in the constructor:

 Dim today As String = System.DateTime.Now.ToLongDateString(  ) today = " " + today.Replace(",","") // remove commas sdToday = New StringDraw  (today, Me)   currentTime = DateTime.Now AddHandler Timer1.Elapsed AddressOf OnTimer 

Because these lines require you to implement the StringDraw class and the OnTimer method, and those will need quite a few pages of explanation, the complete listing of the constructor is shown in Example 5-1. (You can finish typing it in now, but it won't compile until StringDraw and OnTimer are complete.) We'll later pick up our discussion of the constructor beginning with the currentTime assignment statement.

Example 5-1. Constructor for ClockFaceCtrl custom control (from ClockFaceCtrl.vb)
 Public Sub New(  )    MyBase.New(  )    ' This call is required by the Component Designer.    InitializeComponent(  )    BackColor = SystemColors.Window    ForeColor = SystemColors.WindowText    Dim today As String = System.DateTime.Now.ToLongDateString(  )    today = " " + today.Replace(",", "") // remove commas    sdToday = New StringDraw(today, Me)    currentTime = DateTime.Now    Dim timer As New System.Timers.Timer(  )    AddHandler timer.Elapsed, AddressOf OnTimer    timer.Interval = 50    timer.Enabled = True End Sub 'New 

5.2.1.

5.2.1.1. The StringDraw class

Now let's implement the StringDraw class. Its job will be to draw the date and time in a circle around the clock. The date and time will turn upside down if you don't make a special effort to prevent the letters from rotating, so you will use letters that act like a Ferris-wheel car, remaining upright as they rotate around the clock.

You're going to nest StringDraw within the ClockFaceCtrl class. Add the class declaration to ClockFaceCtrl, as follows:

 Public Class ClockFaceCtrl     ...     Private Class StringDraw     End Class End Class 

The StringDraw class has three members: a list of LtrDraw objects (described in a moment), an instance of LtrDraw, and an instance of a ClockFaceCtrl:

 Private myLtrDrawList As Generic.List(Of LtrDraw) = _     New Generic.List(Of LtrDraw) Private myLtrDraw As LtrDraw Private theControl As ClockFaceCtrl 

The StringDraw constructor takes two parameters: a string and a ClockFaceCtrl object. For each character in the string, it initializes a LtrDraw object, which it then adds to the myLtrDrawList collection, as shown in Example 5-2.

Example 5-2. StringDraw constructor
 Public Sub New(ByVal s As String, _   ByVal theControl As ClockFaceCtrl)     Me.theControl = theControl     Dim c As Char     For Each c In s         myLtrDraw = New LtrDraw(c)         myLtrDrawList.Add(myLtrDraw)     Next c End Sub 'New 

When the constructor of the ClockFaceCtrl creates a StringDraw (represented by the variable sdToday), it passes in its Me reference, which refers to the custom control you are creating. For the string, it passes in the current date from which all commas have been removed. (See Example 5-1.)

The code still won't compile even after you enter the StringDraw constructor. To complete the implementation, you still need the LtrDraw class and the ClockFaceCtrl.OnTimer method. (You'll also return to StringDraw to add its most important method, DrawTheString.)

5.2.1.2. The LtrDraw class

The LtrDraw class is responsible for drawing an individual letter. Like the StringDraw class, it is defined as a private nested class within ClockFaceCtrl (see the sidebar "Nested Classes").

Add the class declaration for LtrDraw:

 Public Class ClockFaceCtrl ...     Private Class LtrDraw     End Class End Class 

LtrDraw has five members: the character it holds (myChar), and the current and old x,y coordinates. It remembers the old x and y coordinates so it can erase the character from its previous position before drawing it in its new one.

 Private myChar As Char Private _x As Single Private _y As Single Private oldx As Single Private oldy As Single 

Nested Classes

In Visual Basic 2005 you may nest one class within another, as we've done here with the LtrDraw and StringDraw classes. These classes are "scoped" within the outer class (ClockFaceCtrl) and if you were to refer to the GetWidth( ) method of the LtrDraw class, you would refer to it as ClockFaceCtrl.LtrDraw.GetWidth.

However, because we've defined LtrDraw to be a private nested class, none of its methods are available to outside classes (that is, classes other than the outer class (ClockFaceCtrl) and classes nested within ClockFaceCtrl (such as StringDraw).

Nested classes help hide these "helper" classes from other classes and avoid cluttering up your namespace with class names that are not relevant to other classes.


The constructor for this class takes a char and sets its myChar member variable:

 Public Sub New(ByVal c As Char)     myChar = c End Sub 

The class also provides read/write properties named X and Y. The Set accessor for X remembers the current value of _x in oldx, then sets the value of _x to the value passed in:

 Public Property X(  ) As Single     Get         Return _x     End Get     Set(ByVal Value As Single)         oldx = _x         _x = Value     End Set End Property 

The Y property does the same work for the _y member.

The LtrDraw class has three methods: GetWidth , GetHeight , and DrawLetter.

GetWidth is passed an instance of a Graphics object and an instance of a Font object. It calls the MeasureString method on the Graphics object, passing in the character the class is holding and the font in which that character will be rendered, to get the width of the character as it will be displayed in the application. The GetWidth method is shown in Example 5-3.

Example 5-3. GetWidth method
 Public Function GetWidth( _   ByVal g As Graphics, ByVal theFont As Font) _   As Single     Dim stringSize As SizeF = _       g.MeasureString(myChar.ToString(  ), theFont)     Return stringSize.Width End Function 'GetWidth 

GetHeight works the same way, returning the rendered height, as shown in Example 5-4.

Example 5-4. GetHeight method
 Public Function GetHeight( _   ByVal g As Graphics, ByVal theFont As Font) _   As Single     Dim stringSize As SizeF = _       g.MeasureString(myChar.ToString(  ), theFont)     Return stringSize.Height End Function 'GetHeight 

DrawLetter's job is to actually draw the string in the appropriate location. It is passed a Graphics object as well as a Brush (to determine the color for the letters) and the control itself.

 Public Sub DrawLetter( _    ByVal g As Graphics, ByVal brush As Brush, _    ByVal ctrl As ClockFaceCtrl) 

There are two steps to drawing a letter. First, you set the brush to the background color and draw the character at its old location. The effect is to erase the character.

 ' get a blanking brush to blank out the old letter Dim blankBrush As SolidBrush = New SolidBrush(ctrl.BackColor) ' draw over the old location (erasing the letter) g.DrawString(myChar.ToString(  ), theFont, _   blankBrush, oldx, oldy) 

Second, you change to the brush you were given and redraw the character at its new location:

 ' draw the letter in the new location using the ' brush that was passed in g.DrawString(myChar.ToString(  ), _   theFont, brush, X, Y) 

Example 5-5 combines these two steps, and shows the full listing of DrawLetter.

Example 5-5. LtrDraw.DrawLetter method
 Public Sub DrawLetter( _    ByVal g As Graphics, ByVal brush As Brush, _    ByVal ctrl As ClockFaceCtrl)     'get the font to draw     Dim theFont As Font = ctrl.myFont     ' get a blanking brush to blank out the old letter     Dim blankBrush As SolidBrush = New SolidBrush(ctrl.BackColor)     ' draw over the old location (erasing the letter)     g.DrawString(myChar.ToString(  ), theFont, _       blankBrush, oldx, oldy)     ' draw the letter in the new location using the     ' brush that was passed in     g.DrawString(myChar.ToString(  ), _       theFont, brush, X, Y) End Sub 'DrawLetter 

The version of Graphics.DrawString you use in this example takes five parameters:

  • The string to draw (myChar.ToString)

  • The font to draw in (e.g., Arial 8)

  • A brush to determine the color and texture of the text

  • The x coordinate of the upper lefthand corner of the text

  • The y coordinate of the upper lefthand corner of the text

5.2.1.3. The DrawString.DrawTheString( ) method

As mentioned above, the DrawString class is still missing one method, DrawTheString . Now that you've finished implementing LtrDraw.DrawLetter, you have the key piece.

The first job of this method is to compute the angle by which each letter will be separated. You ask the string for the count of characters, and you use that value to divide the 360 degrees of the circle into equal increments.

 Dim angle As Integer = 360 \ theString.Count 

Now you iterate through the members of myLtrDrawList. For each LtrDraw object you'll want to compute the new x and y coordinates.

You do so by multiplying the angle value computed above by what amounts to the index of the letter (that is, 0 for the first letter, 1 for the second letter, 2 for the third, and so forth), represented by the variable counter. You then add 90 to start the string at 12 o'clock (this is not strictly necessary, since the string will rotate around the clock face!). To make the date string rotate, you must also subtract the value of the shared member variable ClockFaceCtrl.offset. The full computation of the angle is:

 angle * counter + 90 - ClockFaceCtrl.offset 

You take the cosine of this value using the helper method GetCos, which I presented earlier without explanation. GetCos makes use of the Math class's Cos method, which expects an angle in radians rather than degrees. To convert the angle to radians, you multiply it by the value of pi (3.14159265358979..., represented as Math.PI) and divide by 180.

 Private Shared Function _    GetCos(ByVal degAngle As Single) As Single     Return CSng(Math.Cos((Math.PI * degAngle / 180.0F))) End Function 'GetCos 

To get the x coordinate, you multiply the cosine by the constant ClockFaceCtrl.DateRadius (defined as 900), as shown in Example 5-6.

Example 5-6. GetCos method
 Dim newX As Single = _   GetCos((angle * counter + _   90 - ClockFaceCtrl.offset)) _   * ClockFaceCtrl.DateRadius 

Computing the X, Y Coordinates

You compute the x coordinate of a point on a circle by multiplying the cosine of the angle by the radius and you compute the y coordinate of a point on a circle by multiplying the sine of the angle by the radius (see PreCalculus with Unit Circle Trigonometry by David Cohn, [Brooks Cole]).

But this formula (cosine of the angle multiplied by radius) assumes that the center of the circle is the origin of your coordinate system, and that the angle you are multiplying is in radians , measured counter clockwise from the positive x-axis. It also assumes that the y-axis is positive above the origin and negative below.

The first issue is radians versus degrees. A circle is 360 degrees, so if you want to place 12 numbers around the face, each number is 30 degrees from the previous number. You'll need to convert degrees to radians using a simple formula:

When creating a clock face, it is convenient to measure the degrees offset from the y-axis (aligned with 12 o'clock) rather than the x-axis. And it is convenient to increase the angle as you move clockwise (hence the name) rather than the traditional counter-clockwise. In addition, the coordinate system you'll be using has y values that are negative above the origin, rather than positive.

You solve all three problems (using the y-axis as the zero angle, moving clockwise, and the coordinate system) by taking advantage of the fact that the cosine of 90 plus an angle is equal to the opposite of the cosine of 90 minus the angle. This gives us a way to implement a fairly straightforward computation. For example, to compute 2 o'clock in this system, you compute that 2 is 60 degrees clockwise from 12, add 90, and convert the resulting angle (150) to radians and take the cosine of that value. You can then multiply the result times the radius of the circle and you'll get x,y coordinates that match your coordinate system.


ClockFaceCtrl.DateRadius was defined as the private constant value 700.


Returning to DrawTheString, you compute newY the same way as newX, except that you use the GetSin helper method. Like GetCos, it takes an angle in degrees, converts it to radians, and calls Math.Sin to return its sine, as shown in Example 5-7.

Example 5-7. GetSin method
 Private Shared Function _    GetSin(ByVal degAngle As Single) As Single     Return CSng(Math.Sin((Math.PI * degAngle / 180.0F))) End Function 'GetSin 

Unfortunately, what you've computed is the upper lefthand corner of the bounding rectangle for the character you are going to draw. To center the character at this location, you must compute the width and height of the character, and adjust your coordinates accordingly:

 theLtr.X = newX - theLtr.GetWidth( _    g, theControl.myFont) / 2 theLtr.Y = newY - theLtr.GetHeight( _    g, theControl.myFont) / 2 

That accomplished, you increment the counter:

 counter += 1 

Now, you tell the LtrDraw object to draw itself, then you move to the next letter in the string:

     theLtr.DrawLetter(g, brush, theControl) Next theLtr 

Once the loop is completed, you increment the shared offset member of the ClockFace. This will cause you to draw the letter at a slightly different angle the next time around (i.e., the next time the OnTimer event fires), thereby rotating the date string around the perimeter of the clock.

 ClockFaceCtrl.offset += 1 

When you draw each letter, you'll compute the angle as:

 angle * counter + 90 - ClockFaceCtrl.offset 

If the date string has 30 characters, angle will be 12 (360º / 30). counter starts at zero, and ClockFaceCtrl.offset is initialized to 0, so you'll get 12 * 0 + 90 - 0, or 90º. For the second character, counter will be 1, and you'll compute its angle as 12 * 1 + 90 - 0, or 102º.

The next time OnTimer fires, ClockFaceCtrl.offset will be 1. Therefore, you'll compute an angle of 89º for the first character, 101º for the second, etc. Because OnTimer is called 20 times a second, this creates the illusion of the string marching around the outside of the clock face.

The complete listing of StringDraw.DrawTheString is shown in Example 5-8.

Example 5-8. StringDraw.DrawTheString method
 Public Sub DrawTheString( _   ByVal g As Graphics, ByVal brush As Brush)     Dim angle As Integer = 360 \ myLtrDrawList.Count     Dim counter As Integer = 0     Dim theLtr As LtrDraw     For Each theLtr In myLtrDrawList         Dim newX As Single = _           GetCos((angle * counter + 90 - _           ClockFaceCtrl.offset)) * _           ClockFaceCtrl.DateRadius         Dim newY As Single = _           GetSin((angle * counter + 90 - _           ClockFaceCtrl.offset)) * _           ClockFaceCtrl.DateRadius         theLtr.X = newX - theLtr.GetWidth( _            g, theControl.myFont) / 2         theLtr.Y = newY - theLtr.GetHeight( _            g, theControl.myFont) / 2         counter += 1         theLtr.DrawLetter(g, brush, theControl)     Next theLtr     ClockFaceCtrl.offset += 1 End Sub 'DrawString 

5.2.1.4. Drawing the clock face

All of the above has been a digression from the middle of the ClockFaceCtrl constructor. The next part of the constructor gets the current time:

 currentTime = DateTime.Now 

As you saw earlier, when you dragged the Timer control onto the ClockFaceCtrl, Visual Basic created a System.Windows.Forms.Timer object for you and named it Timer1. It has a property, Interval, that sets how long the timer should tick (in milliseconds) before its time elapses. You will want to set that property and also enable the timer so that it begins ticking down.

 Timer1.Interval = 50// milliseconds Timer1.Enabled = True 

When the Interval has elapsed the timer will fire its Elapsed event. You want to handle that event in the OnTimer method, and you can set that relationship programmatically using the AddHandler statement, passing in the Event and the address of the method that will respond to the event:

 AddHandler timer.Elapsed, AddressOf OnTimer 

The OnTimer method is where things get interesting. Once you've implemented it, you'll be able to build and run the project and see the custom control in action. The stub for OnTimer is shown in Example 5-9, with the various methods to be implemented marked by comments.

Example 5-9. Stub for OnTimer method
 Public Sub OnTimer( _ ByVal source As Object, _ ByVal e As Timers.ElapsedEventArgs)    Using g As Graphics = Me.CreateGraphics       'SetScale(g)       'DrawFace(g)       'DrawTime(g, False)       'DrawDate(g)    End Using End Sub 'OnTimer 

This method runs every 50 milliseconds (when the timer interval elapses). It gets a Graphics object, then takes four steps before deleting it. To ensure that the Graphics object itself is deleted as soon as you are done with it, you acquire the device in a Using statement. When the End Using statement is reached, the Graphics object is disposed.

Within the Using block you call four methods: SetScale, DrawDate, DrawFace, and DrawTime. (DrawFace and DrawTime are commented out for now; you'll get to them soon.)

The first step is to set the scale for the clock; the SetScale method takes care of this. To do so, you need to move the origin of the x,y axis from its normal position at the upper left to the center of the clock. You do that by calling TRanslateTransform on the Graphics object, passing in the x,y coordinates of the center (that is, x is half the width and y is half the height).

The TRanslateTransform method is overloaded; the version you'll use takes two Singles as parameters: the x component of the translation and the y component. You want to move the origin from the upper left halfway across the form in the x direction and halfway down the form in the y direction.

World translations are implemented with Matrices. This mathematical concept is beyond the scope of this book, and you do not need to understand matrices to use the transformations.


The form inherits two properties from Control that you'll put to use: Width and Height. Each of these returns its value in pixels.

 g.TranslateTransform(CSng(Width / 2), CSng(Height / 2)) 

The effect is to transform the origin (0,0) to the center both horizontally and vertically.

You are now set to transform the scale from its current units (pixels by default) to an arbitrary unit. You don't care how large each unit is, but you do want 1,000 units in each direction from the origin, no matter what the screen resolution. The size of the units must be equal in both the horizontal and the vertical direction, so you'll need to choose a size. You thus compute which size is smaller in inches: the width or the height of the device.

 Dim inches As Single = Math.Min(Width / g.DpiX, Height / g.DpiY) 

You'll next multiply inches by the dots per inch on the x-axis to get the number of dots in the width, and divide by 2,000 to create a unit that is 1/2,000 of the width of the screen. You'll then do the same for the y-axis. If you pass these values to ScaleTransform( ), you'll create a logical scale 2,000 units on the x-axis and 2,000 units on the y-axis, or 1,000 units in each direction from the center.

 g.ScaleTransform(    inches * g.DpiX / 2000, inches * g.DpiY / 2000) 

The complete listing for the SetScale method appears in Example 5-10.

Example 5-10. ClockFaceCtrl.SetScale( )
 Private Sub SetScale(ByVal g As Graphics)     If Width = 0 Or Height = 0 Then         ' User has made the clock invisible         Return     End If     ' set the origin at the center     g.TranslateTransform(CSng(Width / 2), CSng(Height / 2))     Dim inches As Single = _        Math.Min(Width / g.DpiX, Height / g.DpiY)     g.ScaleTransform( _        inches * g.DpiX / 2000, inches * g.DpiY / 2000) End Sub 'SetScale 

5.2.1.5. Drawing the date

After you set the scale of the clock in OnTimer, you call DrawDate:

 Private Sub DrawDate(ByVal g As Graphics)     Dim brush As SolidBrush = New SolidBrush(ForeColor)     sdToday.DrawTheString(g, brush) End Sub 'DrawDate 

This code invokes the DrawTheString method on the member variable sdToday (which is of type DrawString). As you saw earlier, DrawTheString draws the date around the clock by calling DrawLetter on each letter in the string, passing in the Graphics object, the brush created here in DrawDate, and the ClockFaceCtrl object itself. DrawLetter erases the letter from its old position and draws the letter in its new position, thus "animating" the string to move around the clock face.

5.2.2. Adding the Control to a Form

Before you can use the ClockFaceCtrl that you just created, you'll need to build the NorthWindControls project. After you've done that, create a form named frmClock.vb in the NorthWindWindows project so you can test the control as you add functionality to it. Set the form's size to 520,470.

Drag two controls onto the form: a button (which you'll name btn1224) and a ClockFaceCtrl (which you'll find in the toolbox in the NorthWindControlsComponents tab). Set the clock face control's location to 60,60 and its size to 350,350.

Modify the Welcome form to add a menu choice, Clock. In its Click event handler, show the frmClock form.

 Private Sub ClockToolStripMenuItem_Click( _  ByVal sender As System.Object, _  ByVal e As System.EventArgs) _  Handles ClockToolStripMenuItem.Click      frmClock.Show(  )  End Sub 

Run the application. When you click on the Clock item on the main menu, you should see frmClock with the ClockFaceCtrl displaying the date around the perimeter of the clock, as shown in Figure 5-5.

Figure 5-5. Clock custom control displaying date


5.2.2.1. Drawing the numbers

The work of drawing the clock face is done by the DrawFace method. (As you may recall, we've commented out the call to it in the OnTimer procedure.) To draw this clock, you must write the strings 1 through 12 (or 1 through 24 if the Boolean value b24Hours is set to TRue) in the appropriate location. You will specify the location as x,y coordinates, and these coordinates must be on the circumference of an imaginary circle.

The formula is to get the degrees by dividing the entire circle (360) by the number of hours (12 or 24). Once again, you get the coordinates by using GetCos and GetSin, passing in the number multiplied by the degrees plus 90, and all of that in turn multiplied by the value in FaceRadius (a member constant defined as 700), which represents the radius of the clock face.

However, these x,y coordinates will be the location of the upper lefthand corner of the numbers you draw. This will make for a slightly lopsided clock.

To fix this, you must center the string around the point determined by your location formula. There are two ways to do so. The first is to measure the string, then subtract half the width and height from the location. You begin by calling the MeasureString method on the Graphics object, passing in the string (the number you want to display) and the font in which you want to display it.

 Dim stringSize As SizeF = _    g.MeasureString(i.ToString(  ), font) 

You get back an object of type SizeF, a Structure that has two properties: Width and Height. You can now compute the coordinates of the number you're going to draw, then offset the x location by half the width and the y location by half the height.

 x = GetCos(i*deg + 90) * FaceRadius; x += stringSize.Width / 2; y = GetSin(i*deg + 90) * FaceRadius; y += stringSize.Height / 2; 

This works perfectly well, but .NET is willing to do a lot of the work for you. The trick is to call an overloaded version of the DrawString method that takes an additional (sixth) parameter: an object of type StringFormat.

 Dim format As New StringFormat(  ) 

You set its Alignment and LineAlignment properties to control the horizontal and vertical alignment of the text you want to display. These properties take one of the StringAlignment enumerated values: Center, Far, and Near. Center will center the text, as you'd expect. The Near value specifies that the text is aligned near the origin, while the Far value specifies that the text is displayed far from the origin. In a left-to-right layout, the Near position is left and the Far position is right.

 format.Alignment = StringAlignment.Center format.LineAlignment = StringAlignment.Center 

You are now ready to display the string:

 g.DrawString(    i.ToString(  ), font, brush, -x, -y,format); 

Notice that the x and y values represent how much you must back off the upper lefthand corner of location of the letter so that the character is centered. Thus, these values must be negative.


The StringFormat object takes care of aligning your characters, and your clock face is no longer lopsided. As a nice added feature, if the second hand is pointing to one of the numbers, you'll paint that number green.

 If currentTime.Second = i * 5 Then     g.DrawString(i.ToString(  ), myFont, _       greenBrush, -x, -y, format) Else     g.DrawString(i.ToString(  ), myFont, _       brush, -x, -y, format) End If 

Example 5-11 shows the complete listing.

Example 5-11. ClockFaceCtrl.DrawFace ( )
 Private Sub DrawFace(ByVal g As Graphics)     Dim brush As SolidBrush = New SolidBrush(ForeColor)     Dim greenBrush As SolidBrush = New SolidBrush(Color.Green)     Dim x, y As Single     Dim numHours As Integer     If b24Hours Then         numHours = 24     Else         numHours = 12     End If     Dim deg As Integer = 360 \ numHours     Dim i As Integer     For i = 1 To numHours         x = GetCos((i * deg + 90)) * FaceRadius         y = GetSin((i * deg + 90)) * FaceRadius         Dim format As New StringFormat(  )         format.Alignment = StringAlignment.Center         format.LineAlignment = StringAlignment.Center         If currentTime.Second = i * 5 Then             g.DrawString(i.ToString(  ), myFont, _               greenBrush, -x, -y, format)         Else             g.DrawString(i.ToString(  ), myFont, _               brush, -x, -y, format)         End If     Next i End Sub 'DrawFace 

You can now rebuild the project and run it, but first you need to make one small change: return to the OnTimer method and uncomment the call to DrawFace. You can do this quickly by selecting the line and pressing Ctrl-T U.

When you run it, the form now looks like Figure 5-6.

5.2.2.2. Drawing the time

After drawing the face of the clock, you are ready to draw the hour and minute hands and the second dot (the dot moves around the clock face, indicating the seconds).

Now things get really interesting. The complete listing for the DrawTime method is shown in Example 5-12, and is analyzed line by line afterward.

Figure 5-6. ClockFaceCtrl with date display and clock face


Example 5-12. DrawTime method
 Private Sub DrawTime( _       ByVal g As Graphics, ByVal forceDraw As Boolean)         Dim hourLength As Single = FaceRadius * 0.5F         Dim minuteLength As Single = FaceRadius * 0.7F         Dim secondLength As Single = FaceRadius * 0.9F         Dim hourPen As New Pen(BackColor)         Dim minutePen As New Pen(BackColor)         Dim secondPen As New Pen(BackColor)         hourPen.EndCap = Drawing2D.LineCap.ArrowAnchor         minutePen.EndCap = Drawing2D.LineCap.ArrowAnchor         hourPen.Width = 30         minutePen.Width = 20         Dim secondBrush As SolidBrush = New SolidBrush(Color.Green)         Dim blankBrush As SolidBrush = New SolidBrush(BackColor)         Dim rotation As Single         Dim state As Drawing2D.GraphicsState         Dim newTime As DateTime = DateTime.Now         Dim newMin As Boolean = False         If newTime.Minute <> currentTime.Minute Then             newMin = True         End If         rotation = GetSecondRotation(  )         state = g.Save(  )         g.RotateTransform(rotation)         g.FillEllipse(blankBrush, -25, -secondLength, 50, 50)         g.Restore(state)         If newMin Or forceDraw Then             rotation = GetMinuteRotation(  )             state = g.Save(  )             g.RotateTransform(rotation)             g.DrawLine(minutePen, 0, 0, 0, -minuteLength)             g.Restore(state)             rotation = GetHourRotation(  )             state = g.Save(  )             g.RotateTransform(rotation)             g.DrawLine(hourPen, 0, 0, 0, -hourLength)             g.Restore(state)         End If         currentTime = newTime         hourPen.Color = Color.Red         minutePen.Color = Color.Blue         secondPen.Color = Color.Green         state = g.Save(  )         rotation = GetSecondRotation(  )         g.RotateTransform(rotation)         g.FillEllipse(secondBrush, -25, -secondLength, 50, 50)         g.Restore(state)         If newMin Or forceDraw Then             state = g.Save(  )             rotation = GetMinuteRotation(  )             g.RotateTransform(rotation)             g.DrawLine(minutePen, 0, 0, 0, -minuteLength)             g.Restore(state)             state = g.Save(  )             rotation = GetHourRotation(  )             g.RotateTransform(rotation)             g.DrawLine(hourPen, 0, 0, 0, -hourLength)             g.Restore(state)         End If     End Sub 'DrawTime 

In the DrawTime method, you first delete the hands from their current positions, then draw them in their new positions. You draw the hands as lines, and put an arrow at the end of the line to simulate an old-fashioned clock's hand. Deleting the hands is accomplished by drawing the hands with a brush set to the color of the background (thus making them invisible).

5.2.2.3. Drawing the hands

You draw the hands of the clock with a set of Pen objects:

 Dim hourPen As New Pen(BackColor) Dim minutePen As New Pen(BackColor) Dim secondPen As New Pen(BackColor) 

The length of the pens is set based on the size of the clock itself, with the hour hand shorter than the minute hand, and the second dot moving at the outer edge of the clock face (just inside the numbers):

 Dim hourLength As Single = FaceRadius * 0.5F Dim minuteLength As Single = FaceRadius * 0.7F Dim secondLength As Single = FaceRadius * 0.9F 

The F's in 0.5F, 0.7F, and 0.9F force the values to be treated as Singles rather than Doubles.

The hour and minute hands will have arrows on their ends, like an old-fashioned clock. You accomplish that by setting the pen's EndCap property to ArrowAnchor. This is a value defined in the LineCap enumeration of the Drawing2D namespace.

 hourPen.EndCap = Drawing2D.LineCap.ArrowAnchor minutePen.EndCap = Drawing2D.LineCap.ArrowAnchor 

Having computed the length for the hands, you must set the width of the line that will be drawn, by setting properties on the pen:

 hourPen.Width = 30 minutePen.Width = 20 

You now need two brushes for the second hand, one to erase (using the BackColor) and one to draw the second hand (dot) as green:

 Dim secondBrush As SolidBrush = New SolidBrush(Color.Green) Dim blankBrush As SolidBrush = New SolidBrush(BackColor) 

With the pens created, you are ready to draw the hands, but you must determine where to position the lines for the hour and minute hands, and where to put the second-hand dot. And here you're going to use an interesting approach. Rather than computing the x,y location of the second hand, you will assume that the second hand is always at 12 o'clock. How can this work? The answer is to rotate the world around the center of the clock face.

Picture a simple clock face, with an x,y grid superimposed on it, as shown in Figure 5-7.

One way to draw a second hand at 2 o'clock is to compute the x,y coordinates of 2 o'clock (as you did when drawing the clock face). An alternative approach is to

Figure 5-7. Drawing the clock face


rotate the clock the appropriate number of degrees, and then draw the second hand straight up, which is what you'll do now.

Picture the clock face and a ruler, as shown in Figure 5-8. You can move the ruler to the right angle, or you can keep the ruler straight up and down, and rotate the clock face under it.

To keep your code clean, you'll factor out the computation of how much to rotate the clock into a helper method, ClockFaceCtrl.GetSecondRotation, which will return a Single.

 Private Function GetSecondRotation(  ) As Single     Return 360.0F * currentTime.Second / 60.0F End Function 'GetSecondRotation 

GetSecondRotation uses the currentTime member field. You multiply the current second by 360.0F (360 degrees in a circle), then divide by 60.0F (60 seconds per minute). For example, at 15 seconds past the minute, GetSecondRotation will return 90, because 360 * 15 / 60 = 90.

Figure 5-8. Paper and ruler


5.2.2.4. RotateTransform ( )

You now know how much you want to rotate the world (i.e., rotate the paper under the ruler), so you can erase the second hand (by drawing an ellipse over it using the background color). The steps you will take will be:

  1. Save the current state of the Graphics object.

  2. Rotate the world.

  3. Draw the second hand.

  4. Restore the state of the Graphics object.

It is as if you spin your paper, draw the dot, and then spin it back to the way it was. The code snippet to accomplish this is:

 rotation = GetSecondRotation(  ) Dim state As Drawing2D.GraphicsState = g.Save(  ) g.RotateTransform(rotation) '' erase the second hand dot here g.Restore(state) 

The transform method for rotating the world is called RotateTransform( ), and it takes a single argument: the number of degrees to rotate.

5.2.2.5. FillEllipse

The Graphics Object method you'll use to draw the dot that will "erase" the existing second-hand dot is FillEllipse. This method is overloaded; the version you will use takes five parameters:

  • The brush that will determine the color and texture of the ellipse

  • The x coordinate of the upper lefthand corner of the bounding rectangle

  • The y coordinate of the upper lefthand corner of the bounding rectangle

  • The width of the bounding rectangle

  • The height of the bounding rectangle:

     g.FillEllipse(blankBrush, -25, -secondLength, 50, 50) 

You pass in blankBrush (later you'll pass in secondBrush to draw the ellipse in its new position). Thus, when you are deleting the second hand, blankBrush will be set to the background color. When you are drawing it, secondBrush will be set to green.

The x and y coordinates of the second hand will be determined so that the second hand is straight up from the origin, centered on the y-axis (remember, you've turned the paper under the ruler, you now want to draw along the ruler).

The y coordinate is easy: you'll use the constant you've defined for the length of the second hand. Remember, however, that in this world, the y coordinates are negative above the origin, and since you want to draw straight up to 12 o'clock you must use a negative value.

The x coordinate is just a bit trickier. The premise was that you'd just draw straight up, along the y axis. Unfortunately, this will place the upper lefthand corner of the bounding rectangle along the y-axis, and what you want is to center the ellipse on the y-axis. You thus pass an x coordinate that is half of the size of the bounding rectangle (e.g., 25), and you set that to negative, so that the ball will be centered right on the y-axis.

Since you want your ellipse to be circular[*], the bounding rectangle will be square, with each side set to 50.

[*] A circle is just a special kind of ellipse.

Having drawn the second hand, you go on to draw the hour and minute hands. If you redraw them both every second, however, the clock face flickers annoyingly. You will therefore only redraw these two hands if the minute has changed. To test this, you will compare the new time with the old time and determine if the minute value has changed:

 If newTime.Minute <> currentTime.Minute Then     newMin = True End If 

If the time has changed or if you are in a situation where drawing is forced (e.g., the user has moved or resized the control), then you will redraw the hour and minute hands.

 If newMin Or forceDraw Then '' draw minute and hour End If 

Notice that the If statement tests that either the minute has changed or the forceDraw parameter passed into the DrawTime method is TRue. This allows ClockFaceCtrl_Paint to redraw the hands on a repaint by just setting bForceDraw to true, as shown in Example 5-13.

Example 5-13. ClockFaceCtrl_Paint method
 Private Sub ClockFaceCtrl_Paint(ByVal sender As System.Object, _            ByVal e As System.Windows.Forms.PaintEventArgs) _        Handles MyBase.Paint     bForceDraw = True End Sub 

(Go ahead and add this Paint event handler to the ClockFaceCtrl class.)

The implementation of drawing the hour and minute hands is nearly identical to that for drawing the second hand. This time, however, rather than drawing an ellipse, you actually draw a line. You do so with the DrawLine method of the Graphics object, passing in a pen and four integer values.

The first two values represent the x,y coordinates of the origin of the line, and the second set of two values represent the x,y coordinates of the end of the line. In each case, the origin of the line will be the center of the clock face, 0,0. The x coordinate of the end of the line will be 0, because you'll be drawing along the y-axis. The y-coordinate of the end of the line will be the length of the hour hand. Once again, because the y coordinates are negative above the origin, you'll pass this as a negative number.

For this to work, you must rotate the clock to the appropriate positions for the hour and the minute hand, which you do with the helper methods GetMinuteRotation and GetHourRotation, respectively. GetMinuteRotation is very similar to GetSecondRotation.

 Private Function GetMinuteRotation(  ) As Single      Return 360.0F * currentTime.Minute / 60.0F End Function 'GetMinuteRotation 

GetHourRotation is made more complicated only because you may have a 12-hour clock or a 24-hour clock. With the former, six o'clock is halfway around the circle, while with the latter it is only one quarter of the way around. In addition, the hour hand moves between the hours based on how many minutes it is past the hour. Code for the method is shown in Example 5-14.

Example 5-14. GetHourRotation method
 Private Function GetHourRotation(  ) As Single     ' degrees depend on 24 vs. 12 hour clock     Dim deg As Single     Dim numHours As Single     If b24Hours Then         deg = 15         numHours = 24     Else         deg = 30         numHours = 12     End If     Return 360.0F * currentTime.Hour / numHours + _            deg * currentTime.Minute / 60.0F End Function 'GetHourRotation 

After the three hands are erased by redrawing them with the background color, the currentTime member variable is updated with the new time (newTime), and the second, minute, and hour hands are redrawn with the appropriate colors.

 currentTime = newTime hourPen.Color = Color.Red minutePen.Color = Color.Blue secondPen.Color = Color.Green 

5.2.2.6. Refactor

Notice that the code for erasing the seconds, minute, and hour are repeated within DrawTime (see Example 5-12). It just pains me to write the same code in more than one place, so let's factor the common code into two helper methods: DoDrawSecond and DoDrawTime. The job of DoDrawSecond is to draw the second ellipse with whatever brush it is given. Code for the method is shown in Example 5-15.

Example 5-15. DoDrawSecond method
 Private Sub DoDrawSecond( _      ByVal g As Graphics, _      ByVal secondBrush As SolidBrush)     Dim secondLength As Single = FaceRadius * 0.9F     Dim state As Drawing2D.GraphicsState = g.Save(  )     Dim rotation As Single = GetSecondRotation(  )     g.RotateTransform(rotation)     g.FillEllipse(secondBrush, -25, -secondLength, 50, 50)     g.Restore(state) End Sub 

The first time this is called, secondBrush will represent a brush with the background color. On the second call, it will be a green brush.

The DoDrawTime method works much the same way, but its job is to first erase, and then to draw, the hour and minute hands, as shown in Example 5-16.

Example 5-16. DoDrawTime method
 Private Sub DoDrawTime( _      ByVal g As Graphics, _      ByVal hourPen As Pen, _      ByVal minutePen As Pen)     Dim minuteLength As Single = FaceRadius * 0.7F     Dim state As Drawing2D.GraphicsState = g.Save(  )     Dim rotation As Single = GetMinuteRotation(  )     g.RotateTransform(rotation)     g.DrawLine(minutePen, 0, 0, 0, -minuteLength)     g.Restore(state)     Dim hourLength As Single = FaceRadius * 0.5F     state = g.Save(  )     rotation = GetHourRotation(  )     g.RotateTransform(rotation)     g.DrawLine(hourPen, 0, 0, 0, -hourLength)     g.Restore(state) End Sub 

Factoring out this code allows us to greatly simplify the DrawTime method, whose complete code can now be shown in Example 5-17.

Example 5-17. DrawTime method
 Private Sub DrawTime( _      ByVal g As Graphics, ByVal forceDraw As Boolean)     ' hold the old time     Dim oldTime As DateTime = currentTime     Dim secondBrush As SolidBrush = New SolidBrush(Color.Green)     Dim blankBrush As SolidBrush = New SolidBrush(BackColor)     DoDrawSecond(g, New SolidBrush(BackColor))     Dim newTime As DateTime = DateTime.Now     currentTime = newTime   ' set the new time and update the seconds     DoDrawSecond(g, New SolidBrush(Color.Green))     ' if we've advanced a minute     If newTime.Minute <> oldTime.Minute Or forceDraw Then         currentTime = oldTime ' to erase         Dim hourPen As New Pen(BackColor)         Dim minutePen As New Pen(BackColor)         hourPen.EndCap = Drawing2D.LineCap.ArrowAnchor         minutePen.EndCap = Drawing2D.LineCap.ArrowAnchor         hourPen.Width = 30         minutePen.Width = 20         DoDrawTime(g, hourPen, minutePen) ' erase         currentTime = newTime   ' to draw new time         hourPen.Color = Color.Red         minutePen.Color = Color.Blue         DoDrawTime(g, hourPen, minutePen) ' redraw     End If End Sub 'DrawTime 

Your custom control is now complete. Go back to ClockFaceCtrl.OnTimer and uncomment the call to DrawTime. Rebuild and run the application. You should see something like Figure 5-9.

Figure 5-9. Complete ClockFaceCtrl


5.2.3. Switching from 12-Hour to 24-Hour Display

You've placed a button on the frmClock screen, but so far you haven't done anything with it. The control has a Boolean that tells it which clock face to draw (b24Hours). So the form can get and set that value, the ClockFaceCtrl class will need to expose a public property, TwentyFourHours , as coded in Example 5-18.

Example 5-18. TwentyFourHours property
 Public Property TwentyFourHours(  ) As Boolean     Get         Return b24Hours     End Get     Set(ByVal Value As Boolean)         b24Hours = Value         Me.Invalidate(  )     End Set End Property 

Notice that the Set accessor not only sets the Boolean value, but it invalidates the control, causing it to be redrawn with the appropriate clock face.

Your only remaining task is to get the 24-hour button to work. First, open frmClock.vb in Design view and change the button's Text to "24 Hours." Then double-click on the button to add a Click event handler. Implement it as shown in Example 5-19.

Example 5-19. 24-hour button Click event handler
 Private Sub btn1224_Click( _      ByVal sender As System.Object, _      ByVal e As System.EventArgs) _        Handles btn1224.Click     If Me.ClockFaceCtrl1.TwentyFourHours = True Then         Me.btn1224.Text = "24 Hours"         Me.ClockFaceCtrl1.TwentyFourHours = False     Else         Me.btn1224.Text = "12 Hours"         Me.ClockFaceCtrl1.TwentyFourHours = True     End If End Sub 

Run the application and click on the 24-hour button. The clock changes to 24-hour display, and the button changes to say "12 Hours," as shown in Figure 5-10.

Figure 5-10. Clock Control in 24-hour mode (2:32:06 p.m. Feb. 8, 2005)




Programming Visual Basic 2005
Programming Visual Basic 2005
ISBN: 0596009496
EAN: 2147483647
Year: 2006
Pages: 162
Authors: Jesse Liberty

flylib.com © 2008-2017.
If you may any questions please contact us: flylib@qtcs.net