Component writing, part 3

This article is the final part of a three part article on components. This final part will cover property / component editors, how to write dedicated editors for your component / property, and how to write "hidden" components.


This article originally appeared in Delphi Developer

Copyright Pinnacle Publishing, Inc. All rights reserved.

Custom Component Editors
As soon as we start to write advanced property types for our components, life becomes a little more complicated. Although the object inspector built into Delphi is able to recognise most property types, it is impossible for it to be able to deal with every possible custom type we may write into our components. Sometimes the object inspector is able to deal with our custom types, but editing such a complex arrangement of properties in the object inspector is simply not intuitive enough. It is at this point we may be required to write property / component editors. Delphi has many predefined editors already, these editors are in the DsgnIntf.pas file in the $(Delphi)\Source\ToolsAPI directory. You will need to list this unit in the uses clause of any component editor / property editor you may write, it is also a good idea to keep this file open for reference when writing your own editors.

Coding standards
To start off with I will cover some coding standards that are used when writing component or property editors. There are only a few, but it would be a good idea to keep to these standards when writing your own editors as it makes it easier for other people to understand your work.

  • When creating a property editor, end the name of your editor with the word "Property"; for example TAngleProperty
  • When creating a component editor, end the name of your editor with the word "Editor"; for example TPieChartEditor
  • When writing editors, always write the editor in a separate unit to your actual component. It is good to separate the design time and runtime code and, apart from this, it makes your resulting EXE size smaller (on some versions of Delphi your component may stop applications from compiling if you do not separate them). Also, separate the packages so that your users can build with runtime packages if they want to !
  • Name your editor unit with the same name as your component unit, but append the word "reg" at the end; for example, a component with the unit name "MyComponent.pas" would result in the editor filename being "MyComponentreg.pas"
  • Finally, when writing a component editor / property editor for your component, move your RegisterComponents statement out of your component's unit, and into your component editor's unit. This way your component will not be registered without the editor also being registered.

The property editor
Property editors are used by the IDE to allow special editing of individual properties within a component. Some editors are very simple, some are much more complicated. Delphi already has a number of standard property editors, some of these are:

TIntegerProperty. Used for inputting integers.

TCharProperty. Used for inputting a single character.

TEnumProperty. Used for selected an individual element in an enumerated type (alTop, alClient etc). TBoolProperty. Used for selecting "True" or "False" for Boolean properties.

TFloatProperty. Used for inputting floating point numbers (Variable type Float / Extended etc. The "Real" type should not be used for component properties).

TStringProperty. Used for inputting strings up to a maximum of 255 characters.

TSetProperty. Used for including / excluding individual elements of a Set property. Each element is displayed as a Boolean sub-property. Setting the value to "True" includes the element, setting it to "False" excludes it.

TClassProperty. This is the base class to descend from when you want to create a custom editor to be invoked for properties of a certain class (when you have a class as a property, such as TImage.Picture).

All of these property editors descend directly or indirectly from TPropertyEditor. TPropertyEditor has many properties and methods, the most significant are.


    

function AllEqual: Boolean; virtual;
function GetAttributes: TPropertyAttributes; virtual;
procedure Edit; virtual;
function GetValue: string; virtual;
procedure GetValues(Proc: TGetStrProc); virtual;
    

AllEqual
When multiple components are selected the object inspector filters its list of properties to only the ones that all the selected components have in common. If the value in each component for any given property (eg Width) is the same, the value will be displayed, otherwise no value will be shown. AllEqual is the routine that determines if each value is identical.


    

function TStringProperty.AllEqual: Boolean;
var
  I: Integer;
  V: string;
begin
  Result := False;
  if PropCount > 1 then
  begin
    V := GetStrValue;
    for I := 1 to PropCount - 1 do
      if GetStrValueAt(I) <> V then Exit;
  end;
  Result := True;
end;
    

In the above example TStringProperty compares each value (using GetStrValueAt) with the value of the first component in the list (using GetStrValue, GetStrValueAt(0) would have done the same). The size of the list is determined by using PropCount, this returns the total amount of components selected.

