Sortowanie i filtrowanie tabel.
Techniki łatwego ponownego użycia



Oprócz informacji na temat sortowania i filtrowania tabel będą tu przedstawione nieco bardziej zaawansowane sposoby programowania, pełną garścią czerpiące z takich "narzędzi" jak interfejsy, klasy abstrakcyjne, MVC i generics. Pozwoli to na łatwe tworzenie nowych aplikacji (działających na róznych zestawach danych) poprzez ponowne użycie dużych fragmentów już napisanego kodu.

1.  Sort - najprościej

Java w wersji 6 zapewnia  łatwe w użyciu i elastyczne środki sortowania i filtrowania tabel.
Po to by móc sortować tabelę wystarczy użyć metody:
tabela.setAutoCreateRowSorter(true);
Posortowanie kolumny uzyskujemy poprzez kliknięcie w kolumnę nagłówka tabeli. Kolejne kliknięcia zmieniają porządek sortowania (rosnąco-malejąco).
Poniższy kod ilustruje najprostszy sposób sortowania najprostszej tabeli.

public class Tab1 extends JFrame implements ActionListener {
  
  public Tab1() {
    setDefaultCloseOperation(EXIT_ON_CLOSE);

    // Dane przykładowej tabeli
    Object[][] data = { { "Chopin Fryderyk",  "M", 1789 }, 
                        { "Kopernik Mikołaj", "M", 2900 },
                        { "Słowacki Juliusz", "M",  2000 },
                        { "Szymborska Wisława", "K", 5200 },
                        { "Polański Roman", "M", 3000 },
                        { "Skłodowska-Curie Maria", "K",  2000 },
                        { "Modrzejewska Helena", "K", 2000 },
                      };
    // Nazwy kolumn
    String[] columnNames = { "Nazwisko i imię", "Płeć", "Pensja" };
        
    JTable tab = new JTable(data, columnNames);
    tab.setAutoCreateRowSorter(true);
    add (new JScrollPane(tab));
    pack();
    setVisible(true);
  }


  public static void main(String[] args) {
    new Tab1();
  }  
  
}

Wynik sortowania:

rys1
Uwaga: dane posortowane wg nazwiska i i imienia; aktualną kolumnę i porządek sortowania wskazuje ikonka w nagłówku.

2. Klucze sortowania


W jaki sposób odbywa się sortowanie?
Otóż wywołanie metody setAutoCreateRowSorter(true) tworzy obiekt klasy TableRowSorter (dziedziczącej klasę DefaultRowSorter, implementującą interfejs RowSorter), odpowiednio go konfiguruje i ustala jako "sorter" dla tabeli.

Przy sortowaniu sorter posługuje się kluczami sortowania. Jest to lista obiektów typu SortKey. Każdy SortKey zawiera indeks kolumny, której dane mają być sortowane i porządek ich sortowania (rosnąco-malejąco).
Pierwszy element na liście wskazuje pierwszy klucz sortowania (kolumnę, której dane podlegają porządkowaniu w pierwszej kolejności), drugi - kolumnę, wedle której będą porządkowane wiersze, mające takie same wartości w tej pierwszej kolumnie itd. Domyślnie dostępne są  maksymalnie trzy klucze sortowania, ale można to zmienić używając metody setMaxSortKeys(int n) z klasy DefaultRowSorter.

Kliknięcie w nagłówek kolumny powoduje wywołanie metody toggleSortOrder(int kolumna). Metoda ta:
Oznacza to,  że kolejne kliknięcia w różne kolumny zmieniają listę kluczy sortowania. Poniższy kod źródłowy  pozwala prześledzić te zmiany.


public class Tab2 extends JFrame  {
  
  public Tab2() {
    Object[][] data = { { "Chopin Fryderyk",  "M", 1789 },
                        { "Chopin Fryderyk",  "M", 3000 },
                        { "Kopernik Mikołaj", "M", 2900 },
                        { "Słowacki Juliusz", "M",  2000 },
                        { "Szymborska Wisława", "K", 3200 },
                        { "Polański Roman", "M", 3000 },
                        { "Skłodowska-Curie Maria", "K",  2000 },
                        { "Skłodowska-Curie Maria", "K",  3000 },
                        { "Modrzejewska Helena", "K", 2000 },
    };
    String[] columnNames = { "Nazwisko i imię", "Płeć", "Pensja" };
        
    final JTable tab = new JTable(data, columnNames);
    tab.setAutoCreateRowSorter(true);
    
    final JTextArea report = new JTextArea(20, 40);

    // Nasłuch kliknięć w kolumny
    tab.getTableHeader().addMouseListener( 
        new MouseAdapter() {
          @Override
          public void mouseClicked(MouseEvent e) {
            JTableHeader head = (JTableHeader) e.getSource(); 
            int col = head.columnAtPoint(e.getPoint());
            report.append("Kliknięcie w kolumnę '" + tab.getColumnName(col) + 
                          "'\nDane posortowane wg:\n" );
            TableRowSorter tsorter = (TableRowSorter) tab.getRowSorter();
            List<SortKey> sortKeys =  tsorter.getSortKeys();
            for (SortKey key : sortKeys) {
              report.append("-- " +tab.getColumnName(key.getColumn()) + 
                            " w porządku " + key.getSortOrder() + '\n' );
            }

          }    
        }
    );

    tab.setPreferredScrollableViewportSize(tab.getPreferredSize());
    add (new JScrollPane(tab), "North");
    add(new JScrollPane(report));
    
    setDefaultCloseOperation(EXIT_ON_CLOSE);
    pack();
    setLocationRelativeTo(null);
    setVisible(true);
  }

  public static void main(String[] args) {
    new Tab2();
  }
  
}
Efekt działania tego kodu po kolejnych kliknięciach w kolumny 'Pensja', 'Nazwisko i imię',  'Płeć', 'Płeć' wygląda następująco:

rys2
 

W programie tym pokazano przy okazji jak można uzyskać informacje, dotyczące:

Oczywiście, jak zresztą zobaczymy dalej, wszystkie te właściwości można ustalać za pomocą odpowiednich metod set...

