torsdag den 3. november 2011

Dynamiske filtre i et TcxGrid

I dette indlæg vil jeg vise, hvordan man bygger filtre op dynamisk i et TcxGrid på runtime.

I et TcxGrid er standard funktionaliteten, at man kan vælge en lille drop down på en kolonnes header og vælge, hvilke værdier man vil have grid'et filtreret efter. Når man klikker på checkboxene i den lille drop down, genereres et filter, som bliver synligt i bunden af grid'et.

Her illustreret med et skærmbillede:




For at eksemplet giver mening, skal vi først have fyldt noget data i vores grid. I en anden forbindelse fald jeg over denne database over baseball resultater: http://baseball1.com/statistics/. Den har en fin størrelse, og man behøver ikke at vide noget om baseball for at bruge den. Jeg ved fx intet om det ;o)

For nemt at kunne bruge databasen, har jeg hentet den nyeste Access-version, importeret baseball-databasen hertil, og siden eksporteret tabellen "Batting" ud i en tekstfil.

De første 5 felter i tekstfilen danner datagrundlag for det følgende. Jeg har oprettet en TdxMemtable med en tabel-definition som modsvarer det, der ses i grid'et ovenfor. Det komplette eksempel - med data - kan i øvrigt hentes her.

For at indlæse og parse tekstfilen, har jeg gjort som vist i koden herunder.

Som det ses, bruger jeg en TStringList til parsningen. Det er både nemt og hurtigt. Det tager omkring 1800 ms at parse de knap 94.000  rækker, der er i tekstfilen. Det kan muligvis gøres hurtigere med andre teknikker, men det er ikke vigtigt i forhold til mit eksempel.

procedure TMainForm.LoadData;
var
  Buffer: TStringList;
  LineParser: TStringList;
  Line: String;
begin
  Buffer := TStringList.Create;
  Buffer.LoadFromFile('Batting.txt');
  LineParser := TStringList.Create;
  LineParser.LineBreak := ';';

  dxMemData1.Close;
  dxMemData1.Open;
  DataSource1.DataSet.DisableControls;
  try
    for Line in Buffer do
    begin
      LineParser.Text := Line;
      dxMemData1.Append;
      dxMemData1PlayerID.AsString := LineParser[0];
      dxMemData1YearID.AsString := LineParser[1];
      dxMemData1Stint.AsString := LineParser[2];
      dxMemData1TeamID.AsString := LineParser[3];
      dxMemData1LgID.AsString := LineParser[4];
      dxMemData1.Post;
    end;

  finally
    DataSource1.DataSet.EnableControls;
    FreeAndNil(Buffer);
  end;
end;

Mit mål er nu at opbygge en liste over alle de værdier, der er i en given kolonne, og vise dem i en ChecklistBox. Når der så klikkes på et element i CheckListBoxen skal det slå igennem som et filter på mit grid.

Jeg har valgt at opbygge data til min CheckListBox, når jeg klikker på en kolonne overskrift.

Lad mig starte med at vise proceduren, der opbygger listen over de forskellige værdier fra kolonnen:

procedure TMainForm.BuildFilters(const cxGridDBColumn: TcxGridDBColumn);
var
  FilterStrings: TStringList;
  ValueList: TcxGridFilterValueList;
  i: Integer;
begin
  if cxGridDBColumn = nil then
    exit;

  FilterStrings := TStringList.Create;
  ValueList := cxGridDBColumn.GridView.ViewData.CreateFilterValueList;
  cxCheckListBox1.Tag := Integer(cxGridDBColumn);

  dxMemData1.DisableControls;

  cxCheckListBox1.Items.BeginUpdate;
  cxCheckListBox1.Items.Clear;
  try
    TcxGridDBTableView(cxGridDBColumn.GridView).DataController.Filter.Root.Clear;
    cxGridDBColumn.DataBinding.GetFilterStrings(FilterStrings, ValueList);
    
    for i := 0 to FilterStrings.Count - 1 do
    begin
      if ValueList[i].Kind <> fviValue then
        continue;

      with cxCheckListBox1.Items.Add do
        Text := ValueList[i].DisplayText;
    end;
  finally
    cxCheckListBox1.Items.EndUpdate;
    dxMemData1.EnableControls;
    FreeAndNil(ValueList);
    FreeAndNil(FilterStrings);
  end;
end;

En grid-kolonne (TcxDBGridColumn) har en klasse på sig, kaldet DataBinding. På DataBinding er en funktion, som returnerer en liste over de forskellige værdier, der ligger kolonnen. Funktionen skal have to parametre:

  • En liste af typen TcxGridFilterValueList
  • En TStringlist som er oprettet på traditionel vis

En TcxGridFilterValueList er en liste af TcxFilterValueItem records...:

type
  TcxFilterValueItem = record
    Kind: TcxFilterValueItemKind;
    Value: Variant;
    DisplayText: string;
  end;

... og den kan man få "udleveret" af det view kolonnen ligger på. Sådan her :

var 
  ValueList: TcxGridFilterValueList;

...


  ValueList := cxGridDBColumn.GridView.ViewData.CreateFilterValueList;


Elementernes "Kind"-property angiver, som navnet siger, typen på de enkelte elementer. Den kan have en række forskellige værdier, alt efter hvad der er stoppet i listen. Det vil føre for vidt her at gennemgå alle type'erne her - jeg vil blot nøjes med at koncentrere mig om dem der indeholder en data værdi: Kind = fviValue.

Således har jeg nu med ovenstående BuildFilters-procedure fået udfyldt min CheckListBox i venstre side, så den indeholder de samme elementer (med Kind = fviValue), som i den lille drop down på kolonnens header (billede 1 øverst på siden).