GetAttributes
GetAttributes is called by the IDE when it needs to gather information about the property editor. The object inspector displays an appropriate editor based on the information supplied. The result of GetAttributes (TPropertyAttributes) is a set, so it may contain a combination of the following values (this is not a complete list)

paDialog
Tells the object inspector to show a [...] button after the property name, when the user clicks this button the Edit method is triggered.

paSubProperties
Tells the object inspector to show a [+] expand button before the property name, clicking this button will show an expanded list of sub properties (usually the published properties of a class property).

paValueList
The object inspector will show a combobox with a list of values, this list is determined by the IDE by calling the GetValues method.
NOTE: The GetValues method, not the GetValue method which is completely different

paSortList
If combined with paValueList, the values displayed will be sorted alphabetically.

paMultiSelect
This specifies to the IDE that the property is allowed to be displayed when multiple components are selected. This item is not present for editors such a TClassProperty.

paAutoUpdate
Causes the SetValue method to be called each time the value is altered within the object inspector, rather than waiting for the user to press or edit another property. This is used for "Caption" and "Text" properties, to give a live representation of the value the user is entering.

paReadOnly
If this element is included the value in the object inspector is read-only. This is typically used in conjunction with paDialog. GetValue would be overridden to return a descriptive representation of the property.

Edit
This method is called when the [...] button for the property is clicked. This button appears if the paDialog element is included within the result of GetAttributes.

GetValue
This method is called when the object inspector needs to know how to display the property as a string. This is typically used when [paDialog, paReadOnly] are specified within the result of GetAttributes.

GetValues
This method is called when the object inspector needs to retrieve a list of values to display when paValueList is specified within the result of GetAttributes. GetValues passes a parameter called "Proc" which is of type TGetStrProc. GetStrProc is declared as TGetStrProc = procedure(const S: string) of object;

The IDE expects "Proc" to be called once for every value that should be displayed in the object inspector for this property.


    

procedure THintProperty.GetValues(Proc: TGetStrProc);
begin
  Proc('First item to display');
  Proc('Second item to display');
end;
    

The following example shows how to provide a list of default values for the "Hint" property of all components, whilst still allowing the user to enter a value not in the list.


    

type
  THintProperty = class(TStringProperty)
  public
    function GetAttributes: TPropertyAttributes; override;
    procedure GetValues(Proc: TGetStrProc); override;
  end;

procedure Register;

implementation

procedure Register;
begin
  RegisterPropertyEditor(TypeInfo(String), nil, 'Hint', THintProperty);
end;

{ THintProperty }

function THintProperty.GetAttributes: TPropertyAttributes;
begin
  Result := inherited GetAttributes + [paValueList, paSortList];
end;

procedure THintProperty.GetValues(Proc: TGetStrProc);
begin
  Proc('This is a required entry');
  Proc('Press F1 for more information');
  Proc('This value is read-only');
end;
    

First GetAttributes is overridden, and [paValueList, paSortList] are included in the result. Next GetValues is overridden and three values are added to the drop down list by calling the "Proc" procedure.

Registering property editors
Finally the property editor is registered using RegisterPropertyEditor. RegisterPropertyEditor takes four parameters:

PropertyType: PTypeInfo
Requires a pointer to a TTypeInfo record. This sounds much more complicated than it really is, all we need to do is add TypInfo to our uses clause, and use the TypeInfo function to retrieve the pointer for us. TypeInfo(SomeVariableType)

ComponentClass: TClass
This is the base class that this editor should apply to. The editor will apply to this class and any classes that descend from it. If nil is specified, this editor will apply to any class.

const PropertyName: string
If this editor should only apply to a specific property then the name of the property should be specified here. If the editor should apply to any property of the type specified in PropertyType this value should be ''.

EditorClass: TPropertyEditorClass
This is the class that has been created to deal with the property. In the above example the class is THintProperty.