Jeszcze jedna uwaga, dotycząca kodu: przy jego kompilacji wystąpią ostrzeżenia, dotyczące niesprawdzonych konwersji. Rzecz w tym, że klasy i metody związane z sortowaniem i filtrowaniem są "silnie" sparametryzowane. Pominęliśmy na razie te parametry typów, ponieważ w omawianym przykładzie nie przynoszą one żadnego pożytku, a - co gorsza - generyzacja klas jest tutaj dość rozbudowana. Dla porządku jednak, aby uniknąć ostrzeżeń kompilatora należałoby napisać tak:
            TableRowSorter<? extends TableModel> tsorter = 
              (TableRowSorter<? extends TableModel>) tab.getRowSorter();
            List<? extends SortKey> sortKeys =  tsorter.getSortKeys();

Pokazany na rysunku porządek uzyskaliśmy klikaniem w kolejne kolumny. Czasem jest to jednak niewystarczające, albo zbyt nudne.
Zamiast klikać możemy np. zapisać sekwencję wywolań metody toggleSortOrder(...):
            tsorter.toggleSortOrder(2);
            tsorter.toggleSortOrder(0);
            tsorter.toggleSortOrder(1);
            tsorter.toggleSortOrder(1);
Można by tę sekwencję przypisać do jakiegoś skrótu klawiaturowego lub przycisku - i jednym ruchem uzyskamy to, co poprzednio wymagało czterech kliknięc.
Ale nie jest to najlepsze rozwiązanie - prostsze (i bardziej ogólne) będzie bezpośrednie posłużenie się kluczami sortowania. Nie możemy ingerować w aktualną listę kluczy (zwracaną przez getSortKeys()), bo sorter udostępnia wyłącznie jej niemodyfikowalny widok.
Możemy jednak stworzyć własną listę i podać ją sorterowi, powodując natychmiastowe uporządkowanie według ustalonych na niej kryteriów.
 // sortowanie po kluczach
 List<SortKey> sortKeys = Arrays.asList(
     new SortKey[] {
         new SortKey(1, SortOrder.DESCENDING),  // po płci - malejąco
         new SortKey(0, SortOrder.ASCENDING),   // następnie po nazwisku rosnąco
         new SortKey(2, SortOrder.ASCENDING),   // następnie po pensji rosnąco
     });
 tsorter.setSortKeys(sortKeys);       
Klikanie jest też niewystarczające.
Nie przywrócimy, na przykład, w ten sposób inicjalnego (nieposortowanego) stanu danych.
Aby to zrobić należy podać sorterowi pustą listę kluczy, np:
// Przywrócenie niuporządkowanego stanu danych              
TableRowSorter tsorter = (TableRowSorter) tab.getRowSorter();
List<SortKey> sortKeys =  new ArrayList<SortKey>();
tsorter.setSortKeys(sortKeys);
A dalej - czy użytkownik naszej aplikacji zawsze ma mieć możliwość sortowania każdej kolumny tabeli przez kliknięcie  w nagłówek?
Może chcielibyśmy udostępnić mu bardziej wyszukany i specjalnie dostosowany interfejs sortowania, a zabronić klikania w kolumny nagłówka?
Jest i na to sposób.
Wystarczy ustalić sortowalność kolumn (wszystkich lub wybranych) na false za pomoca metody setSortable(int col) z klasy DefaultRowSorter:
    // Znieść sortowalnośc kolumn przez toggleSort()
    TableRowSorter tsorter = (TableRowSorter) tab.getRowSorter();
    for (int i=0; i < tab.getColumnCount(); i++)
      tsorter.setSortable(i, false);
Teraz klikanie w kolumny nie będzie miało żadnego efektu. Również metoda toggleSortOrder(...) nie będzie działać. Nie wyklucza to jednak sortowania za pomocą ustalenia listy kluczy metodą setSortKeys(...) i dzięki temu możemy dostarczyć wyspecjalizowanego, dostosowanego do potrzeb aplikacji, interfejsu sortowania przy jednoczesnym zablokowaniu możliwości porządkowania tabeli przez kliklanie w kolumny nagłówka.

3. Ikony porządku

Wreszcie: może domyślne ikonki pokazujące porządek sortowania w kolumnie nie odpowiadają naszym potrzebom. Łatwo można je zmienić za pomocą odwołań:
UIManager.put("Table.ascendingSortIcon", iconA);
UIManager.put("Table.descendingSortIcon", iconB);
gdzie : iconA i iconB są typu Icon (klas implementujących interfejs Icon, np. ImageIcon, której obiekty stanowią ikonki z plików graficznych).
Bardzo łatwe jest też pokolorowanie domyślnych ikonek. W tym celu nalezy użyć klasy SortArrowIcon z pakietu sun.swing.icon, np:
import sun.swing.icon.*;
// ...
UIManager.put("Table.ascendingSortIcon", new SortArrowIcon(true, Color.RED);
Tutaj true oznacza, że ikona dotyczy porządku rosnącego. Dla malejącego nalezy użyć słówka false.
Bardziej zaawansowane sposoby wizualizacji uporządkowania danych w tabeli uzyskamy poprzez zdefiniowanie własnych wykreślaczy nagłówka tabeli )dla poszczególnych kolumn).

4. Komparatory


Czy wiemy już wszystko co trzeba?
Czy możemy już być zadowoleni z omówionych możliwości i sortowania przykładowej tabeli?
Absolutnie nie.

Zobaczmy co by się stało, gdyby "pensje" pracowników w tabeli ustalić troszkę inaczej. Dajmy Chopinowi 3, a Kopernikowi 10. Wynik sortowania wyglądałby następująco:


Rys. 3

Nie uzyskaliśmy właściwego porządku. Dlaczego?
Aby odpowiedzieć na to pytanie trzeba rozważyć sposób stosowania przez TableRowSorter kryteriów sortowania.

TableRowSorter opiera porządkowanie na zastosowaniu komparatorów.
Przypomnijmy, że komparator jest obiektem służącym do porównywania dwóch elementów zestawu danych. Dokonuje się tego za pomocą metody compare(T o1, T o2) implementowanej w klasie komparatora. Sortowanie polega na kolejnym porównywaniu ze sobą dwóch elementów (wg takiego czy innego algorytmu), zatem właśnie stosowany komparator przesądza o jego wyniku.

Komparatory dla poszczególnych kolumn możemy pobierać za pomocą metody getComparator(int col) i ustalać za pomocą metody setComparator(int col, Comparator) z klasy DefaultRowSorter.

