In this section, I'll demonstrate how to build a graphical Arrow component. You can use such a component to indicate a flow of information or an action. This component is quite complex, so I'll show you the various steps instead of looking directly at the complete source code. The component I've added to the MdPack package is the final version of this process, which demonstrates several important concepts:
The definition of new enumerated properties, based on custom enumerated data types.
The implementation of the component's Paint method, which provides its user interface and should be generic enough to accommodate all the possible values of the various properties, including its Width and Height. The Paint method plays a substantial role in this graphical component.
The use of properties of TPersistent-derived classes, such as TPen and TBrush, as well as the issues related to their creation and destruction and to handling their OnChange events internally in your component.
The definition of a custom event handler for the component, which responds to user input (in this case, a double-click on the point of the arrow). This requires direct handling of Windows messages and the use of the Windows API for graphic regions.
The registration of properties in Object Inspector categories and the definition of a custom category.
After generating the new component with the Component Wizard and choosing TGraphicControl as the parent class, you can begin to customize the component. The arrow can point in any of four directions: up, down, left, or right. An enumerated type expresses these choices:
type TMdArrowDir = (adUp, adRight, adDown, adLeft);
This enumerated type defines a private data member of the component, a parameter of the procedure used to change it, and the type of the corresponding property.
The ArrowHeight property determines the size of the arrowhead, and the Filled property specifies whether to fill the arrowhead with color:
type TMdArrow = class (TGraphicControl) private FDirection: TMdArrowDir; FArrowHeight: Integer; FFilled: Boolean; procedure SetDirection (Value: TMd4ArrowDir); procedure SetArrowHeight (Value: Integer); procedure SetFilled (Value: Boolean); published property Width default 50; property Height default 20; property Direction: TMd4ArrowDir read FDirection write SetDirection default adRight; property ArrowHeight: Integer read FArrowHeight write SetArrowHeight default 10; property Filled: Boolean read FFilled write SetFilled default False;
Note |
A graphic control has no default size, so when you place it in a form, its size will be a single pixel. For this reason, it is important to add a default value for the Width and Height properties and set the class fields to the default property values in the class constructor. |
The three custom properties are read directly from the corresponding field and are written using three Set methods, all having the same standard structure:
procedure TMdArrow.SetDirection (Value: TMdArrowDir); begin if FDirection <> Value then begin FDirection := Value; ComputePoints; Invalidate; end; end;
Notice that you ask the system to repaint the component (by calling Invalidate) only if the property is really changing its value and after calling the ComputePoints method, which computes the triangle delimiting the arrowhead. Otherwise, the code is skipped and the method ends immediately. This code structure is common, and you will use it for most of the Set procedures of properties.
You must also remember to set the properties' default values in the component's constructor:
constructor TMdArrow.Create (AOwner: TComponent); begin // call the parent constructor inherited Create (AOwner); // set the default values FDirection := adRight; Width := 50; Height := 20; FArrowHeight := 10; FFilled := False;
As mentioned before, the default value specified in the property declaration is used only to determine whether to save the property's value to disk. The Create constructor is defined in the public section of the new component's type definition, and the constructor is marked by the override keyword, as it replaces the virtual Create constructor of TComponent. It is fundamental to remember the override specifier; otherwise, when Delphi creates a new component of this class, it will call the base class's constructor, rather than the one you've written for your derived class.
Drawing the arrow in the various directions and with the various styles requires a fair amount of code. To perform custom painting, you override the Paint method and use the protected Canvas property.
Instead of computing the position of the arrowhead points in drawing code that will be executed often, I've written a separate function to compute the arrowhead area and store it in an array of points defined among the private fields of the component:
FArrowPoints: array [0..3] of TPoint;
These points are determined by the ComputePoints private method, which is called every time a component property changes. Here is an excerpt of its code:
procedure TMdArrow.ComputePoints; var XCenter, YCenter: Integer; begin // compute the points of the arrowhead YCenter := (Height - 1) div 2; XCenter := (Width - 1) div 2; case FDirection of adUp: begin FArrowPoints [0] := Point (0, FArrowHeight); FArrowPoints [1] := Point (XCenter, 0); FArrowPoints [2] := Point (Width-1, FArrowHeight); end; // and so on for the other directions
The code computes the center of the component area (dividing the Height and Width properties by two) and then uses the center to determine the position of the arrowhead. In addition to changing the direction or other properties, you need to refresh the position of the arrowhead when the size of the component changes. You can override the SetBounds method of the component, which is called by VCL every time the Left, Top, Width, and Height properties of a component change:
procedure TMdArrow.SetBounds(ALeft, ATop, AWidth, AHeight: Integer); begin inherited SetBounds (ALeft, ATop, AWidth, AHeight); ComputePoints; end;
Once the component knows the position of the arrowhead, its painting code becomes simpler. Here is an excerpt of the Paint method:
procedure TMdArrow.Paint; var XCenter, YCenter: Integer; begin // compute the center YCenter := (Height - 1) div 2; XCenter := (Width - 1) div 2; // draw the arrow line case FDirection of adUp: begin Canvas.MoveTo (XCenter, Height-1); Canvas.LineTo (XCenter, FArrowHeight); end; // and so on for the other directions end; // draw the arrow point, eventually filling it if FFilled then Canvas.Polygon (FArrowPoints) else Canvas.PolyLine (FArrowPoints); end;
You can see an example of the output of this component in Figure 9.6.
To make the output of the component more flexible, I've added to it two new properties, Pen and Brush, defined with a class type (a TPersistent data type, which defines objects that Delphi can automatically stream). These properties are a little more complex to handle, because the component now has to create and destroy these internal objects. This time, however, you also export the internal objects using properties, so that users can directly change these internal objects from the Object Inspector. To update the component when these subobjects change, you'll also need to handle their internal OnChange property. Here is the definition of the Pen property and the other changes to the definition of the component class (the code for the Brush property is similar):
type TMdArrow = class (TGraphicControl) private FPen: TPen; ... procedure SetPen (Value: TPen); procedure RepaintRequest (Sender: TObject); published property Pen: TPen read FPen write SetPen; end;
You first create the object in the constructor and set its OnChange event handler:
constructor TMdArrow.Create (AOwner: TComponent); begin ... // create the pen and the brush FPen := TPen.Create; // set a handler for the OnChange event FPen.OnChange := RepaintRequest; end;
These OnChange events are fired when one of the properties of the pen changes; all you have to do is to ask the system to repaint your component:
procedure TMdArrow.RepaintRequest (Sender: TObject); begin Invalidate; end;
You must also add a destructor to the component, to remove the graphical object from memory (and free its system resources). All the destructor has to do is call the Pen object's Free method.
A property related to persistent objects requires special handling: Instead of copying the pointer to the object, you have to copy the internal data of the object passed as a parameter. The standard := operation copies the pointer, so in this case you have to use the Assign method:
procedureTMdArrow.SetPen (Value: TPen); begin FPen.Assign(Value); Invalidate; end;
Many TPersistent classes have an Assign method you should use when you need to update the data of these objects. Now, to use the pen for the drawing, you must modify the Paint method, setting the corresponding property of the component Canvas to the value of the internal object before drawing a line (see the example of the component's new output in Figure 9.7):
procedure TMdArrow.Paint; begin // use the current pen Canvas.Pen := FPen;
As the Canvas uses a setter routine to Assign the pen object, you're not simply storing a reference to the pen in a field of the Canvas, but you are copying all of its data. This means that you can freely destroy the local Pen object (FPen) and that modifying FPen won't affect the canvas until Paint is called and the code above is executed again.
To complete the development of the Arrow component, let's add a custom event. Most of the time, new components use the events of their parent classes. For example, in this component, I've made some standard events available by redeclaring them in the published section of the class:
type TMdArrow = class (TGraphicControl) published property OnClick; property OnDragDrop; property OnDragOver; property OnEndDrag;
Thanks to this declaration, the events (originally declared in a parent class) will be available in the Object Inspector when the component is installed.
Sometimes, however, a component requires a custom event. To define a new event, you first need to ensure that there is already a method pointer type suitable for use by the event; if not, you need to define a new event type. This type is a method pointer type (see Chapter 5, "Visual Controls," for details). In both cases, you need to add to the class a field of the event's type: here is the definition I've added in the private section of the TMdArrow class:
FArrowDblClick: TNotifyEvent;
I've used the TNotifyEvent type, which has only a Sender parameter and is used by Delphi for many events, including OnClick and OnDblClick events. Using this field I've defined a published property, with direct access to the field:
property OnArrowDblClick: TNotifyEvent read FArrowDblClick write FArrowDblClick;
(Notice again the standard naming convention, with event names starting with On.) The fArrowDblClick method pointer is activated (executing the corresponding function) inside the specific ArrowDblClick dynamic method. This happens only if an event handler has been specified in the program that uses the component:
procedure TMdArrow.ArrowDblClick; begin if Assigned (FArrowDblClick) then FArrowDblClick (Self); end;
Tip |
The use of Self as parameter of the invocation of the event handler method ensures that when the method is called its Sender parameter would actually refer to the object that fired the event, which you generally expect as a component user. |
The fArrowDblClick method is defined in the protected section of the type definition to allow future descendant classes to both call and change it. Basically, this method is called by the handler of the
wm_LButtonDblClk Windows message, but only if the double-click took place inside the arrow's point. To test this condition, you can use some of the Windows API's region functions.
Note |
A region is a screen area enclosed by any shape. For example, you can build a polygonal region using the three vertices of the arrow-point triangle. The only problem is that to fill the surface properly, you must define an array of TPoints in a clockwise direction (see the description of the CreatePolygonalRgn in the Windows API Help for the details of this strange approach). That's what I did in the ComputePoints method. |
Once you have defined a region, you can use the PtInRegion API call to test whether the point where the double-click occurred is inside the region. The complete source code for this procedure is as follows:
procedure TMdArrow.WMLButtonDblClk ( var Msg: TWMLButtonDblClk); // message wm_LButtonDblClk; var HRegion: HRgn; begin // perform default handling inherited; // compute the arrowhead region HRegion := CreatePolygonRgn (FArrowPoints, 3, WINDING); try // check whether the click took place in the region if PtInRegion (HRegion, Msg.XPos, Msg.YPos) then ArrowDblClick; finally DeleteObject (HRegion); end; end;
The previous code won't be portable to Linux and makes no sense for the CLX/Qt version of the component. If you want to build a similar component for the CLX class library, you can replace the Win32 API calls with direct (low-level) calls to the Qt layer, creating an object of the QRegion class, as in the following listing:
procedure TMdArrow.DblClick; var HRegion: QRegionH; MousePoint: TPoint; begin // perform default handling inherited; // compute the arrow head region HRegion := QRegion_create (PPointArray(FArrowPoints), True); try // get the current mouse position GetCursorPos (MousePoint); MousePoint := ScreenToClient(MousePoint); // check whether the click took place in the region if QRegion_contains(HRegion, PPoint(@MousePoint)) then ArrowDblClick; finally QRegion_destroy(HRegion); end; end;
You've added to this component some custom properties and a new event. If you arrange the properties in the Object Inspector by category, all the new elements will appear in the generic Miscellaneous category. Of course, this is far from ideal, but you can easily register the new properties in one of the available categories.
You can register a property (or an event) in a category by calling one of the four overloaded versions of the RegisterPropertyInCategory function, defined in the DesignIntf unit. When calling this function, you indicate the name of the category, and you can specify the property name, its type, or the property name and the component it belongs to. For example, you can add the following lines to the Register procedure of the unit to register the OnArrowDblClick event in the Input category and the Filled property in the Visual category:
uses DesignIntf; procedure Register; begin RegisterPropertyInCategory ('Input', TMdArrow, 'OnArrowDblClick'); RegisterPropertyInCategory ('Visual', TMdArrow, 'Filled'); end;
The first parameter is a string indicating the category name—a much simpler solution than the original Delphi 5 approach of using category classes. You can define a new category in a straightforward manner by passing its name as the first parameter of the RegisterPropertyInCategory function:
RegisterPropertyInCategory ('Arrow', TMdArrow, 'Direction'); RegisterPropertyInCategory ('Arrow', TMdArrow, 'ArrowHeight');
Creating a new category for the specific properties of your component can make it much simpler for a user to locate its specific features. Notice, though, that because you rely on the DesignIntf unit, you should compile the unit containing these registrations in a design-time package, not a run-time package (the required DesignIde unit cannot be distributed). For this reason, I've written this code in a separate unit from the one defining the component and added the new unit (MdArrReg) to the package MdDesPk, including all the design-time-only units; this approach is discussed later, in the section "Installing the Property Editor."
Warning |
It's debatable whether using a category for the specific properties of a component is a good idea. On one hand, a user of the component can easily spot specific properties. At the same time, some of the new properties may not pertain to any of the existing categories. On the other hand, categories can be overused. If every component introduces new categories, users may be confused. You also face the risk of having as many categories as there are properties. |
Notice that my code registers the Filled property in two different categories. This is not a problem, because the same property can show up multiple times in the Object Inspector under different groups, as you can see in Figure 9.8. To test the arrow component, I've written the ArrowDemo program, which allows you to modify most of its properties at run time. This type of test is important after you have written a component or while you are writing it.
Note |
The Localizable property category has a special role related to the use of the ITE (Integrated Translation Environment). When a property is part of this category, its value is listed in the Translation Environment as a property that can be translated into another language. |