Using RegisterPropertyEditor incorrectly
It is important when using RegisterPropertyEditor that you supply the correct information. Supplying the incorrect information could mean either that your editor affects incorrect properties (eg All string properties) or incorrect components.

At the other extreme, setting the parameters incorrectly could mean that only a specific property in a specific component (and descendants) is associated with your editor. This does not seem like much of a problem at first, but descendant components may wish to implement additional properties of the same type. As these properties will obviously have a different name they will not have the correct property editor assigned to them.

An example of badly registered editor already exists within the VCL. The standard editor for TCollection was registered for all classes descended from TComponent. The problem is that the lowest class capable of being displayed in the object inspector is TPersistent (the class that TComponent descends from).

If a component has a property of type TPersistent (which by default exposes its sub-properties in an expandable list), and one of its properties is of type TCollection, the result is a [...] button in the object inspector that does nothing when clicked (as we saw in part two of this article series).

The solution to this problem seems quite simple. Rather than our sub-property being descended from TPersistent we could descend it from TComponent instead. However, the default behaviour for a property of type TComponent (As determined by the property editor TComponentProperty editor) is to show a list of other components, rather than the sub-properties of an embedded component.

The actual solution really is simple, but only if you know how to write a property editor.

Step 1:


    

type
  TExpandingRecord = class(TPersistent)
    

Should be changed to read


    

type
  TExpandingRecord = class(TComponent)
    

Step 2: Create a property editor like so


    
type
  TExpandingRecordProperty = class(TClassProperty)
  public
    function GetAttributes : TPropertyAttributes; override;
  end;

procedure Register;

implementation

procedure Register;
begin
  RegisterComponents('Article', [TExpandingComponent]);
  RegisterPropertyEditor(TypeInfo(TExpandingRecord), nil, '', TExpandingRecordProperty);
end;

{ TExpandingRecordProperty }

function TExpandingRecordProperty.GetAttributes: TPropertyAttributes;
begin
  Result := [paReadOnly, paSubProperties];
end;
    

Step 3: Remove the RegisterComponents call from the component unit, and register it within the editor unit instead. This way we can ensure the component will not be registered without the component.

Now our property of type TExpandingRecord will show as an expanding property (due to us returning paSubProperties from GetAttributes), and the default editor for TCollection will work as the owner of the TCollection property is a TComponent.

Dialog property editors
Most of the time, when creating a custom property editor, the purpose is to provide a graphical means of interacting with the property.

This first example is a very simple way of allowing the user to enter multiple lines in the "Caption" property of a TLabel. Although this example is not very complicated, it demonstrates how to include a form within your editor.

Step 1:
Select File, New Application from the main menu. This will create a form, name the form "fmLabelEdit", add a TMemo to the form named memCaption. Add two buttons, "OK" and "Cancel" with the ModalResult properties set to mrOK and mrCancel respectively.

Step 2:
Add DsgnIntf and TypInfo to your uses clause.

Caption property editor

Step 3:
Add the following property editor code to your unit.


    

TCaptionProperty = class(TStringProperty)
public
  function GetAttributes: TPropertyAttributes; override;
  procedure Edit; override;
end;
    

And register the property editor like so


    

procedure Register;

implementation
{$R *.DFM}

procedure Register;
begin
  RegisterPropertyEditor(TypeInfo(TCaption), TLabel, 'Caption', TCaptionProperty);
end;
    

Step 4:
Add the following code in order for the object inspector to display the [...] edit button after the property name.


    

function TCaptionProperty.GetAttributes: TPropertyAttributes;
begin
  Result := inherited GetAttributes + [paDialog];
end;
    

Step 5:
Finally, we create an instance of our editor form, set the contents of the memo to the current caption, and then show the form modally.


    

procedure TCaptionProperty.Edit;
var
  I: Integer;
