A couple of hours after I published this, I realized that it was very long. If you just want the punch line then check out
TL;DR version of Name Value Pairs in ComboBoxes and Kinfolk
You have been tasked with adding a simple ComboBox to a form and load its list of values from a configuration file containing a list of name value pairs. [1]
Getting the name value pairs from the file into a string list is a “been there, done that” breeze kind of thing. Getting a ComboBox to display the contents should be just as simple. It takes a TStrings
for its Items
property after all.
begin // Simulate loading from some configuration file FMyNameValuePairs := TStringList.Create; FMyNameValuePairs.Add('Jansen=100'); FMyNameValuePairs.Add('Petersen=200'); FMyNameValuePairs.Add('Gerritsen=300'); FMyNameValuePairs.Add('Dirksen=400'); FMyNameValuePairs.Add('Karelsen=500'); // Assign the "configuration" contents of the string list to the ComboBox ComboBox1.Items := FMyNameValuePairs; end;
Setting a ComboBox’s items does an assign. [2]
Yay! That’s another item of your todo list.
Oops?!
Why are those numbers showing? They are not supposed to be there. Doesn’t a ComboBox know to use only the names when its Items holds a list of name value pairs?
In one word? No.
In more words? No, it doesn’t. The DrawItems
method of TCustomComboBox
simply draws the entire contents of its items:
FCanvas.TextOut(Rect.Left + 2, Rect.Top, Items[Index]);
Bugger
So how do you get around this unfortunate limitation?
OwnerDraw?
One way around it is to set the Style
of the ComboBox to csOwnerDrawFixed
and implement the OnDrawItem
event to draw only the names of the items:
procedure TForm1.ComboBox1DrawItem(Control: TWinControl; Index: Integer; Rect: TRect; State: TOwnerDrawState); begin ComboBox1.Canvas.TextRect(Rect, Rect.Left, Rect.Top, ComboBox1.Items.Names[Index]); end;
It works, but I don’t like it. I don’t like using events for something I will probably end up using in more places.
Custom descendant?
You could create a TCustomComboBox
descendant and override the DrawItem
method. It is virtual so that certainly is an option.
Still, I don’t much like it. The DrawItem
method contains more than just the TextRect
call and you would have to duplicate that code in order for your descendant to work as expected. Yuck. Duplicating code raises red flags in and of itself. In this case you would have to check your DrawItem implementation with each new Delphi version to ensure that it stays in line with its ancestor except for the intended difference of using Items.Names[Index]
instead of Items[Index]
so other code can keep treating your descendant as any TCustomComboBox
.
Personally, I also have an aversion to dealing with the intricacies of graphics drawings. I’d much rather leave the graphics nitty gritty to Delphi and third party component / control vendors so I can concentrate on business functionality and more abstract presentation issues.
Stripping down to the names
An obvious solution is to strip the name-value separator and the value parts from the StringList’s strings before you assign the StringList to the ComboBox’ items.
I’d strongly advise against this. Been there, done that. Didn’t like it one little bit.
It means you have to ensure that every item is found at exactly the same index in both the StringList and the ComboBox. To do so, you have to either severely limit the experience of your users – keeping them from re-ordering, inserting and deleting the ComboBox’ items, or you have to jump through hoops to keep the two lists in sync.
But, to get the ComboBox to display just the names, you have no option but to strip the name-value separator and the value parts.
So, how do you go about this and still leaving your hoops in the attic?
TStringList
to the rescue
Something which is known very well to Delphi “old hands”, but which comes as a surprise to many less experienced Delphi developers is that a TStringList
is not “just” a list of strings. It actually is a list of records that hold both a string and an object reference.
Exactly what we need as it provides the means to keep our names and values together without jumping through hoops and without limiting our users in what they want to do with the list.
StringList bronze
If the values in your name value pairs always are integer numbers, you can get away with a very simple “hack”. Hack because it (ab)uses the object reference to hold the integer value (instead of an actual object reference).
procedure TBetter_Form.FormCreate(Sender: TObject); var idx: Integer; begin // Simulate loading from some configuration file // OwnsObjects = False is the default, but we state it explicitly here // because we are not really storing object instances, but are (ab)using each Item's // Object property to hold the integer value from the configuration file. FMyNameValuePairs := TStringList.Create({OwnsObjects}False); FMyNameValuePairs.Add('Jansen=100'); FMyNameValuePairs.Add('Petersen=200'); FMyNameValuePairs.Add('Gerritsen=300'); FMyNameValuePairs.Add('Dirksen=400'); FMyNameValuePairs.Add('Karelsen=500'); // Show the contents of the "configuration" in a memo Memo1.Text := FMyNameValuePairs.Text; // Load the "configuration" contents of the StringList into the ComboBox for idx := 0 to FMyNameValuePairs.Count - 1 do ComboBox1.AddItem(FMyNameValuePairs.Names[idx], TObject(StrToIntDef(FMyNameValuePairs.ValueFromIndex[idx], 0))); end;
OwnsObjects [3]
Yes. We have lift off.
Coming back down isn’t too bad either.
When you need to get the value of a string selected in the ComboBox, all you need to do is cast the object reference back to an integer.
procedure TBetter_Form.ComboBox1Select(Sender: TObject); begin Name_Text.Caption := ComboBox1.Items[ComboBox1.ItemIndex]; // Integer cast of TObject in this case is safe even on 64-bit because we // ourselves are putting in Integer values. Value_Text.Caption := IntToStr(Integer(ComboBox1.Items.Objects[ComboBox1.ItemIndex])); end;
As said, this hack requires all values are integers. So what to do when your values are something else instead?
StringList silver
When the values in your name value pairs always are not integer numbers but strings or whatever else comes your way, you need to do a little more work, but not much.
As a StringList is a list of string-object associations, the solution is to wrap your values in an object instance before adding the name and that instance to the ComboBox.
If you are using a Delphi version that has neither generics [4] or extended RTTI [5] , you will have to create specific classes for each type you need to wrap. Otherwise you only need to declare a single class. I have opted for using extended RTTI because that has built-in “to string” conversion. Using generics you would have to code that yourself.
type TValueObject = class(TObject) strict private FValue: TValue; public constructor Create(const aValue: TValue); property Value: TValue read FValue; end; { TValueObject } constructor TValueObject.Create(const aValue: TValue); begin FValue := aValue; end;
The way you use this approach is pretty straightforward and comparable to the integer hack. All you would have to change is the instantiation of the StringList to set OwnsObjects
to True
and the loop over the FMyNameValuePairs
StringList to read as:
// Load the "configuration" contents of the StringList into the ComboBox for idx := 0 to FMyNameValuePairs.Count - 1 do ComboBox1.AddItem(FMyNameValuePairs.Names[idx], TValueObject.Create(FMyNameValuePairs.ValueFromIndex[idx]));
Just one problem. The ComboBox doesn’t take ownership of the instances that are added as Objects for its items. The way to get around that is to ensure that FMyNameValuePairs
is instantiated with OwnsObjects
set to True
; add the instances as Objects for the FMyNameValuePairs
‘ items and then just pass them to the ComboBox.
// FMyNameValuePairs should be instantiated with OwnsObjects = True for idx := 0 to FMyNameValuePairs.Count - 1 do FMyNameValuePairs.Objects[idx] := TValueObject.Create(FMyNameValuePairs.ValueFromIndex[idx]); for idx := 0 to FMyNameValuePairs.Count - 1 do ComboBox1.AddItem(FMyNameValuePairs.Names[idx], FMyNameValuePairs.Objects[idx]);
As the ComboBox doesn’t take ownership of the Objects, it means that the Objects of the ComboBox’ items hold references to the value objects actually contained in FMyNameValuePairs
. So you had better make sure that the value objects in FMyNameValuePairs
live longer than the ComboBox’s items. This can be achieved by clearing the ComboBox before freeing FMyNameValuePairs
.
procedure TAnyValue_Form.FormDestroy(Sender: TObject); begin // Ensure the ComboBox can no longer reference the items in FMyNameValuePairs ComboBox1.Clear; FMyNameValuePairs.Free; end;
When you need to get the value of a string selected in the ComboBox, all you need to do is get it back out of its wrapper:
procedure TAnyValue_Form.ComboBox1Select(Sender: TObject); begin Name_Text.Caption := ComboBox1.Items[ComboBox1.ItemIndex]; Value_Text.Caption := TValueObject(ComboBox1.Items.Objects[ComboBox1.ItemIndex]).Value.AsString; end;
StringList gold
But why stop there? Now that you have discovered the capability of stripping a name value pair list down to its names while retaining the association with its values, there is nothing stopping you from limiting that to a ComboBox.
To achieve that, you change the FMyNameValuePairs
a bit:
// Convert the contents so both the ComboBox and Memo can show just the names // and the values are still associated with their items using actual object // instances. for idx := 0 to FMyNameValuePairs.Count - 1 do begin FMyNameValuePairs.Objects[idx] := TValueObject.Create(FMyNameValuePairs.ValueFromIndex[idx]); FMyNameValuePairs.Strings[idx] := FMyNameValuePairs.Names[idx]; end;
Of course you need to do that before you hand it to any control capable of showing the contents of a StringList. And doing that now actually is as simply as you initially thought it should be:
begin // Set the contents for the ComboBox ComboBox1.Items := FMyNameValuePairs; end;
Enjoy!
Code for the gold approach
type TValueObject = class(TObject) strict private FValue: TValue; public constructor Create(const aValue: TValue); property Value: TValue read FValue; end; { TValueObject } constructor TValueObject.Create(const aValue: TValue); begin FValue := aValue; end; procedure TGold_Form.ComboBox1Select(Sender: TObject); begin Name_Text.Caption := ComboBox1.Items[ComboBox1.ItemIndex]; Value_Text.Caption := TValueObject(ComboBox1.Items.Objects[ComboBox1.ItemIndex]).Value.AsString; end; procedure TGold_Form.FormCreate(Sender: TObject); var idx: Integer; begin // Simulate loading from some configuration file // We set OwnsObjects to True so that any object instances we add will be // freed automatically when we free the string list. FMyNameValuePairs := TStringList.Create({OwnsObjects}True); FMyNameValuePairs.Add('Jansen=Aquarius'); FMyNameValuePairs.Add('Petersen=Capricorn'); FMyNameValuePairs.Add('Gerritsen=Pisces'); FMyNameValuePairs.Add('Dirksen=Libra'); FMyNameValuePairs.Add('Karelsen=Scorpio'); // Convert the contents so both the ComboBox and Memo can show just the names // and the values are still associated with their items using actual object // instances. for idx := 0 to FMyNameValuePairs.Count - 1 do begin FMyNameValuePairs.Objects[idx] := TValueObject.Create(FMyNameValuePairs.ValueFromIndex[idx]); FMyNameValuePairs.Strings[idx] := FMyNameValuePairs.Names[idx]; end; // Show the contents of the "configuration" in a memo Memo1.Text := FMyNameValuePairs.Text; // Load the "configuration" contents of the string list into the combo box ComboBox1.Items := FMyNameValuePairs; // Does an Assign! end; procedure TGold_Form.FormDestroy(Sender: TObject); begin // Ensure the ComboBox can no longer reference the items in FMyNameValuePairs ComboBox1.Clear; // Free the "configuration" string list. // As OwnsObjects is True, we don't need to free the Objects ourselves. // It is done for us. FMyNameValuePairs.Free; end;
Notes
[1] Name Value pairs, or key value pairs, seem to have a ubiquitous nature. Come to think of it, there is a whole data storage pattern based on it. The Entity-Attribute-Value pattern.
When applied to relational databases, the Entity-Attribute-Value pattern is considered an anti-pattern. More details and discussions can be found through the following links:
– Is there a name for this database structure
– EAV – is it really bad in all scenarios?
– Alternatives to Entity-Attribute-Value (EAV)?
[2] Actually, the setter does an Assign
when the internal FItems
member has already been assigned. Otherwise it just sets its FItems
member to the supplied instance. This is because TCustomCombo
allows descendants to give FItems
a reference to TStrings
descendant of their liking, but still keeps FItems
completely under its own control by declaring it private. Which forces descendants to go through the setter. The TCustomComboBox
descendant of TCustomCombo
does exactly that. In its constructor it sets the Items
property to a newly instantiated TStrings
descendant. Which means that when you come to use the Items
property of a TComboBox
, the FItems
member is already assigned and when you set the Items
property, the Assign
method is used to copy the contents.
[3] In old(er) versions of Delphi TStringList
does not have the OwnsObjects
constructor parameter. Means that you need to loop over the list yourself to free all the instances stored in Objects
before you free the StringList itself.
[4] Generics were introduced in Delphi 2009 but are generally considered usable only as of Delphi 2010.
[5] Extended RTTI was introduced in Delphi 2010.
Thank you for this. I got it on the first try. I then used it to modify a function that I had already created. It builds a name-value list from a data source so I can link text to an ID field. I am tempted to take it further using TValue to make it more generic, but I have spent enough time solving what seems like a Database Programming 101 level problem.
function PopulateStringList(SList:TStringList;Source: TDataSet; Field1, Field2: string): integer;
var
ClosedSource: boolean;
begin
Result := 0;
ClosedSource := not Source.Active;
if ClosedSource then
Source.Open
else
Source.First;
while not Source.Eof do
begin
if (Field2 = '') then
SList.Add(Source.FieldByName(Field1).AsString)
else
begin
SList.Add(Source.FieldByName(Field1).AsString+'='+Source.FieldByName(Field2).AsString);
SList.Objects[Result] := TValueObject.Create(SList.ValueFromIndex[Result]);//load value portion of list into Objects
SList.Strings[Result] := SList.Names[Result];//load name portion of list into Strings
end;
Inc(Result);
Source.Next;
end;
if ClosedSource then
Source.Close;
end;