Sort table rows
Sorting tables is a common task in Swing applications. JSE 6 has added a sorting and filtering mechanism to JTable, using RowSorter.
The following example uses JSE 1.4. It sorts a particular table using any column, and in any direction (ascending order or descending order). Much of this example can be applied to any table, with the exception of the Comparator implementations, which are specific to the underlying data.
The example uses a small JTable having data related to movies - title, date viewed, and so on.
The implementation is split between 3 classes :
- Movie - a Model Object which does most of the work by defining several Comparator classes (which do the actual sorting)
- MovieTableModel - a custom TableModel which wraps a Model Object, and decides which Comparator to use
- MainWindow - contains the JTable, and uses a MouseAdapter to listen for clicks on the table's column header
Here is the Movie class.
package hirondelle.movies.edit; import java.math.BigDecimal; import java.util.*; import hirondelle.movies.exception.InvalidInputException; import hirondelle.movies.util.Util; /** Data-centric class encapsulating all fields related to movies. <P>This class exists in order to encapsulate, validate, and sort movie information. This class is used both to validate user input, and act as a 'transfer object' when interacting with the database. <P><b>This class would greatly benefit from a JUnit test class, to test its data validation and sorting.</b> */ final class Movie implements Comparable<Movie>{ /** Constructor taking regular Java objects natural to the domain. <P>When the user has entered text, this constructor is called indirectly, through {@link #Movie(String, String, String, String, String)}. @param aId optional, the database identifer for the movie. This item is optional since, for 'add' operations, it has yet to be assigned by the database. @param aTitle has content, name of the movie @param aDateViewed optional, date the movie was screened by the user @param aRating optional, in range 0.0 to 10.0 @param aComment optional, any comment on the movie */ Movie( String aId, String aTitle, Date aDateViewed, BigDecimal aRating, String aComment ) throws InvalidInputException { fId = aId; fTitle = aTitle; fDateViewed = aDateViewed; fRating = aRating; fComment = aComment; validateState(); } /** Constructor which takes all parameters as <em>text</em>. <P>Raw user input is usually in the form of <em>text</em>. This constructor <em>first</em> parses such text into the required 'base objects' - {@link Date}, {@link BigDecimal} and so on. If those parse operations <em>fail</em>, then the user is shown an error message. If N such errors are present in user input, then N <em>separate</em> message will be presented for each failure, one by one. <P>If all such parse operations <em>succeed</em>, then the "regular" constructor {@link #Movie(String, String, Date, BigDecimal, String)} will then be called. It's important to note that this call to the second constructor can in turn result in <em>another</em> error message being shown to the user (just one this time). */ Movie( String aId, String aTitle, String aDateViewed, String aRating, String aComment ) throws InvalidInputException { this( aId, aTitle, Util.parseDate(aDateViewed, "Date Viewed"), Util.parseBigDecimal(aRating, "Rating"), aComment ); } String getId(){ return fId; } /** This set method is rather artificial. It results from the toy persistence layer. It's dissatisfying to add this method since the class would otherwise be immutable. Immutability is a highly desirable characteristic. */ void setId(String aId){ fId = aId; } String getTitle(){ return fTitle; } Date getDateViewed(){ return fDateViewed; } BigDecimal getRating(){ return fRating; } String getComment(){ return fComment; } @Override public boolean equals(Object aThat){ if ( this == aThat ) return true; if ( !(aThat instanceof Movie) ) return false; Movie that = (Movie)aThat; return areEqual(this.fTitle, that.fTitle) && areEqual(this.fDateViewed, that.fDateViewed) && areEqual(this.fRating, that.fRating) && areEqual(this.fComment, that.fComment) ; } @Override public int hashCode(){ int result = 17; result = addHash(result, fTitle); result = addHash(result, fDateViewed); result = addHash(result, fRating); result = addHash(result, fComment); return result; } @Override public String toString(){ return "Movie Id:" + fId + " Title:" + fTitle + " Date Viewed:" + fDateViewed + " Rating:" + fRating + " Comment: " + fComment ; } /** Default sort by Date Viewed, then Title. Dates have the most recent items listed first. */ public int compareTo(Movie aThat) { if ( this == aThat ) return EQUAL; int comparison = DESCENDING*comparePossiblyNull(this.fDateViewed, aThat.fDateViewed); if ( comparison != EQUAL ) return comparison; comparison = this.fTitle.compareTo(aThat.fTitle); if ( comparison != EQUAL ) return comparison; comparison = comparePossiblyNull(this.fRating, aThat.fRating); if ( comparison != EQUAL ) return comparison; comparison = comparePossiblyNull(this.fComment, aThat.fComment); if ( comparison != EQUAL ) return comparison; return EQUAL; } /** Sort by Title. */ static Comparator<Movie> TITLE_SORT = new Comparator<Movie>(){ public int compare(Movie aThis, Movie aThat) { if ( aThis == aThat ) return EQUAL; int comparison = aThis.fTitle.compareTo(aThat.fTitle); if ( comparison != EQUAL ) return comparison; comparison = DESCENDING*comparePossiblyNull(aThis.fDateViewed, aThat.fDateViewed); if ( comparison != EQUAL ) return comparison; comparison = comparePossiblyNull(aThis.fRating, aThat.fRating); if ( comparison != EQUAL ) return comparison; comparison = comparePossiblyNull(aThis.fComment, aThat.fComment); if ( comparison != EQUAL ) return comparison; return EQUAL; }; }; /** Sort by Rating (descending), then Date Viewed (descending). */ static Comparator<Movie> RATING_SORT = new Comparator<Movie>(){ public int compare(Movie aThis, Movie aThat) { if ( aThis == aThat ) return EQUAL; int comparison = DESCENDING*comparePossiblyNull(aThis.fRating, aThat.fRating); if ( comparison != EQUAL ) return comparison; comparison = DESCENDING*comparePossiblyNull(aThis.fDateViewed, aThat.fDateViewed); if ( comparison != EQUAL ) return comparison; comparison = aThis.fTitle.compareTo(aThat.fTitle); if ( comparison != EQUAL ) return comparison; comparison = comparePossiblyNull(aThis.fComment, aThat.fComment); if ( comparison != EQUAL ) return comparison; return EQUAL; }; }; /** Sort by Comment. */ static Comparator<Movie> COMMENT_SORT = new Comparator<Movie>(){ public int compare(Movie aThis, Movie aThat) { if ( aThis == aThat ) return EQUAL; int comparison = comparePossiblyNull(aThis.fComment, aThat.fComment); if ( comparison != EQUAL ) return comparison; comparison = aThis.fTitle.compareTo(aThat.fTitle); if ( comparison != EQUAL ) return comparison; comparison = comparePossiblyNull(aThis.fRating, aThat.fRating); if ( comparison != EQUAL ) return comparison; comparison = DESCENDING*comparePossiblyNull(aThis.fDateViewed, aThat.fDateViewed); if ( comparison != EQUAL ) return comparison; return EQUAL; }; }; // PRIVATE // private String fId; private final String fTitle; private final Date fDateViewed; private final BigDecimal fRating; private final String fComment; private static final BigDecimal TEN = new BigDecimal("10.0"); private static final int EQUAL = 0; private static final int DESCENDING = -1; private void validateState() throws InvalidInputException { InvalidInputException ex = new InvalidInputException(); if( ! Util.textHasContent(fTitle) ) { ex.add("Title must have content"); } if ( fRating != null ){ if ( fRating.compareTo(BigDecimal.ZERO) < 0 ) { ex.add("Rating cannot be less than 0."); } if ( fRating.compareTo(TEN) > 0 ) { ex.add("Rating cannot be greater than 10."); } } if ( ex.hasErrors() ) { throw ex; } } private boolean areEqual(Object aThis, Object aThat){ return aThis == null ? aThat == null : aThis.equals(aThat); } private int addHash(int aHash, Object aField){ int result = 37*aHash; if (aField != null){ result = result + aField.hashCode(); } return result; } /** Utility method. */ private static <T extends Comparable<T>> int comparePossiblyNull(T aThis, T aThat){ int result = EQUAL; int BEFORE = -1; int AFTER = 1; if(aThis != null && aThat != null){ result = aThis.compareTo(aThat); } else { //at least one reference is null - special handling if(aThis == null && aThat == null) { //do nothing - they are not distinct } else if(aThis == null && aThat != null) { result = BEFORE; } else if( aThis != null && aThat == null) { result = AFTER; } } return result; } }
Here is the custom TableModel, which translates a column index into a Comparator in its sortByColumn method.
package hirondelle.movies.edit; import java.util.Collections; import java.util.Comparator; import java.util.List; import hirondelle.movies.util.Util; import javax.swing.table.AbstractTableModel; /** Table model used by {@link javax.swing.JTable}, explicitly for {@link Movie} objects. <P>When a database operation occurs, the view is refreshed by calling {@link #refreshView()}. <P>Note this class includes some methods which are unrelated to the needs of the superclass, but are useful in the context of this application. */ public final class MovieTableModel extends AbstractTableModel { /** Constructor. */ public MovieTableModel(){ fDAO = new MovieDAO(); fMovies = fDAO.list(); } /** Explicitly refresh the view. <P>This style seems to be cleaner and simpler than implementing a listener on the DAO. */ public void refreshView() { fMovies = fDAO.list(); //one might want to preserve the sort order here fireTableDataChanged(); } /** Returned the selected movie's id. */ public String getId(int aRow){ Movie movie = fMovies.get(aRow); return movie.getId(); } /** Return the selected {@link Movie}. */ public Movie getMovie(int aRow){ return fMovies.get(aRow); } /** Sort the movies. When called repeatedly, this method will toggle the sort between ascending and descending. @param aIdx index of the column by which to sort. */ public void sortByColumn(int aIdx){ fNumClicks++; if( aIdx == 1 ) { //natural sorting of the Movie class Collections.sort(fMovies); } else { Comparator<Movie> comparator = null; if ( aIdx == 0 ){ comparator = Movie.TITLE_SORT; } else if ( aIdx == 2 ){ comparator = Movie.RATING_SORT; } else if ( aIdx == 3 ){ comparator = Movie.COMMENT_SORT; } Collections.sort(fMovies, comparator); } if( (fNumClicks % 2) == 0){ Collections.reverse(fMovies); } fireTableDataChanged(); } /** Return the number of columns in the table. */ public int getColumnCount() { return 4; } /** Return the number of rows in the table. */ public int getRowCount() { return fMovies.size(); } /** Return the <tt>Object</tt> in a specific table cell. */ public Object getValueAt(int aRow, int aCol) { Object result = null; Movie movie = fMovies.get(aRow); if(aCol == 0) { result = movie.getTitle(); } else if(aCol == 1) { result = Util.format(movie.getDateViewed()); } else if(aCol == 2) { result = movie.getRating(); } else if(aCol == 3) { result = movie.getComment(); } return result; } /** Return the name of a specific column. */ public String getColumnName(int aIdx){ String result = ""; if( aIdx == 0) { result = "Title"; } else if( aIdx == 1) { result = "Viewed"; } else if( aIdx == 2) { result = "Rating"; } else if( aIdx == 3) { result = "Comment"; } return result; } // PRIVATE // private MovieDAO fDAO; private List<Movie> fMovies; private int fNumClicks = 0; }
Finally, here is the GUI class which listens for column clicks, and passes them on to the custom TableModel. Although the class is lengthy, the only items related to sorting are the SortMovieTable nested inner class and the clickOnHeaderSortsTable method, near the end.
package hirondelle.movies.main; import hirondelle.movies.LaunchApplication; import hirondelle.movies.about.AboutAction; import hirondelle.movies.edit.MovieActionAdd; import hirondelle.movies.edit.MovieActionChange; import hirondelle.movies.edit.MovieActionDelete; import hirondelle.movies.edit.MovieTableModel; import hirondelle.movies.exit.ExitAction; import hirondelle.movies.util.Util; import hirondelle.movies.util.ui.UiUtil; import java.awt.Dimension; import java.awt.Toolkit; import java.awt.event.ActionEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.Color; import java.util.logging.Logger; import javax.swing.*; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import java.util.Locale; /** Main window for the application. <P>A menu bar, and a sortable table containing the user's list of movies. <P>Some applications would add a confirmation dialog when the user exits. */ public final class MainWindow { /** Return an instance of this class. <P>This class is made a singleton, since there is only one main window. Any caller can refresh the main window using <PRE>MainWindow.getInstance().refreshView();</PRE> This lets the app avoid needing to pass around an object reference to the main window. */ public static MainWindow getInstance() { return INSTANCE; } /** Build and display the main window. @param aUserName user name, as validated by {@link hirondelle.movies.login.LoginController}. */ public void buildAndShow(String aUserName){ fUserName = aUserName; fLogger.fine("Building GUI for user : " + aUserName); buildGui(); } /** Refresh the display in response to changing database content. */ public void refreshView(){ fMovieTableModel.refreshView(); } /** Return the user name passed to {@link #buildAndShow(String)}. <P>The user name can be accessed anywhere using : <PRE>MainWindow.getInstance().getUserName();</PRE> */ public String getUserName(){ return fUserName; } // PRIVATE // /** The single instance of this class. */ private static MainWindow INSTANCE = new MainWindow(); /** Empty constructor prevents the caller from creating an object. */ private MainWindow() { } private MovieTableModel fMovieTableModel; private JTable fMovieTable; private Action fChangeMovieAction; private Action fDeleteMovieAction; private String fUserName; private static final Logger fLogger = Util.getLogger(MainWindow.class); /** Build the user interface. */ private void buildGui(){ JFrame frame = new JFrame( LaunchApplication.APP_NAME + " - " + fUserName.toUpperCase(Locale.ENGLISH) ); fMovieTableModel = new MovieTableModel(); fMovieTable = new JTable(fMovieTableModel); fMovieTable.setBackground(Color.LIGHT_GRAY); buildActionsAndMenu(frame); buildContent(frame); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); placeInMiddlePartOfTheScreen(frame); addApplicationIcon(frame); UiUtil.centerAndShow(frame); } /** Sort the table. Listens for clicks on the JTableHeader. */ private final class SortMovieTable extends MouseAdapter { @Override public void mouseClicked(MouseEvent aEvent) { fLogger.config("Sorting the table."); int columnIdx = fMovieTable.getColumnModel().getColumnIndexAtX(aEvent.getX()); fMovieTableModel.sortByColumn(columnIdx); } } /** Show a dialog to edit a movie. Listens for double-clicks on the JTable. */ private final class LaunchEditMovieDialog extends MouseAdapter { @Override public void mouseClicked(MouseEvent aEvent) { if( aEvent.getClickCount() == 2) { fLogger.config("Editing a movie."); ActionEvent event = new ActionEvent(this, 0, ""); fChangeMovieAction.actionPerformed(event); } } } /** Enable edit and delete actions only when something is selected in the table. */ private final class EnableEditActions implements ListSelectionListener { public void valueChanged(ListSelectionEvent aEvent) { fLogger.fine( "List selection changed. First:" + aEvent.getFirstIndex() + " Last " + aEvent.getLastIndex() ); if( aEvent.getFirstIndex() != -1) { fDeleteMovieAction.setEnabled(true); fChangeMovieAction.setEnabled(true); } else { fDeleteMovieAction.setEnabled(false); fChangeMovieAction.setEnabled(false); } } } /** Build the menu bar. */ private void buildActionsAndMenu(JFrame aFrame) { JMenuBar menuBar = new JMenuBar(); JMenu fileMenu = new JMenu("File"); fileMenu.setMnemonic('F'); Action addMovieAction = new MovieActionAdd(aFrame); fileMenu.add(new JMenuItem(addMovieAction)); fChangeMovieAction = new MovieActionChange(aFrame, fMovieTable, fMovieTableModel); fileMenu.add(new JMenuItem(fChangeMovieAction)); fDeleteMovieAction = new MovieActionDelete(fMovieTable, fMovieTableModel); fileMenu.add(new JMenuItem(fDeleteMovieAction)); Action exitAction = new ExitAction(); fileMenu.add(new JMenuItem(exitAction)); menuBar.add(fileMenu); JMenu helpMenu = new JMenu("Help"); helpMenu.setMnemonic('H'); helpMenu.add(new JMenuItem(new AboutAction(aFrame))); menuBar.add(helpMenu); aFrame.setJMenuBar(menuBar); } /** Expand the frame to fill the middel part of the screen. */ private void placeInMiddlePartOfTheScreen(JFrame aFrame) { Dimension screen = Toolkit.getDefaultToolkit().getScreenSize(); Dimension halfScreen = new Dimension(2*screen.width/3, screen.height/2); aFrame.setPreferredSize(halfScreen); } /** Custom icon for the upper left corner of the frame. Not that a path relative to this class is used. */ private void addApplicationIcon(JFrame aFrame) { ImageIcon icon = UiUtil.createImageIcon("app_icon.png", "Application icon", this.getClass()) ; aFrame.setIconImage(icon.getImage()); } /** Build the main content of the frame. */ private void buildContent(JFrame aFrame) { //relative column widths fMovieTable.getColumnModel().getColumn(0).setPreferredWidth(100); fMovieTable.getColumnModel().getColumn(1).setPreferredWidth(20); fMovieTable.getColumnModel().getColumn(2).setPreferredWidth(20); fMovieTable.getColumnModel().getColumn(3).setPreferredWidth(200); /* Interesting: even though these methods are one-liners, it's still useful to create them, since, from the point of view of the caller, they *greatly* clarify the intent, and read at a higher level of abstraction. */ clickOnHeaderSortsTable(); doubleClickShowsEditDialog(); rowSelectionEnablesActions(); JScrollPane panel = new JScrollPane(fMovieTable); aFrame.getContentPane().add(panel); } private void clickOnHeaderSortsTable() { fMovieTable.getTableHeader().addMouseListener(new SortMovieTable()); } private void doubleClickShowsEditDialog() { fMovieTable.addMouseListener( new LaunchEditMovieDialog() ); } private void rowSelectionEnablesActions() { fMovieTable.getSelectionModel().addListSelectionListener(new EnableEditActions()); } }
See Also :
Would you use this technique?
|
|