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
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 constructorThis 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( )
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 classNow 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 constructorPublic 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 classThe 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
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 methodPublic 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 methodPublic 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 methodPublic 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:
5.2.1.3. The DrawString.DrawTheString( ) methodAs 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 methodDim newX As Single = _ GetCos((angle * counter + _ 90 - ClockFaceCtrl.offset)) _ * ClockFaceCtrl.DateRadius
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 methodPrivate 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 methodPublic 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 faceAll 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 methodPublic 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.
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 dateAfter 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 FormBefore 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 date5.2.2.1. Drawing the numbersThe 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);
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 timeAfter 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 faceExample 5-12. DrawTime methodPrivate 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 handsYou 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 facerotate 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 ruler5.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:
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. FillEllipseThe 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:
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.
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 methodPrivate 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 methodPrivate 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. RefactorNotice 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 methodPrivate 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 methodPrivate 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 methodPrivate 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 ClockFaceCtrl5.2.3. Switching from 12-Hour to 24-Hour DisplayYou'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 propertyPublic 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 handlerPrivate 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) |