Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Hiding items in TListBox while filtering by String

Short Version: Is there any way to control or modify LisBox items individually? for example set their Visible property to False separately. I found a TListBoxItem class in Fire Monkey when I was searching, but I don't want to use Fire Monkey and want it in VCL.

Detailed Version: I tried to filter my ListBox using two TStringList and an Edit, one StringList is global to keep the original list (list_files_global) and another StringList to help filtering procedure (list_files_filter) and my primary list of files is my ListBox (list_files). I created my global StringList on onCreate event while program is starting to store my original list:

procedure Tfrm_main.FormCreate(Sender: TObject);
Begin
  list_files_global := TStringList.Create;
  list_files_global.Assign(list_files.Items);
End;

and used Edit's onChange event for filtering:

procedure Tfrm_main.edit_files_filterChange(Sender: TObject);
Var
  list_files_filter: TStringList;
  i: Integer;
Begin
  list_files_filter := TStringList.Create;
  list_files_filter.Assign(list_files.Items);

  list_files.Clear;

  for i := 0 to list_files_filter.Count - 1 do 
    if pos(edit_files_filter.text, list_files_filter[i]) > 0 then 
      list_files.Items.Add(list_files_filter[i]);

End;

and for switching off the filter, just recover the list from my global list that I created at first:

list_files.Items := list_files_global;

here so far, everything works just fine, but problem is when I'm trying to edit/rename/delete items from filtered list, for example I change an item:

list_files.Items[i] := '-- Changed Item --';

list will be edited, but when I switch off the filter, the original list will be back and all changes are lost. so I want to know is there any proper way to solve this problem? Something like hiding items individually or change items visibility, etc... so I can change the filtering algorithm and get rid of all this making extra lists. I searched the internet and looked into Delphi's help file for a whole day and nothing useful came up.

like image 292
Armin Taghavizad Avatar asked Sep 23 '19 18:09

Armin Taghavizad


2 Answers

The items of a VCL listbox, List Box in the API, does not have any visibility property. The only option for not showing an item is to delete it.

You can use the control in virtual mode however, where there are no items at all. You decide what data to keep, what to display. That's LBS_NODATA window style in the API. In VCL, set the style property to lbVirtual.

Extremely simplified example follows.

Let's keep an array of records, one record per virtual item.

type
  TListItem = record
    FileName: string;
    Visible: Boolean;
  end;

  TListItems = array of TListItem;

You can extend the fields as per your requirements. Visibility is one of the main concerns in the question, I added that. You'd probably add something that represents the original name so that you know what name have been changed, etc..

Have one array per listbox. This example contains one listbox.

var
  ListItems: TListItems;

Better make it a field though, this is for demonstration only.

Required units.

uses
  ioutils, types;

Some initialization at form creation. Empty the filter edit. Set listbox style accordingly. Fill up some file names. All items will be visible at startup.

procedure TForm1.FormCreate(Sender: TObject);
var
  ListFiles: TStringDynArray;
  i: Integer;
begin
  ListFiles := ioutils.TDirectory.GetFiles(TDirectory.GetCurrentDirectory);

  SetLength(ListItems, Length(ListFiles));
  for i := 0 to High(ListItems) do begin
    ListItems[i].FileName := ListFiles[i];
    ListItems[i].Visible := True;
  end;

  ListBox1.Style := lbVirtual;
  ListBox1.Count := Length(ListFiles);

  Edit1.Text := '';
end;

In virtual mode the listbox is only interested in the Count property. That will arrange how many items will show, accordingly the scrollable area.

Here's the filter part, this is case sensitive.

procedure TForm1.Edit1Change(Sender: TObject);
var
  Text: string;
  Cnt: Integer;
  i: Integer;
begin
  Text := Edit1.Text;
  if Text = '' then begin
    for i := 0 to High(ListItems) do
      ListItems[i].Visible := True;
    Cnt := Length(ListItems);
  end else begin
    Cnt := 0;
    for i := 0 to High(ListItems) do begin
      ListItems[i].Visible := Pos(Text, ListItems[i].FileName) > 0;
      if ListItems[i].Visible then
        Inc(Cnt);
    end;
  end;
  ListBox1.Count := Cnt;
end;

The special case in the edit's OnChange is that when the text is empty. Then all items will show. Otherwise code is from the question. Here we also keep the total number of visible items, so that we can update the listbox accordingly.

Now the only interesting part, listbox demands data.

procedure TForm1.ListBox1Data(Control: TWinControl; Index: Integer;
  var Data: string);
var
  VisibleIndex: Integer;
  i: Integer;
begin
  VisibleIndex := -1;
  for i := 0 to High(ListItems) do begin
    if ListItems[i].Visible then
      Inc(VisibleIndex);
    if VisibleIndex = Index then begin
      Data := ListItems[i].FileName;
      Break;
    end;
  end;