Zobaczmy więc jakie komparatory stosuje wobec kolumn naszej tabeli automatycznie (przez setAutoCreate(...)) stworzony sorter:
    // ....
    Object[][] data = { { "Chopin Fryderyk",  "M", 3 }, 
                        { "Kopernik Mikołaj", "M", 2900 },
                         // ...
    };
    String[] columnNames = { "Nazwisko i imię", "Płeć", "Pensja" };
    
    JTable tab = new JTable(data, columnNames);
    tab.setAutoCreateRowSorter(true);
    
    // Zobaczmy komparatory
    TableRowSorter<? extends TableModel> tsorter = 
      (TableRowSorter<? extends TableModel>) tab.getRowSorter();
    for (int i=0; i < tab.getColumnCount(); i++) {
      System.out.println(tsorter.getComparator(i));
    }

    // ...
Otrzymamyw  wyniku:
java.text.RuleBasedCollator@524aa00b
java.text.RuleBasedCollator@524aa00b
java.text.RuleBasedCollator@524aa00b






Okazuje się, że jest to kolator (komparator stosowane do porównywania napisów z uwzględnieniem lokalizacji - czyli ustawień regionalnych). Można sprawdzić, że jest to  kolator uzyskiwany dla domyślnej lokalizacji za pomocą odwołania Collator.getInstance().

Dlaczego taki właśnie został wybrany? Odpowiedź tkwi w modelu danych naszej tabeli.
Modelem jest (tworzony w konstruktorze JTable(Object[][], Object[])) obiekt anonimowej klasy wewnętrznej dziedziczącej AbstractTabelModel. W takim modelu metoda getColumnClass() zwraca Object, a wtedy sorter stosuje domyślny kolator wobec wartości kolumn przekształconych  w napisy metodą toString().
Nie możemy się więc dziwić, że pensje potraktowane jako napisy mają inny porządek niż gdyby były traktowane jako liczby.

Ogólnie, sorter dobiera komparatory wedle następującego algorytmu.
  1. Jeżeli dla kolumny ustalono komparator za pomocą metody setComparator - będzie użyty ten właśnie komparator.
  2. W przeciwnym razie, jeżeli getColumnClass() modelu danych zwraca klasę String, użyty zostanie domyślny kolator (Collator.getInstance()).
  3. W przeciwnym razie jeżeli getColumnClass() modelu danych zwraca klasę, która implementuje interfejs Comparable, zostanie użyty prywatny komparator, stosujący metodę compareTo(...) implementowaną w zwróconej klasie.
  4. W przeciwnym razie, jeżeli dla danej kolumny ustalono sposób przekształcenia w napis za pomocą klasy TableStringConverter, zostanie dokonane to przekształcenie i wobec niego zastosowany domyślny kolator.
  5. W przeciwnym razie stosowany jest domyślny kolator wobec wartości uzyskanych metodą toString.
W naszym przykładzie miał miejsce przypadek 5.
Anlizując algorytm doboru komparatorów możemy "na szybko" rozwiązać problem sortowania pensji na dwa sposoby:

Oba te sposoby nie są właściwe w omawianym przypadku (bowiem mamy kontrolę nad modelem danych). Mogą sie one okazać przydatne w innych okolicznościach i dlatego pokazuję je na prostym przykładzie, zwracając przy okazji uwagę na niektóre pułapki kodowania.

Zacznijmy od własnego komparatora. Należy tu w metodzie compare przekształcić napisy-pensje w liczby całkowite i zwrócić wynik ich porównania.
Wiedząc o parametryzacji komparatorów  mamy silną pokusę by napisać tak:
    // Uwaga: błędne rozwiązanie!
    int col = tab.getColumnModel().getColumnIndex("Pensja");
    tsorter.setComparator(col, new Comparator<String>() {
      public int compare(String s1, String s2) {
        return Integer.parseInt(s1) - Integer.parseInt(s2);
      }
    });
Spotka nas jednak niemiła niespodzianka: przy sortowaniu wystąpi wyjątek ClassCastException.
Stanie się tak dlatego, że komparatorowi przekazywane są dane typu zwracanego przez getColumnClass() modelu. A w naszym przypadku jest to typ Object i przy próbie podstawienia na String wystąpi wyjatek ClassCastException.
Poza tym model danych tworzony ad hoc przez konstruktor JTable(Object[][], Object[]) dopuszcza edycję wszystkich komórek tabeli. Łacno się może okazac więc, że ktoś edytując pensję wprowadził napis nie dający się potraktowac jako reprezentacja liczby całkowitej.
Lepszy (nie powodujący błędów) komparator winien wyglądać tak:

    // .... 
    // Ustalenie komparatora dla pensji
    int col = tab.getColumnModel().getColumnIndex("Pensja");
    tsorter.setComparator(col, new Comparator() {

      public int compare(Object o1, Object o2) {
        int n1, n2;
        try {
           n1 = Integer.parseInt(String.valueOf(o1));
        } catch (NumberFormatException exc) {
           n1 = Integer.MIN_VALUE;  // nie-liczby będą na końcu
        }
        try {
          n2 = Integer.parseInt(String.valueOf(o2));
        } catch (NumberFormatException exc) {
          n2 = Integer.MIN_VALUE;  // nie-liczby będą na końcu
        }
        return n1 - n2;
      }
      
    });
    // ...
Z tym komparatorem porządkowanie po pensjach będzie już właściwe, ale oczywiście jest to złe rozwiązanie, bowiem tracimy znacznie na efektywności przy przekształcebniach Object->String->int.

5. Konwertery napisowe


Drugi sposób szybkiej naprawy wadliwego porządku pensji może polegać na zdefiniowaniu odpowiedniego TableStringConvertera.
Implementując abstrakcyjną  metodę tej klasy:

    String toString(TableModel model, int row, int column)

określamy reprezentację napisową elementów modelu danych.

Będzie ona brana pod uwagę przy sortowaniu gdy tylko ustalimy konwerter dla sortera za pomocą metody setStringConverter. Pokazuje to poniższy kod żródlowy


    final DecimalFormat df = new DecimalFormat("000000");
    
    tsorter.setStringConverter( new TableStringConverter() {

      @Override
      public String toString(TableModel model, int row, int col) {
        Object elt = model.getValueAt(row, col);
        if (col == 2) return df.format(elt);
        else return elt.toString(); 
      }

    });
