Name Value Pairs in ComboBoxes and Kinfolk

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.

ComboBox naively filled with name value pairs

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 OnDrawItemevent 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]

ComboBox filled with name value pairs relying on values being integers

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;

ComboBox filled with name value pairs allowing values of any type

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;

ComboBox and Memo filled with names of name value pairs allowing values of any type

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 Itemsproperty, 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.

Posted in Software Development
Tags: , , , , , , ,

Leave a Reply

Your email address will not be published. Required fields are marked *

*

Show Buttons
Hide Buttons