Næste skridt er, at når jeg tilføjer et nyt filter via mit grid, skal det slå igennem ovre i ChecklistBoxen. Helt kort skal man bare implementere et OnchangeEvent på filteret. Jeg vælger mit View i designeren, og trykker F11 for Object Inspectoren. Her vælges DataController-->Filter-->OnChange.


Jeg indsætter flg. kode:

procedure TMainForm.cxGrid1DBTableView1DataControllerFilterChanged(Sender: TObject);

  procedure CheckItemList(Root: TcxFilterCriteriaItemList);
  var
    i, j: Integer;
  begin
    i := 0;
    while i < Root.Count do
    begin
      if (Root.Items[i] is TcxFilterCriteriaItemList) then
        CheckItemList(TcxFilterCriteriaItemList(Root.Items[i]))
      else
        for j := 0 to cxCheckListBox1.Items.Count - 1 do
          if cxCheckListBox1.Items[j].Text = 
            String(TcxFilterCriteriaItem(Root.Items[i]).Value) then
          begin
            cxCheckListBox1.Items[j].Checked := True;
            Break;
          end;

      Inc(i);
    end;
  end;

var
  Filter: TcxDBDataFilterCriteria;
  SavedFilterChangedEvent: TNotifyEvent;
  i: Integer;
begin
  cxCheckListBox1.Items.BeginUpdate;
  try
    for i := 0 to cxCheckListBox1.Items.Count - 1 do
      cxCheckListBox1.Items[i].Checked := False;

    Filter := TcxDBDataFilterCriteria(Sender);
    SavedFilterChangedEvent := Filter.OnChanged;
    Filter.OnChanged := nil;
    CheckItemList(Filter.Root);
    Filter.OnChanged := SavedFilterChangedEvent;
  finally
    cxCheckListBox1.Items.EndUpdate;
  end;
end;

Jeg vil ikke knytte mange kommentarer til proceduren, da der ikke er ret meget i den. Dog vil jeg lige gøre opmærksom på, at min TcxFilterCriteriaItemList kan indeholde to slags elementer: TcxFilterCriteriaItemList og TcxFilterCriteriaItem. Derfor kalder den (rekursive) subprocedure sig selv, hvis den møder en TcxFilterCriteriaItemList i listen. For at undgå et uendeligt loop, sætter jeg Filter's OnchangeEvent til nil, mens jeg arbejder med filteret.


Sidste skridt er at gå den anden vej. Altså når man klikker på et element i ChecklistBoxen, skal det også slå igennem i grid'et, således at begge filtre "følges ad".

Da jeg fyldte data i ChecklistBox'en (mis?)brugte jeg dens Tag-property til at gemme min kolonne's adresse. Det er ikke en praksis jeg kan anbefale, men det var bare en hurtig genvej i mit eksempel.

Jeg har til formålet lavet en procedure, som kan traversere min CheckListBox igennem og opbygge et filter udfra de elementer der er valgt:

procedure TMainForm.ApplyFiltersToGrid(cxGridDBColumn : TcxGridDBColumn);
var
  Filter: TcxDBDataFilterCriteria;
  i: Integer;
  cxCheckListBoxItem: TcxCheckListBoxItem;
  SavedFilterChangedEvent: TNotifyEvent;
begin
  if cxGridDBColumn = nil then
    exit;

  Filter := TcxGridDBTableView(cxGridDBColumn.GridView).DataController.Filter;
  SavedFilterChangedEvent := Filter.OnChanged;

  Filter.OnChanged := nil;
  try
    Filter.BeginUpdate;

    Filter.Root.Clear;
    Filter.Root.BoolOperatorKind := fboOr;

    for i := 0 to cxCheckListBox1.Items.Count - 1 do
    begin
      cxCheckListBoxItem := cxCheckListBox1.Items[i];
      if not cxCheckListBoxItem.Checked then
        continue;

      Filter.Root.AddItem(cxGridDBColumn, foEqual, cxCheckListBoxItem.Text, 
        cxCheckListBoxItem.Text);
    end;

  finally
    Filter.Active := True;
    Filter.EndUpdate;
    Filter.OnChanged := SavedFilterChangedEvent;
  end;
end;

Der er ikke meget at sige om selve proceduren, udover at jeg igen sætter filterets OnChange-event til nil mens jeg arbejder på det, for at undgå et uendeligt loop.

Således kan jeg nu med det ovenstående bygge et filter dynamisk op og anvende det på et grid.

---

Man opdager hurtigt, at når man arbejder med et TcxGrid med mange datarækker i, som fx i det ovenstående, hvor i der er knap 94.000 rækker, at det at tilpasse kolonners brede efter teksten, kan være en tung omgang. Det er også et problem, som er rapporteret til DevExpress nogle gange, men deres forslag er flg.:

procedure TForm1.cxButton1Click(Sender: TObject);
begin
  cxGrid1.BeginUpdate;
  try
    cxGrid1DBTableView1.ApplyBestFit();
  finally
    cxGrid1.EndUpdate;
  end
end;

Dette er næsten 14 sekunder (13.600 ms) om at blive udført. I min optik er det uholdbart lang tid, fordi det gør det stort set umuligt at lave skalerbare applikationer, hvis der er RIGTIGT mange datarækker i grid'et. Men "Der er ingen sløsning, når Borrisholt finder en løsning" - så fortvivl ikke ;o) Jeg har en løsning på problemet, således at den ovenstående kode kan udføres på 250 ms, altså godt og vel en faktor 54.

Det komplette eksempel, med data, kan i øvrigt hentes her.

Den løsning bliver så genstand for mit næste indlæg. Stay tuned!

Jens Borrisholt

Ingen kommentarer:

Send en kommentar