Tutaj metoda format z klasy Format (którą dziedziczy DecimalFormat) formatuje obiekty  jako sześciocyfrowe  liczby całkowite z wiodącymi zerami (o tym mówi wzorzec w konstruktorze DecimalFormat).
Dotyczy to tylko elementów modelu, które są pensjami (trzecia kolumna modelu danych:  if (col == 2) ... ). W pozostałych przypadkach StringConverter zwraca wartości uzyskiwane metodą toString().
W ten sposób pensje (mniejsze od 999999) będą dobrze porządkowane, a inne kryteria sortowania też będą działać właściwie (bo dane są napisami).
 


6. Model danych tabeli - klucz do właściwego sortowania i filtrowania


Oba przedstawione powyżej rozwiązania problemu wadliwego porządkowania pensji są niedobre, ponieważ wymagają dodatkowych przekształceń danych, co zmniejsza efektywność sortowania.
Właściwym rozwiązaniem jest stworzenie odpowiedniego modelu danych tabeli.
Jest to zresztą zasada ogólna: w realnych aplikacjach zawsze należy posługiwać się własnym modelem danych.
Własny model danych dostarcza odpowiednich. właściwych  informacji o typach danych w poszczególnych kolumnach. W związku z tym sorter może posłużyć się gotowymi, efektywnymi metodami compareTo z klas danych.
Ponadto ewentualne definiowanie własnych komparatorów jest "dziedzinowo" zorientowane i bardziej przejrzyste.

Zróbmy więc model danych dla  tabeli opisującej "pracowników". Przy okazji łatwo będzie rozbudowac poprzednie przykłady o takie właściwości pracowników jak data urodzenia czy adres.
Oczywiście, pracownicy w programie powinni być obiektami. Kod źródłowy przedstawia ich klasę.