end;

What happens here is that the listbox requires an item to show providing its index. We loop through the master list counting visible items to find out which one matches that index, and supply its text.

like image 192
Sertac Akyuz Avatar answered Sep 18 '22 12:09

Sertac Akyuz


This is something I often do, but with list views instead of list boxes. The basic principles are the same, though.

I tend to store the individual items as objects, which are reference types in Delphi. And I keep them all in one main unfiltered list, which owns the objects, while I maintain a filtered list (which does not own the objects) for display purposes. Like @Sertac, I combine this with a virtual list view.

To see how this works in practice, create a new VCL application and drop a list view (lvDisplay) and an edit control (eFilter) on the main form:

Screenshot of main form with edit and list view control

Notice I have added three columns to the list view control: "Name", "Age", and "Colour". I also make it virtual (OwnerData = True).

Now define the class for the individual data items:

type
  TDogInfo = class
    Name: string;
    Age: Integer;
    Color: string;
    constructor Create(const AName: string; AAge: Integer; const AColor: string);
    function Matches(const AText: string): Boolean;
  end;

where

{ TDogInfo }

constructor TDogInfo.Create(const AName: string; AAge: Integer;
  const AColor: string);
begin
  Name := AName;
  Age := AAge;
  Color := AColor;
end;

function TDogInfo.Matches(const AText: string): Boolean;
begin
  Result := ContainsText(Name, AText) or ContainsText(Age.ToString, AText) or
    ContainsText(Color, AText);
end;

And let us create the unfiltered list of dogs:

TForm1 = class(TForm)
  eFilter: TEdit;
  lvDisplay: TListView;
  procedure FormCreate(Sender: TObject);
  procedure FormDestroy(Sender: TObject);
private
  FList, FFilteredList: TObjectList<TDogInfo>;
public
end;

where

function GetRandomDogName: string;
const
  DogNames: array[0..5] of string = ('Buster', 'Fido', 'Pluto', 'Spot', 'Bill', 'Rover');
begin
  Result := DogNames[Random(Length(DogNames))];
end;

function GetRandomDogColor: string;
const
  DogColors: array[0..2] of string = ('Brown', 'Grey', 'Black');
begin
  Result := DogColors[Random(Length(DogColors))];
end;

procedure TForm1.FormCreate(Sender: TObject);
var
  i: Integer;
begin

  FList := TObjectList<TDogInfo>.Create(True); // Owns the objects

  // Populate with sample data
  for i := 1 to 1000 do
    FList.Add(
      TDogInfo.Create(GetRandomDogName, Random(15), GetRandomDogColor)
    );

  FFilteredList := FList;

  lvDisplay.Items.Count := FFilteredList.Count;
  lvDisplay.Invalidate;

end;

procedure TForm1.FormDestroy(Sender: TObject);
begin
  if FFilteredList <> FList then
    FreeAndNil(FFilteredList);
  FreeAndNil(FList);
end;

The idea is that the list view control always displays the FFilteredList, which either points to the same object instance as FList, or points to a filtered (or sorted) version of it:

// The list view's OnData event handler
procedure TForm1.lvDisplayData(Sender: TObject; Item: TListItem);
begin

  if FFilteredList = nil then
    Exit;

  if not InRange(Item.Index, 0, FFilteredList.Count - 1) then
    Exit;

  Item.Caption := FFilteredList[Item.Index].Name;
  Item.SubItems.Add(FFilteredList[Item.Index].Age.ToString);
  Item.SubItems.Add(FFilteredList[Item.Index].Color);

end;

// The edit control's OnChange handler
procedure TForm1.eFilterChange(Sender: TObject);
var
  i: Integer;
begin

  if string(eFilter.Text).IsEmpty then // no filter, display all items
  begin
    if FFilteredList <> FList then
    begin
      FreeAndNil(FFilteredList);
      FFilteredList := FList;
    end;
  end
  else
  begin
    if (FFilteredList = nil) or (FFilteredList = FList) then
      FFilteredList := TObjectList<TDogInfo>.Create(False); // doesn't own the objects
    FFilteredList.Clear;
    for i := 0 to FList.Count - 1 do
      if FList[i].Matches(eFilter.Text) then
        FFilteredList.Add(FList[i]);
  end;

  lvDisplay.Items.Count := FFilteredList.Count;
  lvDisplay.Invalidate;

end;

The result:

Screenshot of populated list, no filtering

Screenshot of filtered list

Notice that there always is only one in-memory object for each dog, so if you rename a dog, the changes will reflect in the list view, filtered or not. (But don't forget to invalidate it!)

like image 39
Andreas Rejbrand Avatar answered Sep 19 '22 12:09

Andreas Rejbrand