This article describes a data-aware component that provides a treeview front-end for a dataset. It behaves like a standard treeview component available in the Delphi component palette but its structure and other data is located in a dataset. In addition, it provides a tutorial on how to develop a datalink and a property editor.
During the development of a database-driven personal information manager, I had to develop a data-aware treeview control to be used as an interface to one of the dataset of the database. The intent was to allow the user to exhibit the content of the dataset in a hierarchical interface that could be modified at will. It provides a hierarchical interface and stores its structure and other data in a dataset and requires the development of genuine datalink and property editor.
The component
From the surface, the new component behaves exactly as the TTreeview component of the VCL with the added feature that it stores its structure as well as other fields in a dataset. It is derived from TCustomTreeView, the parent of the TTreeView component of the Delphi's visual component library and as such, is a window that displays a contracting/expanding hierarchical list of nodes, such as the headings in a document, the entries in an index, or the files and directories on a disk. Each node in the control consists of a label and a number of optional bitmap images and each node can have a list of subnodes associated with it. Figure 1 shows a snapshot of the component.
Nodes can be created, deleted or moved (through drag-and-drop) during which the dataset is automatically kept in synchronization with the display. Selecting a node allows the display of the other fields of the dataset. Figure I shows a snapshot of the component.
The dataset
The dataset underlying the component must be specifically designed to contain the structure of the Treeview component. It is comprised of four essential fields:
- KeyFieldName - it is the key field (defined as a short) that identifies uniquely every record of the table. This reference is defined when the record is created.
- ParentFieldName - it is the parent field that identifies the parent of the record in the hierarchical view (defined as a short).
- IsFolderFieldName - a logical field that defines the nature of the node (folder/container or standard node/leaf).
- LabelFieldName - the label of the node (alpha).
The other fields of the dataset are defined by the user and they may be displayed when a node of the treeview is selected.
How to use it
The source code of this component can be downloaded here. It contains three data-aware components contained in a package called gtrodblib6.dpk. Once the gtrodblib6 package is compiled and installed on Delphi 6, the component becomes available in the GTRO pane of the component palette. From there, is can be dropped on a form or on a frame. It is then displayed and you give it the label that you want. The object inspector displays the published properties and event handlers. The most important are:
- A DataSource which must be connected to a dataset through its data source property;
- A Navigator which must be connected to a TDBNavigator component;
- KeyFieldName, and ParentFieldName which must be connected to integer fields (ftInteger, ftSmallInt, or ftAutoInc);
- IsFolderFieldName which must be connected to a logical field (ftBoolean);
- LabelFieldName which must be connected to a string (ftString) field;
- Images can be left blank in which case default icons will be displayed. However, if an ImageList is assigned, this ImageList must contain the following icons: IMG_HOME = 0, IMAGE_FOLDER_CLOSED = 1, IMAGE_FILE_CLOSED = 2, IMAGE_FOLDER_OPEN = 3 and IMAGE_FILE_OPEN = 4.
Once these have been initialized, the component is ready for use on a form or on a frame. There is no need to generate OnEnter and OnExit event handlers to connect and disconnect the external navigator as these actions are performed under the hood of the component when the component is entered or exited.
How it works
The component appears as a standard TreeView component on a form. Its nodes can be created, deleted and moved as in the case for the TreeView component. Its singularity lies in the fact that it stores its data in a dataset. Creating, deleting and moving nodes update this dataset as they occur.
Loading nodes in the component
When using an ordinary TreeView component, loading the component is performed using the LoadFromFile() method that loads the component from a text file formatted specifically for this purpose. Our component is loaded in a similar fashion but its data is located in a dataset rather than in a text file. In addition, the code used to load the component is a bit more tricky. In fact, the LoadFromTable() method uses two special classes TItems and TListOfItems to perform its work (see Figure 2 and this annex). It is a three step process:
- The records of the dataset are read sequentially (only once) and the content of their KeyField, ParentField, Title and IsFolder fields are transfered in a TItem objects created for each record. Each such TItem object is then inserted in a list of items.
- This list is then read sequeltially, TItem by TItem, in order to find its parent TItem. If it is found, a pointer to that parent object is inserted in the ParentField variable of the TItem. If it is not found, the ParentItem pointer is set to nil meaning that the corresponding node has the root node as parent;
- The nodes of the treeview are created from the TItems objects of the TListOfItems.
as shown in the code that follows:
procedure TGtroDBTreeView.LoadFromTable
var
...
begin
FListOfItems:= TListOfItems.Create; // creates the list containing the TItems
OnChange:= nil; // no reaction to the OnChange event
Items.BeginUpdate;
try
Items.Clear;
AddRootNode;
FDatalink.DataSource.DataSet.Open; // opens the table
FDatalink.DataSource.DataSet.First; // puts the pointer on the first record
while not FDatalink.DataSource.DataSet.Eof do
begin
R:= FDatalink.Fields[0].AsInteger; // Key field
P:= FDatalink.Fields[1].AsInteger; // Parent field
F:= FDatalink.Fields[2].AsBoolean; // IsFolder field
T:= FDatalink.Fields[3].AsString; // Title field
Item:= TItem.Create(F, R, P, T); // creates an initialized TItem
FListOfItems.Add(Item); // Adds the TItem object in the list
FDatalink.DataSource.DataSet.Next; // Next record
end; // ...for
for i:= 0 to FListOfItems.Count - 1 do
begin // Updates parent pointers
P:= TItem(FListOfItems.Items[i]).ParentID;
if P <> 0 then
begin
Ptr:= FListOfItems.FindItem(P);
TItem(FListOfItems.Items[i]).ParentItem:= Ptr;
end; // ...if
end; // ...for
// Builds the hierarchical view
for i:= 0 to FListOfItems.Count - 1 do
AddANode(TItem(FListOfItems.Items[i]));
CustomSort(@MyCustomSortProc, 0); // sorts the nodes
FullCollapse;
Items.Item[0].Expand(False);
finally
OnChange:= TreeViewChange; // reassign the OnChange event handler
FListOfItems.Free; // frees the list of TItems
Items.EndUpdate;
end; // try...finally
end;
Adding, deleting and moving nodes
In this component, adding and deleting nodes must be synchronized with updates of the associated dataset. These operations are performed inside the component and they are triggered by an internal navigator which receives commands from the outside of the component. Moving nodes is performed by a drag-and-drop operation and the updating of the dataset is performed by internal code.
Internal Navigator
The control of additions and deletions of nodes is performed by an internal TDBNavigator component controlled by code in the TGtroDBTreeView component. Such navigator is declared as a property of the component but is not created in the component. It belongs to the host application to add an external navigator component and this external navigator must be connected to the internal navigator through the published Navigator property either at design or at execution time. This property is set as follows:
procedure TGtroDBTreeView.SetNavigator(Value: TDBNavigator);
begin
if Assigned(Value) then // new value <> nil => Initialization of FNavigator
begin
// Stores the event handler of the external navigator in order to call it later
FOldOnClick:= Value.OnClick;
FNavigator:= Value;
FNavigator.VisibleButtons:= [nbInsert, nbDelete]; // two buttons
FNavigator.DataSource:= DataSource; // sets the data source
FNavigator.OnClick:= NavigatorClick; // sets the internal event handler
end
else // new value = nil => deletes all links with FNavigaror
begin
if Assigned(FNavigator) then // unless FNavigator already at nil.
begin
FNavigator.OnClick:= nil;
FNavigator.DataSource:= nil;
FNavigator:= nil;
end;
end;
end;
Once connected, the external navigator becomes the controller of the internal navigator. It exhibits two buttons: one (+) to add new nodes, the other (-) to delete an existing one The OnClick event handler that was previously assigned to the external navigator (if any) is now replaced by the internal OnClick event handler called NavigatorClick():
procedure TGtroDBTreeView.NavigatorClick(Sender: TObject;
Button: TNavigateBtn)
begin
// execute the event handler associated with the external navigator
if Assigned(FOldOnClick) then FOldOnClick(Self, Button);
if Button = nbDelete then DeleteSelectedNode; // deletes the selected node
if Button = nbInsert then AddNewNode(NodeType); // add a child node
end;
Adding nodes
As shown in the code above, clicking on the + button of the external navigator generates a new node through the AddNewNode() method. The argument NodeType of AddNewNode() defines the type of the new node. It must be defined by the host application either as ntFolder (creation of a container node) or as ntFile (creation of a standard node) before the + button of the navigator is clicked.
procedure TGtroDBTreeView.AddNewNode(NodeType: eNodeType);
var
RecordID, ParentID: Integer;
NewNode: TTreeNode;
begin
if NodeType = ntRoot then Exit; // do nothing if NodeType is ntRoot
if Selected = nil then Selected:= Items.GetFirstNode;
if not IsNodeAllowed(Selected) then Exit;
RecordID:= NextRecordID;
ParentID:= Integer(Selected.Data); // RefNo du noeud parent
NewNode:= Items.AddChildObjectFirst(Selected, ' ', Pointer(RecordID));
with NewNode do
begin
case NodeType of // folder or file node?
ntFolder: // assign folder icon to node
begin
ImageIndex:= IMG_FOLDER_CLOSED;
SelectedIndex:= IMG_FOLDER_OPEN;
end;
ntFile: // assign file icon to node
begin
ImageIndex:= IMG_FILE_CLOSED;
SelectedIndex:= IMG_FILE_OPEN;
end;
end; // ...case
MakeVisible;
end; // ...with
Selected:= NewNode;
NewNode.Focused:= true;
NewNode.EditText;
// Inscription dans la table
with FDataLink.DataSource.DataSet do
begin
Append;
FDatalink.Fields[0].Value:= RecordID;
FDatalink.Fields[1].Value:= ParentID;
FDatalink.Fields[2].Value:= NodeType = ntFolder;
FDatalink.Fields[3].Value:= NewNode.Text;
Post;
GetNextRecordID;
end; // ...with
end;
Deleting nodes
Deleting an existing node is performed by clicking on the - on the external navigator. As shown in the code of the NavigatorClick() event handler, when doing that, the user triggers the DeleteSelectedNode() method which finds the record associated with the selected node and deletes it.
procedure TGtroDBTreeView.DeleteSelectedNode; // public begin NodeToRecord(Selected); // find the record associated with the node Selected.Delete; end;
NodeToRecord() is a utility member function that returns true when the record associated with a node is found but at the same time, since it uses the Locate() method of the dataset, it makes such a record active. Its code follows:
function TGtroDBTreeView.NodeToRecord(Node: TTreeNode): Boolean;
begin
FDataLink.DataSet.DisableControls;
try
Result:= FDataLink.DataSource.DataSet.Locate(FDatalink.FFieldNames[0],
Integer(Node.Data), []);
finally
FDataLink.DataSet.EnableControls;
end; // try...finally
end;
Moving nodes
Moving nodes in the treeview component is performed by a drag-and-drop operation. In Delphi, such operation is a three-step process: starting the drag-and-drop, dragging over the nodes and dropping on a node as shown in the listings that follow:
procedure TGtroDBTreeView.TVDragOver(Sender, Source: TObject; X, Y: Integer;
State: TDragState; var Accept: Boolean);
const
EDGE = 50;
var
TargetNode, SourceNode: TTreeNode;
begin
inherited;
TargetNode:= GetNodeAt(X, Y);
if (Source = Sender) and (TargetNode <> nil) then
begin
Accept:= IsNodeAllowed(TargetNode);
if Accept then
begin
SourceNode:= Selected;
while (TargetNode.Parent <> nil) and (TargetNode <> SourceNode) do
TargetNode:= TargetNode.Parent;
if TargetNode = SourceNode then Accept:= False;
end; // ...if
end // ...if
else Accept:= False;
// Scrolling
if (Y < EDGE) and Assigned(TopItem.GetPrevVisible()) then
TopItem:= TopItem.GetPrevVisible;
if (Y > Height - EDGE)
and Assigned(TopItem.GetNextVisible()) then
TopItem:= TopItem.GetNextVisible;
end;
and
procedure TGtroDBTreeView.TVDragDrop(Sender, Source: TObject; X, Y: Integer);
var
TargetNode, SourceNode: TTreeNode;
RecordID: Integer;
Found: Boolean;
begin
inherited;
TargetNode:= GetNodeAt(X, Y);
if TargetNode <> nil then
begin
FDataLink.DataSource.DataSet.DisableControls;
SourceNode:= Selected;
try
SourceNode.MoveTo(TargetNode, naAddChildFirst);
TargetNode.Expand(False);
Selected:= TargetNode;
{ Éditof the dataset }
// Trouver record correspondant au noeud source et le rendre courant
Found:= NodeToRecord(TargetNode);
if Found then
RecordID:= FDataLink.Fields[0].Value
else RecordID:= 0;
// Trouver record correspondant au noeud cible et le rendre courant
NodeToRecord(SourceNode);
FDataLink.DataSource.DataSet.Edit;
FDataLink.Fields[1].Value:= RecordID; // change le record Parent
FDataLink.DataSource.DataSet.Post;
CustomSort(@MyCustomSortProc, 0);
finally
Selected:= SourceNode;
FDataLink.DataSource.DataSet.EnableControls;
end; // try...finally
end; // ...if
end;
Technicalities
Some necessary concepts have to be added here in order to complete this article. Data-aware components often require the development of special ways to access the data in the datasets as well as special means to allow the Object Inspector to edit the properties correctly. It is the case here as the component uses four fields of the dataset (need for a genuine Datalink0 some of which require new property editors.
Datalink
Generally, database programs connect to some data-aware control through a DataSource component, and then connect the DataSource component to a data set, usually a TTable or a TQuery. The connection between a data-aware control and the TDataSource is called a data link, and is represented pro grammatically by an object of class TDataLink declared and implemented in DB.pas. Though TDataLink is not technically an abstract class, it is seldom used directly. Either you use one of the Delphi-provided data link classes derived from it or you derive a new one yourself.
In cases where only one field is published, the TFieldDatalink class provided by Delphi is used. It publishes a DataSource and a FieldName property through which the user can connect the control to a dataset. In the case of this component, a DataSource and four field properties must be published by the component, thus requiring genuine datalinks for each of them. These fields are the following: KeyFieldName, ParentFieldName, IsFolder and LabelFieldName
The TGtroDBTreeView class is derived from the TCustomTreeView class, the ancestor of the TTreeView component as shown below.
TGtroDBTreeView = class(TCustomTreeView)
private
FDataLink: TTreeViewDataLink;
...
published
property DataSource: TDataSource read GetDataSource write SetDataSource;
property KeyFieldName: string
read GetKeyFieldName write SetKeyFieldName;
property ParentFieldName: string
read GetParentFieldName write SetParentFieldName;
property IsFolderFieldName: string
read GetIsFolderFieldName write SetIsFolderFieldName
property LabelFieldName: string
read GetLabelFieldName write SetLabelFieldName;
...
property Navigator: TDBNavigator read FNavigator write SetNavigator;
... publication of the TCustomTreeView properties
end;
This truncated listing of the declaration of the component shows that a TTreeViewDataLink is declared as a private member variable and that the component publicizes the DataSource and the four fields properties (through the Fields and FieldNames properties) mentionned above.This datalink is declared as follows:
TTreeViewDataLink = class(TDataLink)
private
FTreeView: TCustomTreeView;
FFieldNames: array [0..3] of string; // four fields
FFields: array [0..3] of TField;
FOnActiveChange: TNotifyEvent;
function GetFields(i: Integer): TField;
function GetFieldNames(i: Integer): string;
procedure SetFields(i: Integer; Value: TField);
procedure SetFieldNames(I: Integer; const Value: string);
procedure UpdateField(i: Integer);
protected
procedure ActiveChanged; override;
public
constructor Create(ATreeView: TCustomTreeView);
property FieldNames[i: Integer]: string
read GetFieldNames write SetFieldNames;
property TreeView: TCustomTreeView read FTreeView write FTreeView;
property Fields[i: Integer]: TField read GetFields;
property OnActiveChange: TNotifyEvent read FOnActiveChange write FOnActiveChange;
end;
The read and write methods of the Fields and the FieldNames properties simply connect the fields to the dataset. In addition, the SetFieldNames() method calls the UpdateFields() method that call the field editors of these fields.
procedure TTreeViewDataLink.UpdateField(i: Integer);
begin
if Active and (FFieldNames[i] <> '') then
begin
if Assigned(FTreeView) then
SetFields(i, GetFieldProperty(DataSource.DataSet, FTreeView, FFieldNames[i]))
else
SetFields(i, DataSource.DataSet.FieldByName(FFieldNames[i]));
end
else SetFields(i, nil);
end;
Property editors
The Object Inspector provides default editing for most types of properties but, as a designer, your ability to create custom property editors is one of the reasons that Delphi is so good. Basically, you have the ability to create a dialog box to edit one or more properties in any way you want. In Delphi 5 Pro, the basic property editors (TPropertyEditor, TStringProperty and the like) are defined in C:\Program Files\Borland\Delphi5\Source\Toolsapi\dsgnintf.pas whereas in Delphi 6, they are in C:\Program Files\Borland\Delphi6\Source\ToolsAPI\DesignEditors.pas.
Now that the DataSource and the FieldNames properties appear in the object inspector, the fields that they retrieve must obey certain rules. These rules follow:
- The KeyFieldName and the ParentFieldName properties must be restricted to fields containing integer values (ftInteger, ftSmallInt or ftAutoInc);
- The IsFolderName property must be restricted to fields containing boolean values (ftBoolean); and
- The LabelFieldName must be restricted to fields containing string values (ftString).
As an example of property editor, I will show how
TGtroDBTreeViewKeyDataFieldEditor = class(TStringProperty)
public
function GetAttributes: TPropertyAttributes; override;
procedure GetValues(Proc: TGetStrProc); override;
end;
Whereas the first method provides the attributes of the property
function TGtroDBTreeViewKeyDataFieldEditor.GetAttributes: TPropertyAttributes;
begin
Result:= [paAutoUpdate, paMultiSelect, paValueList, paSortList];
end;
the second method restricts the selection of the fields of the dataset to certain types, here: ftSmallInt, ftInteger and ftAutoInc fields as shown by the highlighted portions of the code that follows.
procedure TGtroDBTreeViewKeyDataFieldEditor.GetValues(Proc: TGetStrProc);
var
SList: TStringList;
TView: TGtroDBTreeView;
i: Integer;
Field: TField;
begin
SList:= TStringList.Create;
try
TView:= GetComponent(0) as TGtroDBTreeView; // fetch TGtroDBTreeView component
if Assigned(TView.DataSource) and
Assigned(TView.DataSource.DataSet) then
begin // compile a list of fields only if component is connected correctly
TView.DataSource.DataSet.GetFieldNames(SList);
for i:= 0 to SList.Count - 1 do
begin
if TView.DataSource.DataSet.Active then
Field:= TView.DataSource.DataSet.Fields[i]
else
Field:= TView.DataSource.DataSet.FieldDefs.Items[i].CreateField(nil);
if ((Field.DataType = ftSmallint)
or (Field.DataType = ftInteger
or (Field.DataType = ftAutoInc))
and (Field.Index = 0) then
Proc(SList.Strings[i]);
if not TView.DataSource.DataSet.Active then Field.Free;
end;
end;
finally
SList.Free;
end;
end;
Registering the property editor
The registration of the property editor is performed in the Register procedure as follows:
procedure Register;
begin
RegisterPropertyEditor(TypeInfo(string), TGtroDBDateTimePicker, 'DataField',
TGtroDBDateTimePickerDataFieldEditor);
RegisterComponentEditor(TGtroDBDateTimePicker, TGtroDBDateTimePickerComponentEditor);
end;
The first procedure called in the Register procedure is the RegisterPropertyEditor() procedure. Its declaration is as follows:
procedure RegisterPropertyEditor(
PropertyType: PTypeInfo; ComponentClass: TClass;
const PropertyName: string; EditorClass: TPropertyEditorClass);
and it associates the property editor class specified by the EditorClass parameter with the property type specified by the PropertyType parameter. The PropertyName parameter is set to restrict the property editor to properties with a specific name whereas setting it to an empty string will associates the property editor with any property of the specified type. The ComponentClass parameter is set to restrict the property editor to a component class and its descendants. Setting ComponentClass to nil associates the property editor with the property type for any component.
In the case above, it assigns the TGtroDBDateTimePickerDataFieldEditor to the DataField property of the TGtroDBDateTimePicker component.
In order to separate the run-time and design-time code, the property editors have been developed in a separate unit called DBReg.pas which is included in the gtrodblib6.dpk package.
Conclusion
The development of three data-aware components gave the occasion of initiating ourselves with the gist of data-aware controls: datalinks and property editors. Datalinks are objects that allow the publication of the DataSource property as well as surfacing the fileds of the database that need to be linked. Once this is done, property editors are developed to restrict the selection of fields in the object inspector to specific types.
The GtroDBTreeView component itself was described in the following sections. The actions of loading the nodes from the dataset, creating new nodes, deleting existing nodes and moving nodes around were described in details. Three components were developed but only the GtroDBTreeView component was described in the article (the other two are described in the separate articles entitled "A data-aware Pushbutton Calendar component" and "A data-aware DateTimePicker component"). The code of these components can be downloaded here. The components can be installed in the component palette by installing the gtrodblib7 package. They will install in the GTRO pane of the component palette.
A demo showing an application of the component has been added and can be downloaded here. The file contains an executable of the program (it can be executed as is), the code of the program, an Access database filled with "somewhat ridiculous geographical data" as well as the HowTo.html file containing the instruction for its use.
Annexes
TItem and TListOfItems objects
The TItem and TListOfItems discussed in this article are special objects used in loading the content of the dataset into the treeview component. They are declared as follows:
TItem = class
private
RecordID, ParentID: Integer;
Text: string;
FParentItem: TItem;
Flag: Boolean;
public
constructor Create(F: Boolean; R, P: Integer; T: string);
property ParentItem: TItem read FParentItem write FParentItem;
end;
and
TListOfItems = class(TList)
destructor Destroy; override;
function FindItem(X: Integer): TItem;
end;
When a TItem is created, the Flag (IsFolder), RecordID, ParentID and the Title member variables of the object are set to the values of the parameters of the Create() constructor whereas the FParentItem member variable is set to nil.