public class Employee 
      implements Serializable, Comparable<Employee> {
  
  private String name;
  private Address address;
  private Date birthDate;
  private Integer salary;
 
  public Employee(String name, Address a, Date birthDate, 
                  Integer salary) {
    super();
    this.name = name;
    address = a;
    this.birthDate = birthDate;
    this.salary = salary;
  }
  
  public Employee() {
  }

  // gettery i setery, np:

  public Date getBirthDate() {
    return birthDate;
  }
  
  
  public void setBirthDate(Date birthDate) {
    this.birthDate = birthDate;
  }
  
  // itd. ...
  // ...

  // standardowe implementacje metod hashCode() i equals()
  @Override
  public int hashCode() {
    // ... 
  }

  @Override
  public boolean equals(Object obj) {
    // ...
  } 

  // Naturalny porządek
  public int compareTo(Employee o) {
    // ...
  }
Łatwo się domyślić, że w poszczególnych kolumnach tabeli pracowników będziemy chcieli przedstawiać dane, opisywane przez pola klasy Employee. Mamy tu pola typów String, Date, Integer. Wymienione klasy implementują interfejs Comparable, zapewniając naturalny porządek, wobec tego sorter tabeli będzie automatycznie to wykorzystywał. Mamy też  pole typu Address. Tę klasę trzeba zdefiniować i nie zapomnieć przy tym o właściwiej implementacji interfejsu Comparable.  Oto fragmenty definicji klasy Address.

public class Address 
       implements Serializable, Comparable<Address> {
  
  private String town;
  private String street;
  private int nr;
  
  public Address(String town, String street, int nr) {
    this.town = town;
    this.street = street;
    this.nr = nr;
  }
  
  // ...

  // gettery i settery (dla wszystkich pól)
  // na przykład:
  public int getNr() {
    return nr;
  }
  
  public void setNr(int nr) {
    this.nr = nr;
  }

  // ...
  
  // Ważna metoda toString()! 
  public String toString() {
    return town + "; " + street + "; " + nr;  
  }

  // Naturalny porządek
  public int compareTo(Address a) {
    int r = town.compareTo(a.town);
    if (r == 0) r = street.compareTo(a.street);
    if (r == 0) r = nr - a.nr;
    return r;
  }

}

Możemy teraz przystąpić do definiowania modelu danych tabeli.
Zrobimy to w taki sposób, aby łatwo tworzyć modele dal różnych danych i by mogły one być wykorzystywany w uniwersalnym GUI (mogącym obsłużyć różne modele). W tym celu zdefiniujemy klasę RowListModel, która opisuje model tabeli dla dowolnych danych, reprezentowanych jako lista obiektów. Każdy element listy (obiekt) będzie pokazywany w odrębnym wierszu tabeli.


Klasa RowListModel
import java.util.*;
import javax.swing.table.*;

public abstract class RowListModel<E> extends AbstractTableModel {

  private List<String> colNames;
  private List<E> rows;

  public RowListModel(List<E> elist, String[] cols) {
    rows = new ArrayList<E>(elist);
    colNames = Arrays.asList(cols);
  }

  @Override
  public int getColumnCount() {
    return colNames.size();
  }

  @Override
  public int getRowCount() {
    return rows.size();
  }

  @Override
  public String getColumnName(int column) {
    return colNames.get(column);
  }

  @Override
  public Class<?> getColumnClass(int c) {
    return getValueAt(0, c).getClass();
  }

  public E getRow(int r) {
    return rows.get(r);
  }

  public void addRow(E newVal) {
    rows.add(newVal);
    this.fireTableDataChanged();
  }

}

Teraz aby stworzyć model danych dla tabeli pracowników wystarczy zdefiniowac tylko dwie metody: getValueAt i setValueAt:

public class EmpTabModel extends RowListModel<Employee> {
  
  
  public EmpTabModel(List<Employee> elist, String[] cols) {
    super(elist, cols);
  }

  @Override
  public Object getValueAt(int r, int c) {
    Employee e = getRow(r);
    switch (c) {
      case 0 : return e.getName();
      case 1 : return e.getAddress();
      case 2 : return e.getBirthDate();
      case 3 : return e.getSalary();
      default : return null;
    }
  }
  
  @Override
  public void setValueAt(Object val, int r, int c) {
    Employee emp = getRow(r);
    switch (c) {
      case 0 :  emp.setName((String) val); break;
      case 1 : emp.setAddress((Address) val); break;
      case 2 : emp.setBirthDate((Date) val); break;
      case 3 : emp.setSalary((Integer) val); break;
      default : break;
    }
    this.fireTableCellUpdated(r, c);
  }
 
}

Aby zobaczyć jak działa przygotowany model danych w połączeniu z sortowalną tabelą użyjemy przykładowej aplikacji GUI.  Jest ona przygotowana w taki sposób, by można było także prześledzić dodatkowe niuanse sortowania, jak również filtrowanie tabeli (o czym będzie dalej).

Działanie aplikacji przedstawia poniższy rysunek.

Przykładowa aplikacja - tablica z modelem danych EmpTabModel
rys. 4 

Jak widać sortowanie adresów działa bez zarzutu bez żadnych dodatkowych starań z naszej strony (bo klasa Address implementuje właściwą metodę compareTo() i jej właśnie używa sorter). Ponadto daty pokazywane są we właściwym zlokalizowanym formacie, a liczby - są liczbami, co widac z wyrównania do prawej. Wystarczyło ustalić model danych dla tabeli i gotowe.

7. Uniwersalne GUI  sortowania i filtrowania tabel

Kod GUI napisany jest w sposób uniwersalny, tak by mógł obsługiwać różne modele danych.

Uniwersalne GUI sortowania i filtrowania tabel
import java.awt.*;
import java.awt.event.*;
import java.util.*;
import javax.swing.*;
import javax.swing.table.*;

import static tabsort.GuiUtils.*;


public class TableGUI<M extends RowListModel<E>, E> extends JFrame implements ActionListener {
  
  private static ImageIcon specFiltersImg = 
    new ImageIcon(tabsort.TableGUI.class.getResource("images/help.gif")); 

  // Model danych
  private M model;
  // Tabela
  private JTable table;
  // Sorter
  private TableRowSorter<M> sorter;
  private FilterDialog<M> filterDialog;
  private SortPanel sortPanel;
  

  public TableGUI(String title, final M model) {
    super(title);
    this.model = model;
    setDefaultCloseOperation(DISPOSE_ON_CLOSE);
       
    // Utworzenie sortera
    sorter =  new TableRowSorter<M>(model);
    
    // Tabela i ustalenie sortera
    table = new JTable(model);
    table.setRowSorter(sorter);
    
    // Szybkie wyrównanie dat (jesli są) do prawej (bez własnego renderera)
    TableCellRenderer cellRenderer = table.getDefaultRenderer(Date.class);
    ((JLabel) cellRenderer).setHorizontalAlignment(JLabel.RIGHT);
    

    table.addMouseListener(new MouseAdapter() {

      @Override
      public void mouseReleased(MouseEvent e) {
        if (e.isAltDown()) {
          int r = table.rowAtPoint(e.getPoint());
          r = table.convertRowIndexToModel(r);
          SimpleDialog.show(TableGUI.this, "Edycja", model, r);
        }
        else if (e.isMetaDown()) {
          JPopupMenu pm = getPopupSortKeysWindow();
          pm.show(e.getComponent(), e.getX(), e.getY());
        }
      }

    });

    add(new JScrollPane(table));
     
    Box controls = Box.createHorizontalBox();
    controls.setBorder(BorderFactory.createEmptyBorder(10, 5, 5, 5));
    Dimension filler = new Dimension(5,5);
    controls.add(button("Sort ...", "cmd:sort", this));
    controls.add(Box.createRigidArea(filler));
    controls.add(button("Filter ...", "cmd:filter", this));
    controls.add(Box.createGlue());
    controls.add(button("Add record ...", "cmd:add", this));
    controls.add(Box.createRigidArea(filler));
    controls.add(check("Update on insert ?", "cmd:updateable", this));
    controls.add(Box.createGlue());
    controls.add(label(specFiltersImg, "hint:Special filters",  showSpecialsPopupMenu));
    add(controls, "South");
  }
  
  
  public M getModel() {
    return model;
  }
  
  public TableRowSorter<M> getSorter() {
    return sorter;
  }
  
  public JTable getTable() {
    return table;
  }

  private JPopupMenu getPopupSortKeysWindow() {  // panel sortowania dostępny jako popup
    final JPopupMenu popup = new JPopupMenu();
    if (sortPanel == null) sortPanel = new SortPanel(table);
    sortPanel.setCloseButton(true);
    popup.add(sortPanel);
    return popup;
  }
    

  public void actionPerformed(ActionEvent e) {
    String cmd = e.getActionCommand();
    M model = ((M) table.getModel());

    if (cmd.equals("add")) {
      // obsługa dodoawnia nowego rekordu
      
    } else if (cmd.equals("filter")) {  // dialog filtrowania
      if (filterDialog == null) filterDialog = 
          new FilterDialog<M>(TableGUI.this, "Filtrowanie", model);
      RowFilter<? super M, ? super Integer> filtr = filterDialog.getFilter();
      sorter.setRowFilter(filtr);
      
    } else if (cmd.equals("sort")) {  // dialog sortowania
      JDialog sortDialog = new JDialog(TableGUI.this, "Sortowanie", true);
      if (sortPanel == null) sortPanel = new SortPanel(table);
      sortPanel.setCloseButton(false);
      sortDialog.add(sortPanel);
      sortDialog.pack();
      sortDialog.setLocationRelativeTo(TableGUI.this);
      sortDialog.setVisible(true);
      
    } else if (cmd.equals("updateable")) {
      JToggleButton tb = (JToggleButton) e.getSource();
      sorter.setSortsOnUpdates(tb.isSelected());
    }

  }
  
  // Menu dla specjalnych akcji, które można okreslić z zewnątrz
  private JPopupMenu specialsPopupMenu;
  
  MouseListener showSpecialsPopupMenu = new MouseAdapter() {
    public void mouseClicked(MouseEvent e) {
      if (specialsPopupMenu == null) return;
      Dimension d = specialsPopupMenu.getPreferredSize();
      specialsPopupMenu.show(e.getComponent(), 
                            e.getX()-d.width , e.getY()-d.height);
    }
  };

  public void setSpecialsPopupMenu(Action[] specialActions) {
    specialsPopupMenu = new JPopupMenu();
    for (Action action : specialActions) {
      specialsPopupMenu.add(action);
    }
  }
  
}
Uwagi:

Następujący kod źródłowy pokazuje wykorzystanie odpowiedniego modelu danych i uniwersalnego GUI w konkretnej aplikacji.

Aplikacja  tablicowa - "Pracownicy"
// odpowiednie importy

public class EmployeeApp {
  
  private TableGUI<EmpTabModel, Employee> gui;
  
  public EmployeeApp() {
    // Metoda genarate tworzy w sposób losowy lisę pracowników
    // tu o rozmiarze 100 elementów
    List<Employee> elist = Employee.generate(100);
    
    // Nazwy kolumn
    String[] colNames =  { "Nazwisko i imię", "Adres",  
                           "Data urodzenia", "Pensja" };
    
    // Utworzenie modelu danych
    EmpTabModel model = 
      new EmpTabModel(elist,  colNames);

    // Utworzenie GUI   
    gui =  new TableGUI<EmpTabModel, Employee>("Aplikacja tablicowa - pracownicy",
                                       model);
    
    gui.setSpecialsPopupMenu(specialActions);
    gui.pack();
    gui.setLocationRelativeTo(null);
    gui.setVisible(true);
  }
  
  
  public static void main(String[] args) {
    try {
      UIManager.setLookAndFeel(
          "com.jgoodies.looks.plastic.PlasticXPLookAndFeel"
          );
    } catch (Exception e) {
      e.printStackTrace();
    }
    SwingUtilities.invokeLater( new Runnable() {
      public void run() {
        new EmployeeApp();
      }
    });
  }

}

8. Model + uniwersalne GUI = łatwe dostosowania i modyfikacje


Dzięki zastosowanemu podejściu (prosty model danych i uniwersalne GUI) bardzo łatwo i szybko można tworzyć sortowalne i filtrowalne tabele dla innych niż przykład pracowników przypadków. Oto w kilka minut możemy zbudować podobną do poprzedniej aplikację, tyle że teraz dotyczącą samochodów. Jej dzialanie pokazuje rysunek.

Dzialanie aplikacji tablicowej - samochody (posortowana kolumna "Rocznik")

rys5

Poniższe kody źródłowe są wszystkim, co potrzebne, aby uzyskać taką funkcjonalność.

Klasa samochodów

import javax.swing.*;

public class Car {
  private String make;
  private String model;
  private Integer year;
  private Integer price;
  private Icon icon;
  
  public Car(String make, String model, Integer year, Integer price, Icon icon) {
    super();
    this.make = make;
    this.model = model;
    this.year = year;
    this.price = price;
    this.icon = icon;
  }
  
  public String getMake() {
    return make;
  }
  
  public void setMake(String make) {
    this.make = make;
  }
 
  public String getModel() {
    return model;
  }
  
  public void setModel(String model) {
    this.model = model;
  }

  public Integer getPrice() {
    return price;
  }

  public void setPrice(Integer price) {
    this.price = price;
  }
  
  public Integer getYear() {
    return year;
  }
  
  public void setYear(Integer year) {
    this.year = year;
  }
  
  public Icon getIcon() {
    return icon;
  }
  
  public void setIcon(Icon icon) {
    this.icon = icon;
  }
  
}

Model danych dla tabeli samochodów

public class CarTabModel extends RowListModel<Car> {
  
  
  public CarTabModel(List<Car> elist, String[] cols) {
    super(elist, cols);
  }

  @Override
  public Object getValueAt(int r, int c) {
    Car o = getRow(r);
    switch (c) {
      case 0 : return o.getMake();
      case 1 : return o.getModel();
      case 2 : return o.getYear();
      case 3 : return o.getPrice();
      case 4 : return o.getIcon();
      default : return null;
    }
  }
  
  @Override
  public void setValueAt(Object val, int r, int c) {
    Car car = getRow(r);
    switch (c) {
      case 0 :  car.setMake((String) val); break;
      case 1 : car.setModel((String) val); break;
      case 2 : car.setYear((Integer) val); break;
      case 3 : car.setPrice((Integer) val); break;
      case 4 : car.setIcon((Icon) val); break;
      default : break;
    }
    this.fireTableCellUpdated(r, c);
  }
 
}

Aplikacja "samochody"
public class CarApp {
  
  private TableGUI<CarTabModel, Car> gui;
  
  public CarApp() {
    
    Class kl = getClass();     
    ImageIcon icon = new ImageIcon(kl.getResource("images/lockkey.gif"));

    List<Car> clist = Arrays.asList( new Car[] {
        new Car("Plymouth", "Barracuda", 1970, 300, 
            new ImageIcon(kl.getResource("images/plym.jpeg" ))),
        new Car("Lamborghini", "Murcielago", 2007, 1000, 
            new ImageIcon(kl.getResource("images/lamborghini.jpeg"))),
        new Car("Jeep", "Cherokee", 1997, 6, icon),
        new Car("Jeep", "Cherokee", 2001, 12, icon),
        new Car("Fiat", "Spider", 1982, 40, icon),
        new Car("Fiat", "Bravo", 2005, 40, icon),
        new Car("Mitsubishi", "Galant", 2007, 80, 
            new ImageIcon(kl.getResource("images/mitsubishi.jpg"))),
    });
    
    // Nazwy kolumn
    String[] colNames =  { "Marka", "Model",  
                           "Rocznik", "Cena", "Foto" };
    // Utworzenie modelu danych
    CarTabModel model =
      new CarTabModel(clist,colNames);
    
    gui =  new TableGUI<CarTabModel, Car>("Aplikacja tablicowa - samochody", model);
   
    gui.getTable().setRowHeight(70);
    gui.getTable().getColumn("Foto").setWidth(70);

    gui.pack();
    gui.setLocationRelativeTo(null);
    gui.setVisible(true);
  }
  
  
  public static void main(String[] args) {
    try {
      UIManager.setLookAndFeel(
          "com.jgoodies.looks.plastic.PlasticXPLookAndFeel"
          );
    } catch (Exception e) {
      e.printStackTrace();
    }
    
    SwingUtilities.invokeLater( new Runnable() {
      public void run() {
        new CarApp();
      }
    });
  }
}

Zobacz działanie obu aplikacji, uruchamianych z programu testowego MainTest









9. Sortowanie po kluczach

Widoczny na poprzednich rysunkach przycisk "Sort..." otwiera dialog zaawansowanego sortowania.
Pokazuje go poniższy rysunek.

rys. 6

Lista po lewej stronie zawiera nazwy kolumn - dostępne klucze (ten fragment programu również jest generyczny, przygotowany na dzialanie z dowolnymi tabelami - lista uzyskiwana jest dynamicznie  od modelu tabeli).
Aktualne klucze dodajemy do prawej listy przyciskiem >>.
Przycisk "Sort" sortuje tabelę wg wybranych kluczy, "Unsort" przywraca jej nieposortowaną postać.
Domyślnie porządek sortowania jest rosnący, można to zmienić znacznikiem "Descending".

Poniższy kod żródłowy pokazuje obsługę przycisków "Sort" - "Unsort".

  // ...
  // Sorter
  private TableRowSorter<? extends TableModel> tsorter;

  // Model danych prawej listy (wybranych kluczy sortowania)
  private DefaultListModel sortKeysListModel = new DefaultListModel();

  // Lista kluczy sortowania
  private  List<SortKey> sortKeys = new ArrayList<SortKey>();

  private  JRadioButton rbSortOrder = new JRadioButton("Descending");

  // ...
  
  // Obsługa przycisków
  ActionListener sortHandler = new ActionListener() {

    public void actionPerformed(ActionEvent e) {
      String cmd = e.getActionCommand();
      // ...
      if (cmd.endsWith("sort")) {
        sortKeys.clear();                           // zawsze świeża lista
        if (!cmd.startsWith("un")) {                // przycisk "Sort"

          // Porządek sortowania
          SortOrder sortOrder = SortOrder.ASCENDING;
          if (rbSortOrder.isSelected()) sortOrder = SortOrder.DESCENDING;

          // ile kluczy? gdy więcej od 3 trzeba ustalić MaxSortKeys 
          int skn = sortKeysListModel.getSize();
          if (skn > 3) tsorter.setMaxSortKeys(skn);
           
          // Wybrane nazwy kolumn 
          List<?> klist = Collections.list(sortKeysListModel.elements());
           
          // Dla każdej wybranej kolumny ustalamy klucz sortowania
          for (Object col : klist) {
            int ind = tab.getColumnModel().getColumnIndex(col);
            sortKeys.add(new SortKey(ind, sortOrder));
          }
        }
        // Sortowanie 
        // (lub przywróoenie stanu "nieposortwana" gdy przycisk "Insort")
        tsorter.setSortKeys(sortKeys);
      }
      // ...
    }
  };    
Zwrócmy uwagę, że gdy liczba kluczy sortowania przekracza 3, nalezy użyć metody setMaxSortKeys.

10. Edycja


Jeśli chodzi o edycję posortowanej tabeli to warto zwrócić uwagę na pewne niuanse.
Gdy dodajemy nowe rekordy do tabeli domyślnie trafiają one na koniec.
Możemy zmienić to zachowanie (nowy rekord znajdzie się w aktualnym porządku sortowania) używając metody:
   sorter.setSortsOnUpdates(true);

Przy edycji istniejących danych tabeli bardzo ważną kwestią jest uwzględnienie właściwych dla modelu indeksów wierszy. Edytory komórek (w tym domyślne) sprawę załatwiają automatycznie. Jeśli jednak tworzymy jakiś inny edytor, bazujący na zaznaczonym w widoku tabeli wierszu, konwersję pomiędzy indeksem wiersza w widoku i indeksem wiersza w modelu musimy zagwarantować sami. Jest tak dlatego, że po posortowaniu tabeli indeksy wierszy  w widoku nie odpowiadają indeksom w modelu.
Jest to bardzo ważna i wygodna właściwość sortera: porządkowaniu podlega widok, a nie model. Dzięki temu możemy mieć różne widoki na ten sam model, inaczej uporządkowane. Ale oznacza to również, że musimy zapewnić właściwą konwersję indeksów wierszy.
Służy do tego metoda convertRowIndexToModel(...).

Dla przykładu, dodając do tabeli odpowiedni MouseListener możemy na zaznaczonym wierszu alt-kliknięciem  otworzyć dialog edycji, co wygląda tak:


rys.7
 
W kodzie obsługi alt-kliknięcia ważne jest uzyskanie właściwego indeksu wiersza:
// ...
table.addMouseListener(new MouseAdapter() {

      @Override
      public void mouseReleased(MouseEvent e) {
        if (e.isAltDown()) {
          int r = table.rowAtPoint(e.getPoint());
          r = table.convertRowIndexToModel(r);
          SimpleDialog.show(TableGUI.this, "Edycja", model, r);
        }
      }

});
// ...
Edycję (a także wpisywanie nowych rekordów) zapewniamy poprzez własną klasę SimpleDialog (tutaj jej kod nie jest istotny).

11. Filtrowanie wierszy

Filtry służą do pokazywania tylko tych wierszy, które spełniają określone warunki.
Warunki określane są przez metodę boolean include(...), którą należy zdefiniować w klasie dziedziczącej abstrakcyjną klasę RowFilter. Metoda otrzymuje jako argument obiekt typu RowFilter.Entry, od którego można uzyskać informacje dotyczące wiersza i powinna zwracać true, jeśli dany wiersz ma być widoczny, false w przeciwnym razie.
Utworzony obiekt-filtr ustala się za pomocą metody setRowFilter(..) wołanej wobec sortera, co powoduje ukrycie tych wierszy, dla których include zwraca false.

Klasa RowFilter dostarcza gotowych, łatwych w użyciu, filtrów, które uzyskujemy statycznymi metodami numberFilter(...), dateFilter(...) i regexFilter(...). 

Metoda:
 RowFilter.numberFilter(RowFilter.ComparisonType c, Number n, int ... indeksy)
daje filtr, którego metoda include zwraca true tylko dla wierszy, które zawierają choć jedną wartość numeryczną spełniającą kryterium zadawane przez typ prównania (c) i liczbę (n).
Typy porównania to stałe wyliczeniowe z enumeracji RowFilter.ComparisonType:
Jeśli podano indeksy, będą brane pod uwagę tylko wartości wierszy w kolumnach o podanych indeksach, w przeciwnym razie - we wszystkich kolumnach typu Number lub pochodnego od Number.    

Przykład: pokazać tylko tych pracowników, którzy mają pensję > 3000:
// sorter jest sorterem ustalonym dla tabeli pracowników
pensja = 3000;
kolumna = 3;   // indeks kolumny pensji
sorter.setRowFilter(RowFilter.numberFilter(ComparisonType.AFTER, pensja, kolumna));
Tak samo działa gotowy filtr dat, z tym, że porównania dotyczą dat:
RowFilter.dateFilter(RowFilter.ComparisonType c, Date d, int ... indeksy)
Przykład: pokazać tylko tych pracowników, którzy urodzili się przed 1 stycznia 1980.
        SimpleDateFormat dateFormat = 
          new SimpleDateFormat("yyyy-MM-dd");
        // ...
        try {
          Date data = dateFormat.parse("1980-01-01");
          sorter.setRowFilter(RowFilter.dateFilter(ComparisonType.BEFORE, data, 2));
        } catch (ParseException exc) {
           System.out.println("Wadliwa data");
        }

Gotowy filtr regularnych wyrażeń:
RowFilter.regexFilter(String regex, int ... indeksy);
pokazuje tylko te wiersze, w których znajduje się choć jedna wartość pasująca do regularnego wyrażenia regex (dopasowanie metodą find(), a więc odnajdującą wystąpienie, a nie dopasowującą cały tekst jak match()).
Warto zauwazyć, że ten filtr posługuje się wartościami kolumn przekształconymi na napisy za pomocą metody toString(). Zatem przykładowy fragment:
// ...
        String regex = JOptionPane.showInputDialog("Regex:");
        if (regex != null)
           sorter.setRowFilter(RowFilter.regexFilter(regex));
// ...
zastosowany wobec tabeli pracowników da  następujący wynik.

Działanie filtra regex

rys.8
Tutaj np. widzimy p. Stefana Wabackiego dlatego, bo ma napis 80 w końcówce pensji, a p. Tabackiego, bo ma 80 w końcówce roku urodzenia. Zwróćmy uwagę, że nie podaliśmy żadnych indeksów kolumn, dlatego pod uwagę są brane wartości ze wszystkich kolumn.

Tworząc własne filtry musimy odziedziczyć klasę RowFilter i dostarczyć implementacji metody include.
Warto przy tym wiedzieć, że klasa RowFilter jest parametryzowana dwoma parametrami. W zamierzeniu, przy użyciu z TableRowSorterem, pierwszy oznacza typ modelu danych, drugi - typ identyfikatora wiersza (dla TableRowSortera Integer, indeks wiersza).
Dzięki tej parametryzacji w metodzie include bez zbędnych konwersji zawężających  możemy sięgać do modelu danych i do indeksów wierszy. Informacji dostarcza argument metody include - RowFilter.Entry. Klasa ta zawiera metody ich pobierania:
Poniższy kod źródłowy przedstawia filtr, pozostawiający w widoku tabeli tylko tych pracowników, którzy mają podane imię (jedno słowo) i mieszkają przy podanej ulicy (też dla uproszczenia jedno słowo).

        String in = showInputDialog("Street and firstName");
        if (in == null) return;
        final String[] data = in.split(" +");
        RowFilter<EmpTabModel, Integer> filtr = new RowFilter<EmpTabModel, Integer>() {

          @Override
          public boolean include(RowFilter.Entry<? extends EmpTabModel, 
                                                 ? extends Integer> entry) {
            EmpTabModel model = entry.getModel();
            int row = entry.getIdentifier();
            Employee emp = model.getRow(row);
            String[] names = emp.getName().split(" +");
            
            return emp.getAddress().getStreet().equals(data[0].trim()) &&
                   names[1].equals(data[1].trim()); 
          }
          
        };
        JTable table = gui.getTable();
        TableRowSorter<EmpTabModel> sorter = gui.getSorter();
        sorter.setRowFilter(filtr);
Zauważmy, że sorter też jest sparametryzowany, co właśnie wynika z - wygodnej przy pisaniu metody include - parametryzacji filtrów.

Po zastosowaniu filtra  można nie tylko zobaczyć w tabeli efekty filtrowania, łatwo można również uzyskać aktualnie widoczne wiersze, co pokazuje poniższy fragment:
        int rows = sorter.getViewRowCount();
        for (int i=0; i < rows; i++) {
          System.out.println(table.getValueAt(i, 0) + " - " +
                             table.getValueAt(i, 1));
        }
Liczbę widocznych wierszy zwraca metoda sortera getViewRowCount(). Wartości pobieramy metodą getValueAt(int row, int col) z klasy JTable. Należy pamiętać, że  indeksy wierszy i kolumn dotyczą tu widoku, a nie modelu (o co właśnie nam chodzi).
Przykładowy wynik  - wynik filtracji po imionach i ulicach (ulica  "Polna", imię "Jerzy")

Ubacki Jerzy - Poznań; Polna; 111
Gabacki Jerzy - Warszawa; Polna; 76
Ibacki Jerzy - Warszawa; Polna; 73
Abacki Jerzy - Kraków; Polna; 94
Babacki Jerzy - Kraków; Polna; 51

Dowolne filtry (i te gotowe z klasy RowFilter i nasze własne) można łączyć za pomocą metod andFilter i orFilter.
Metoda andFilter łączy filtry, stosując logiczną koniunkcję wobec ich działania (wynikowy filtr będzie pozostawiał w widoku te wiersze, które spełniają kryteria wszystkich filtrów).
Metoda orFilter zwraca wynikowy filtr, który pozostawia wiersze, spełniające warunki zadawane przez którykolwiek z filtrów.
Argumentem metod jest zestaw filtrów, podawany jako referencja do obiektu klasy implementującej Iterable. Zazwyczaj będziemy tu stosowac jakieś standardowe kolekcje (wszystkie implementują interfejs Iterable).
Kod źródłowy pokazuje przykład łączenia filtrów.

Przykład łączenie filtrów:
          String regex = showInputDialog("Enter regex for names");
          if (regex == null) return;
          String number = showInputDialog("Enter minimal salary");
          if (number == null) return;
          int minSal = new Scanner(number).nextInt();
          // Lista filtrów łączonych za pomocą operacji AND lub OR
          List<RowFilter<? super EmpTabModel, ? super Integer>> filterList = 
            new ArrayList<RowFilter<? super EmpTabModel, ? super Integer>>();

          // Wynikowy filtr
          RowFilter<? super EmpTabModel, ? super Integer> rowFilter;
          

          filterList.add(RowFilter.regexFilter(regex, 0));
          filterList.add(RowFilter.numberFilter(ComparisonType.AFTER, minSal, 3));

          rowFilter = RowFilter.andFilter(filterList);
          gui.getSorter().setRowFilter(rowFilter);

Niestety, nieco zawikłana parametryzacja (użycie ? super) jest tu niezbędna,  po to choćby by móc dostarczać filtrów z parametrami <Object, Object>.

Wreszcie, działanie każdego filtru (również uzyskanego metodami andFilter i orFilter mozna odwrócić za pomocą metody RowFilter notFilter(RowFilter).