begin
  with TfmLabelEdit.Create(Application) do
  try
    memCaption.Lines.Text := GetStrValue;
    ShowModal;

  {If the ModalResult of the form is mrOK, we need to set the "Caption" property of each TLabel.}

    if ModalResult = mrOK then
      for I:=0 to PropCount-1 do
        TLabel(GetComponent(I)).Caption := memCaption.Lines.Text;
  finally
    Free;
  end;
end;
    

Step 6:
Install the unit into the package, and then try out the new editor !

Advanced property editors
Anyone who has ever used TActionList or TDataSet (TTable / TQuery) will have experience of the following example, possibly without even realising.

The ActionList editor is obviously a custom editor as it allows grouping of actions, whereas the FieldsEditor of TDataSet may at first seem like a standard editor, but upon closer inspection has a popup menu with items such as "Add fields". However, the most remarkable feature of both of these editors is not that they are custom dialog editors (similar to the one we covered earlier), but the fact that the items they create are included in the main class declaration of the current unit.


    

type
  TForm1 = class(TForm)
    ActionList1: TActionList;
    Action1: TAction;
    Action2: TAction;
  private
    { Private declarations }
  public
    { Public declarations }
  end;
    

The benefit of this is that the IDE is made aware of these items, therefore allowing them to be selected from a list of objects whenever the property of a component requires them.

Action property

In the above illustration, two actions are added to a TActionList, clicking the "Action" property of Button1 shows a list consisting of the actions added. The two actions are also added to the Form's class declaration, and can therefore be referred to by name (Action1, Action2).

The trick here lies entirely in the property editor and not within the component. When a property editor is triggered (ie the Edit method is called) the Designer property contains a valid reference to an IFormDesigner (TFormDesigner in Delphi 4). Many of the functions of this interface are not within the scope of this article, if you wish to learn more about the capabilities of this interface I would recommend a book called Delphi Developer's Handbook by Marco Cantu.

Some of the methods include


    

function MethodExists(const Name: string): Boolean;
procedure RenameMethod(const CurName, NewName: string);
procedure SelectComponent(Instance: TPersistent);
procedure ShowMethod(const Name: string);
function GetComponent(const Name: string): TComponent;
function CreateComponent(ComponentClass: TComponentClass; Parent: TComponent; Left, Top, Width, Height: Integer): TComponent;
    

Some of the above calls are fairly elementary, MethodExists for example will return True or False depending on whether or not a method name already exists within the form of the current unit (FormCreate, Button1Click etc). ShowMethod will move the cursor to the named method, and RenameMethod will change the name of a method.

The two methods that are of interest to use at this point are:

CreateComponent
Given a component class, a parent to hold the component, and position / dimensions, the designer will create an instance of the class as if the developer had selected it from the component palette and added it to the form themself.

Modified
Informs the designer that something has been altered (a property etc). This alters the state of the unit so that the IDE knows it should be saved before closing (it also enables the save button in the IDE).

When adding items to our array all we need to do is to get TMyProperty.Designer to create a component on our behalf. This component will then be added to the form and any property that refers to a class of this type will automatically be aware of it. In the case of TActionList and TDataSet the components that are added to the form are not visible at design-time, the owner component acts as a kind of "manager" for the components.

During design-time you wont see a TAction or a TField component on the component palette which would possibly make you suspect they are not registered, yet the IDE is still able to create instances of these components (and they are also not visible). The answer is not that they aren't registered, this behaviour is a result of "how" the component is registered.

Whereas RegisterComponents will add your components to the component palette, the RegisterNoIcon method will register your component without adding it to the component palette, registering in this way also tells the IDE that the component should not be displayed during design-time.

In the following example we will create a component called a TWavSound (a additional component called TWavButton is included in the source code that accompanies this article as an example). TWavSound will simply hold data from a WAV file, and play the sound on demand. Although it would be simple for us to drop one TWavSound onto our form for each WAV sound we require, our form could soon start to become unmanageable, therefore we will also create a manager class called TWavList.

TWavList

