[ Team LiB ] |
5.2 Custom ControlsIf your application needs a UI component whose behavior is sufficiently different from any of the built-in controls, it usually makes sense to write a special-purpose control for the job. And although writing a control from scratch is slightly harder work than just reusing existing controls, it is normally more straightforward than trying to bend an unsuitable control to meet your needs. Custom controls derive directly from Control. This means that you're not really starting from scratch at all—your class will automatically have all the functionality that is common to all controls. But there are two areas in which you are on your own: your control's appearance and the way it handles input from the user. With a custom control, you are given a blank slate. It is your responsibility to determine the control's appearance. In fact, the main reason for creating a custom control is often that none of the built-in ones looks right for the application. So we will now see how your control can draw itself, and we will then look at how to deal with input from the user. 5.2.1 RedrawingWhen your control first becomes visible at runtime, Windows Forms will ask it to draw itself. It does this by calling your control's OnPaint method. This method is defined by the Control class, but its implementation doesn't draw anything. The built-in control types supply an implementation of OnPaint for you, but with a custom control, it is your job to override this and draw the control as you see fit.
If you add a custom control to a project using Visual Studio .NET, you will see that it provides you with a skeleton class definition consisting of a constructor and an OnPaint method like that shown for C# in Example 5-1[4] and for VB in Example 5-2. Note that it has added a call to the base class's OnPaint method. You are required to do this whenever you override any of the Control class's methods that begin with On. These methods all correspond to events (so there is an OnClick and an OnLayout method, for example). The framework always raises an event by calling the associated OnXxx method, which gives the control class the chance to process that event before any event handlers are called. If you failed to call the base class's method after having overridden it, the event would never be raised—it is the Control.OnXxx methods that are responsible for calling any event handlers that clients of this control may have attached. They also very often do other work too, so you must always make sure you call them when you override such a method.
Example 5-1. A skeleton OnPaint function in C#protected override void OnPaint(PaintEventArgs pe) { // TODO: Add custom paint code here // Calling the base class OnPaint base.OnPaint(pe); } Example 5-2. A skeleton OnPaint function in VBProtected Overrides Sub OnPaint( _ ByVal pe As System.Windows.Forms.PaintEventArgs) MyBase.OnPaint(pe) 'Add your custom paint code here End Sub The existence of an OnPaint method implies that all controls raise a Paint event. This is indeed the case, and by handling that event, you can actually modify the appearance of other controls by drawing over them. (However, some controls deliberately hide the Paint event in the Forms Designer. This is usually because they are doing something unusual that will cause handling of the Paint method not to have the anticipated effects. For example, if you set the Button control's FlatStyle property to FlatStyle.System, it will let the operating system draw the button. This is not likely to interact well with any drawing done in the button's Paint event handler.) But in a custom control, there is no need to add an event handler for the Paint event—we simply take the direct route and override OnPaint. Notice that this method is passed an object of type PaintEventArgs, as shown in Examples Example 5-1 and Example 5-2. This provides us with two properties: Graphics and ClipRectangle. The ClipRectangle property returns an object of type Rectangle that tells us exactly which part of the control must be redrawn. We may well not be required to draw the entire control—possibly a window that is on top of our control has been moved slightly, causing a small, previously hidden portion to come into view. We would be wasting our time if we attempted to draw parts that didn't need redrawing. There's no actual harm in drawing too much—Windows Forms clips whatever we draw to the part that actually needs redrawing—but if your control's appearance is complex, it will speed things up if you use the ClipRectangle property to work out which parts you don't need to redraw.
The most important feature of the PaintEventArgs object is the Graphics property, whose type is a class also called Graphics. This object is our entry point into GDI+, the part of the .NET Framework class libraries dedicated to drawing. It is the subject of Chapter 7, so we will not go into much detail here. For the purposes of illustrating how to implement the OnPaint method, it is sufficient to know that the Graphics object provides various methods for drawing shapes and text onto the screen. Example 5-3 shows a simple implementation of OnPaint in C# that draws a basic table. Example 5-4 shows the equivalent code in VB. (Figure 5-4 shows how this control will look in the Designer when it is used on a form.) The core of this method is the loop that prints out each table entry to the screen using the Graphics object's DrawString method. The DrawString method will be discussed in detail in Chapter 7, along with the other GDI+ features. Example 5-3. Drawing a simple table in C#protected override void OnPaint(PaintEventArgs pe) { const int tableEntries = 10; const int entryHeight = 12; using (Brush b = new SolidBrush(ForeColor)) { for (int i = 0; i < tableEntries; ++i) { string s = string.Format("Table entry {0}", i+1); Point position = new Point(0, i * entryHeight); pe.Graphics.DrawString(s, Font, b, position); } } // Calling the base class OnPaint base.OnPaint(pe); } Example 5-4. Drawing a simple table in VBProtected Overrides Sub OnPaint( _ ByVal pe As System.Windows.Forms.PaintEventArgs) Const tableEntries As Integer = 10 Const entryHeight As Integer = 12 Dim b As Brush = New SolidBrush(ForeColor) Try Dim i As Integer Dim s As String Dim position As New PointF() For i = 0 To tableEntries - 1 s = String.Format("Table entry {0}", i + 1) position.X = 0 position.Y = i * entryHeight pe.Graphics.DrawString(s, Font, b, position) Next Finally b.Dispose() End Try ' Calling the base class OnPaint MyBase.OnPaint(pe) End Sub The code in Examples Example 5-3 and Example 5-4 also highlights a very important feature common to most OnPaint methods—it honors the settings of certain properties on the control. The Control class provides a Font property, and because we are displaying text, we pass the Font object returned by that property to the DrawString method. So if the user modifies our Font object, we will draw with whatever font she has specified; otherwise we will use the ambient font, as determined for us by the Control class. Likewise, we have used the Color object returned by the ForeColor property, also supplied by Control (and also an ambient property) to determine the color in which the text should be drawn. (The use of the Brush class will be discussed in Chapter 7, although you will recognize the using construct from Chapter 3 in the C# code in Example 5-3—this ensures that the Brush object's resources are released as soon as we have finished drawing. Because VB does not provide an equivalent to the using construct, we've had to add a call to the Dispose method ourselves.) Figure 5-4. A custom control in useFigure 5-4 raises a interesting point. We are looking at the Forms Designer here, as the grid points and selection outline make clear. But our control is displayed correctly, which implies that its OnPaint method must have been called—there is no other way that the table could have appeared. This is an important feature to understand about the Designer—it creates instances of your controls' classes at design time, and will call certain methods on them, such as OnPaint. This is why your controls must have been built successfully before they can be used in the Designer—if they haven't been built, they certainly can't be loaded or have their methods run. Even if they have been built without error, certain methods (such as OnPaint) must execute correctly for the control to work properly in the Designer. Chapter 9 will talk about the design-time environment in depth. If you are trying out code in Visual Studio .NET as you read this book, you may have hit a problem at this point. It is not entirely obvious how you use a custom control from the Forms Designer. Although the development environment is smart enough to detect when you have added a UserControl to your project, and adds it to the Toolbox automatically, it doesn't do this for custom controls. You have to right-click on the Toolbox, select Customize Toolbox . . . , choose the .NET Framework Components tab, and browse for the DLL containing your control. Once you have added the DLL, the Toolbox will show any custom controls that it contains. Drawing your components is essentially straightforward: override OnPaint and use GDI+ to paint your control. Because GDI+ is dealt with in Chapter 7, we will now move on to dealing with user input. 5.2.2 Handling InputThe custom control in the previous section is inert—it will always look the same and will not respond to any user input. Some of the built-in controls are like this; for example, PictureBox just displays an image. But most of your controls will need to deal with mouse or keyboard input, and they may need to modify their appearance in response to this input. 5.2.2.1 Mouse inputYour control could simply attach event handlers to itself at runtime—because it derives from Control, all the standard events described in Chapter 2 are available. However, there is a much more direct way of receiving events. In the previous section, instead of attaching an event handler to the Paint event, we simply overrode the OnPaint method. We can do the same thing for input handling. For example, instead of handling the MouseDown and MouseUp events, we can simply override their counterparts,[5] as shown in Example 5-5 in C# and Example 5-6 in VB. Overriding these OnXxx methods is the preferred approach when writing your own controls because it is more efficient than attaching event handlers, and lets you determine whether your code runs before or after any attached event handlers.
Example 5-5. Handling mouse events in a custom control in C#private bool pressed = false; protected override void OnMouseDown(MouseEventArgs e) { if (e.Button == MouseButtons.Left) { pressed = true; Invalidate(); } base.OnMouseDown(e); } protected override void OnMouseUp(MouseEventArgs e) { if (e.Button == MouseButtons.Left) { pressed = false; Invalidate(); } base.OnMouseUp(e); } Example 5-6. Handling mouse events in a custom control in VBProtected Overrides Sub OnMouseDown(ByVal e As MouseEventArgs) If e.Button = MouseButtons.Left Then pressed = True Invalidate() End If MyBase.OnMouseDown(e) End Sub Protected Overrides Sub OnMouseUp(ByVal e As MouseEventArgs) If e.Button = MouseButtons.Left Then pressed = False Invalidate() End If MyBase.OnMouseUp(e) End Sub Examples Example 5-5 and Example 5-6 illustrate three important techniques. First, notice that the handlers call the base class's OnMouseUp and OnMouseDown methods—this is mandatory in all such overrides. Second, the control is maintaining some internal state that is modified by the user's input—the pressed field will be true whenever the mouse's left button is held down over the control, and false when it is not. (Remember that Windows Forms automatically captures the mouse when a button is pressed while over a control. So our OnMouseUp method will always be called even if the mouse moves away from our control after OnMouseDown was called.) The final point to note is that the methods call Invalidate once they have changed the control's state. This is a method of the Control class that tells the framework that what is currently on screen is no longer a valid representation of the state of the object. This will cause the framework to redraw the control. You must do this whenever you change any of the data that the control uses to determine how to draw itself. Examples Example 5-7 and Example 5-8 show a simple OnPaint method in C# and VB, respectively, that makes the control's appearance reflect its internal state. Most of the time, this will just draw the text normally, but when the left button is held down (i.e., pressed is true), it inverts the color of the control by filling the control's area with a rectangle using the control's foreground color, and then drawing the text over it in the background color. Example 5-7. Representing internal state through appearance in C#protected override void OnPaint(PaintEventArgs pe) { Graphics g = pe.Graphics; using (Brush fore = new SolidBrush(ForeColor), back = new SolidBrush(BackColor)) { Brush rbrush = pressed ? fore : back; Brush tbrush = pressed ? back : fore; g.FillRectangle(rbrush, ClientRectangle); g.DrawString(Text, Font, tbrush, ClientRectangle); } // Calling the base class OnPaint base.OnPaint(pe); } Example 5-8. Representing internal state through appearance in VBProtected Overrides Sub OnPaint(ByVal pe As PaintEventArgs) Dim g As Graphics = pe.Graphics Dim fore As Brush = New SolidBrush(ForeColor) Dim back As Brush = New SolidBrush(BackColor) Try Dim rBrush, tBrush As Brush If pressed Then rBrush = fore tBrush = back Else rBrush = back tBrush = fore End If g.FillRectangle(rBrush, ClientRectangle) Dim crectf As New RectangleF(ClientRectangle.X, _ ClientRectangle.Y, _ ClientRectangle.Width, _ ClientRectangle.Height) g.DrawString(Text, Font, tBrush, crectf) Finally fore.Dispose() back.Dispose() End Try ' Calling the base class OnPaint MyBase.OnPaint(pe) End Sub The OnPaint method will be called by the framework whenever a redraw is required, because the mouse event handlers (shown in Examples Example 5-5 and Example 5-6) call Invalidate whenever they change the control's state. It is your responsibility to do this because the framework has no idea whether a change in your object's state will require the control to be redrawn. It only calls OnPaint when you tell it that what is currently on screen is no longer valid. This is a very simple example, but more complex custom controls work in much the same way. For example, you might write a custom control that displays an editable picture. Its OnPaint method would have a lot more work to do—it would need to iterate through all the items in the drawing and call the appropriate methods on the Graphics object to display them (probably using the ClipRectangle property in the PaintEventArgs object to determine which parts of the drawing don't need to be drawn). But the principle is still the same: OnPaint draws a representation of the control's internal state onto the screen, and whenever that state changes, it is your program's responsibility to notify the framework. Sometimes you will change the state in such a way that only a small part of the control's display needs redrawing. For example, if your control shows a table, and you change a single cell in that table, you wouldn't want to redraw the entire table. Because of this, the Invalidate method is overloaded, allowing you to be more selective. The version we used in Example 5-5 takes no parameters and invalidates the entire control, but you can pass parameters indicating which part has changed. For example, you can supply a Rectangle, indicating which area you would like to redraw. This can enable your control to update itself much more quickly, which can be particularly important if you are updating the display because of a drag operation—if the user is moving an item around with the mouse, you will want your control to repaint itself as responsively as possible; otherwise, the application will feel sluggish, and it will feel to the user as though the mouse has become bogged down in treacle. 5.2.2.2 Keyboard inputOf course, the mouse is not the only input device. Most controls that support mouse input will also want to allow themselves to be controlled with the keyboard, for ease of use and accessibility. By and large, this is fairly straightforward—you just override the appropriate methods, such as OnKeyPress. (This corresponds to the KeyPress event described in Chapter 2.) For controls that allow text to be typed in, there is an obvious interpretation for key presses—each letter that the user types will cause a character to appear on the screen. But for controls that don't need to support text entry, it may still be worth supporting keyboard input for accessibility. For example, most of the standard controls will behave as though you clicked on them if you press the spacebar while they have the focus. If you wish to do this, the way to fake a click event is to call the OnClick method yourself. Overriding this method in your control ensures that your control does the same thing as it would have done if it really had been clicked, and when the base class's OnClick method runs, it will raise the control's Click event. The Control class can handle certain standard types of input for you. For example, your control can automatically detect double-clicks and raise the DoubleClick event. However, you might not always want this behavior. Fortunately, it can be disabled—it is one of a number of standard control features that can be turned on and off using the SetStyle method. 5.2.3 Control StylesThe Control class provides a great deal of functionality. However, you won't necessarily want all the features switched on for all the controls that you define, so Windows Forms makes certain features optional. You select the features you require by setting control styles. The Control class provides a method called SetStyle that lets you turn styles on and off. You specify the styles that you wish to change with the ControlStyles enumeration. This is a flags-style enumeration, so you can pass any combination to SetStyle, along with a bool or Boolean indicating whether you are enabling or disabling the specified styles. This allows you to modify certain styles while leaving others unchanged. This is particularly useful when deriving from another control type—it means you do not need to determine the full set of styles it uses to change a single style. Examples Example 5-9 and Example 5-10 show how to modify a control's styles. Example 5-9. Modifying a control's styles in C#public MyControl() { SetStyle(ControlStyles.ResizeRedraw | ControlStyles.StandardClick, true); SetStyle(ControlStyles.StandardDoubleClick, false); } Example 5-10. Modifying a control's styles in VBPublic Sub New() SetStyle(ControlStyles.ResizeRedraw Or _ ControlStyles.StandardClick, True) SetStyle(ControlStyles.StandardDoubleClick, False) End Sub Examples Example 5-9 and Example 5-10 show the constructor of a control in C# and VB, respectively, that modifies the following features:
You can also determine whether the control can receive the focus by using the Selectable style. And you can prevent your control from being resized by setting the FixedHeight and FixedWidth styles. There are also several styles that are used to manage the way the control is redrawn; these are described in Chapter 7. The SetStyle method is protected. You cannot modify another control's styles—you are only allowed to set the styles on a control you have written yourself. This is because it is difficult for a developer to be sure of whether someone else's control is relying on a particular combination of styles. 5.2.4 Scrollable Custom ControlsOne of the main reasons for writing a custom control is to provide a visual representation of your application's data. In some applications, the amount of data to be displayed will not necessarily always fit in the space available, in which case it probably makes sense for your control to be scrollable. Both the Form and the UserControl classes can automatically provide scrollbars, but the basic Control class cannot. Fortunately, there is a simple solution to this: instead of inheriting directly from Control, you can inherit from ScrollableControl and set the AutoScrollMinSize property to the total scrollable size you require. You will not inherit any unwanted extra functionality—ScrollableControl itself inherits directly from Control. You will still be writing a custom control responsible for its own appearance and behavior; it will simply have the option to be scrollable. If you do this, it is your responsibility to take into account the current scroll position when redrawing. If you draw a string at position (0, 0) it will always be drawn at the top-left corner of the control, regardless of what the current scroll position is. Worse, when the user moves the scrollbar, the contents of your window are simply moved rather than redrawn with a call to OnPaint. Only the newly exposed part at the edge of the control will be redrawn. If you haven't taken the scroll offset into account, this leads to an inconsistent mess in the control. Fortunately, it is easy to adjust the drawing position according to the current scroll position. We don't have to offset all the coordinates ourselves because the Graphics class has a method for doing just this, as illustrated in the following code fragment: // C# code protected override void OnPaint(PaintEventArgs pe) { pe.Graphics.TranslateTransform( AutoScrollPosition.X, AutoScrollPosition.Y); . . . ' VB code Protected Overrides Sub OnPaint(pe As PaintEventArgs) pe.Graphics.TranslateTransform( _ AutoScrollPosition.X, AutoScrollPosition.Y) . . . Having done this, the Graphics object will automatically offset all the coordinates you supply. (AutoScrollPosition is a member of ScrollableControl, and its value describes the current scroll position.) Of course, if your mouse input handlers need to know the exact location that was clicked (e.g., your control displays a table and you need to calculate which row and column was clicked), you will have to apply the reverse transformation. The mouse events ignore the scroll position and supply you with coordinates relative to the top-left corner of the control. It is very easy to perform the reverse translation in C#: protected override void OnMouseDown(MouseEventArgs e) { Point mousePos = new Point(e.X, e.Y) - new Size(AutoScrollPosition); . . . } In VB, the code is a little more cumbersome: Protected Overrides Sub OnMouseDown( _ ByVal e As System.Windows.Forms.MouseEventArgs) Dim mousePos As New Point(e.X, e.Y) mousePos = Point.op_Subtraction(mousePos, _ New Size(AutoScrollPosition)) End Sub The Point and Size types are used to represent positions and two-dimensional sizes. (These types are discussed in more detail in Chapter 7.) They use operator overloading to allow a Point to be adjusted by a Size, which is why we are able to use the - sign in the C# code. Overloaded operators are translated by the .NET Common Language Runtime into calls to an op_operation method, which is why we are able to call the Point class's shared op_Subtraction method from VB. |
[ Team LiB ] |