Every technique used in the source code to these components was covered in part two of this series of articles so the source code will not be covered in any great level of detail. However, I will show the class declarations of these components just to give you an idea of how they are structured.

Note: At the bottom of the unit, within the initialization section of the unit you may notice the following code:

initialization
  RegisterClass(TWavSound);

The reason is that RegisterNoIcon doesn't seem to do a complete job. Although it allows us to create instances of the registered component from our property editor something seems to go wrong when a project is re-loaded containing these components. A "Class not registered" message box is displayed and the project is corrupted. Additionally registering the class in this way seems to fix the problem

TWavSound


    

type
  PWavData = ^TWavData;
  TWavData = packed record
    Size: Longint;
    Data: array[0..0] of byte;
  end;

  TWavSound = class(TComponent)
  private
    FWavData: PWavData;
    FWav: TWav;
    procedure ReadWavData(Stream: TStream);
    procedure WriteWavData(Stream: TStream);
  protected
    procedure DefineProperties(Filer: TFiler); override;
  public
    destructor Destroy; override;
    procedure Clear;
    procedure LoadFromFile(const Filename: TFilename);
    procedure LoadFromStream(Stream: TStream);
    procedure Play;
  published
  end;
    

FWavData
Will be used to store the contents of the WAV file once loaded from a stream or a file.

Clear
Will free the memory holding FWavData.

Play
Will use the sndPlaySound API call in MMSystem.pas to play the data in FWavData.Data.

ReadWavData and WriteWavData
Will be used internally by the IDE when it needs to read / write the data stored within FWavData.

DefineProperties
Will specify a "hidden" property called WavData, and tell the IDE that ReadWavData and WriteWavData should be used for streaming the data.

FWav
Is set internally by the TWav class when TWav.WavSound is set to our component. The reason is that this collection item will need to be freed when our TWavSound component is freed, in order to stop it from pointing to an invalid object.

TWavSound


    

type
  TWav = class(TCollectionItem)
  private
    FWavSound: TWavSound;
    procedure SetWavSound(const Value: TWavSound);
  protected
  public
    procedure Play;
  published
    property WavSound: TWavSound read FWavSound write SetWavSound;
  end;
    

SetWavSound
Will ensure that the WavSound to which it points will have its FWav set correctly.

TWavs
Is a standard implementation of TCollection so will not be covered in this article. (See part 2 of this series)

TWavList
TWavList is simply a component that publishes a TWavs property to allow us to edit the list of wavs at design-time.

TWavsProperty
TWavsProperty is the property editor that has been designed to handle this class. Although a standard TCollection editor would be sufficed (to a point) I decided to create a new editor in order to allow the playing / clearing of WAVs at design-time.

First I created a new unit with a form in. I added a few TSpeedButtons and a TListBox to list the items in.

My collection editor
Additionally, I added the following items to the Form's class declaration


    

 FWavs: TWavs;
 FComponent: TComponent;
 TheDesigner: IFormDesigner;
    

FWavs
Will hold a reference of the TCollection that we are editing.

FComponent
Will hold a reference to the component that owns the collection. As our form will not be shown modally we will need to close our form if this component is destroyed (using the Notification method of our form).

TheDesigner
Will hold a reference to the current Designer object passed to our property editor. This will be used to call CreateComponent, and to select our hidden TWavSound into the object inspector whenever an item is selected in our listbox.

The actually property editor is a very simple one.


    

type
  TWavsProperty = class(TClassProperty)
  public
    function GetAttributes: TPropertyAttributes; override;
    function GetDisplayName: string;
    procedure Edit; override;
  end;
    

The only real method worth mentioning here is the Edit method. The implementation of which is


    

procedure TWavsProperty.Edit;
begin
  if fmWavsEditor = nil then
    fmWavsEditor := TfmWavsEditor.Create(Application);

  with fmWavsEditor do
  begin
    TheDesigner := Self.Designer; //Don't forget SELF !!
    Caption := Self.GetName;

    //Setup the display, and then show the form
    Edit(TComponent(GetComponent(0)), TWavs(GetOrdValue));
  end;
end;
    

First the editor form is created (if not already created).

"TheDesigner" of the Form is set to Self.Designer. Do not forget the "Self" here as TForm also has a Designer property which at this point will be nil.

GetComponent(0) is used to retrieve the component that owns the property. FreeNotification is called for this component to ensure that our form is notified if the component is destroyed (so that we can close our form).

GetOrdValue is used to retrieve the class object (the "Wavs" property") that is to be edited, the result is typecast as TWavs.

The Edit method that is called is part of TfmWavsEditor, it is a method I added which simply clears the listbox and populates the items with the names of the FWavs entries. It then shows the form.

Note: Later versions of Delphi return TPersistent from the GetComponent function, therefore the result must be typecast to TComponent.

Talking to IFormDesigner
The main two parts of this editor (except for clearing the WAV and playing the WAV) are the parts where "TheDesigner" is interacted with.

The first part to mention should be the part where the "New" button is clicked, a new item is added to the collection, a new TWavSound is added to our form's class declaration, and finally the TWavSound is selected into the object inspector.


    

procedure TfmWavsEditor.sbNewClick(Sender: TObject);
var
  Wav: TWav;
  WavSound: TWavSound;
begin
  //Add an item to the collection
  Wav := FWavs.Add;

  //Ask TheDesigner to create a new TWavSound component for us
  WavSound := TWavSound(TheDesigner.CreateComponent(TWavSound,
    nil, 0, 0, 0, 0));

  //Set the Wav (CollectionItem) to point to our new TWavSound component
  Wav.WavSound := WavSound;

  //Select our new TSoundComponent into the object inspector
  //so that it may be renamed if so desired
  TheDesigner.SelectComponent(WavSound);

  //Internally refresh the items in the listbox
  RefreshList;
  lbItems.ItemIndex := FWavs.Count-1;

  //Tell the IDE that something has changed
  TheDesigner.Modified;
end;
    

The second part to mention is where the correct TWavSound is selected into the object inspector when an item is clicked in the listbox.


    

procedure TfmWavsEditor.lbItemsClick(Sender: TObject);
begin
  with lbItems do
    if ItemIndex >=0 then
      TheDesigner.SelectComponent(FWavs[ItemIndex].WavSound);
end;
    

Avoiding access violations
Finally we need to ensure that we are not left referencing an object that is no longer valid. This is quite simply achieved by following the following two steps

  1. Make sure our form is notified when the component that owns our class property is destroyed.
  2. Override the Notification method of our form and close the form if the relevant component is destroyed.

To ensure we are notified when the component is destroyed:


    

procedure TfmWavsEditor.Edit(AComponent: TComponent; AWavs: TWavs);
begin
  //First we need to remove notification for the current component
  if FComponent <> nil then
    FComponent.RemoveFreeNotification(Self);

  //Now we need to add notification for the current component
  AComponent.FreeNotification(Self);
  FComponent := AComponent;

  FWavs := AWavs;
  lbItems.ItemIndex := -1;
  RefreshList;

  Show;
end;
    

What to do when a component is destroyed:


    

procedure TfmWavsEditor.Notification(AComponent: TComponent; Operation: TOperation);
begin
  inherited;
  if Operation = opRemove then
  begin
    //If the owner component is destroyed
    //we should close our form

    if (AComponent = FComponent) then
      Close
    else
    //If the component that is destroyed
    //we refresh our list just incase it affects our component

    if (AComponent is TWavSound) then
      RefreshList;
  end;
end;
    

Summary
In this article we covered how to write a component editor, we then moved on to creating simple property editors, finally we covered more advanced property editors (including minimal use of the IFormDesigner interface). All of the demonstrated techniques in this article (and more) have been used in my DIB (device independent bitmap) components.

These components are available for free download from http://www.StuckIndoors.com/dib and are open-source (so any development contributions would be greatly appreciated).

 

Share this article!

Follow us!

Find more helpful articles: