javaswingjtablerowfilterrowsorter

Row filter doesn't work as expected on cell update events


Working in a shared table model example I realized that if we attach a row filter to a table's row sorter this filter doesn't have any effect on cell update events. According to RowSorter API:

Concrete implementations of RowSorter need to reference a model such as TableModel or ListModel. The view classes, such as JTable and JList, will also have a reference to the model. To avoid ordering dependencies, RowSorter implementations should not install a listener on the model. Instead the view class will call into the RowSorter when the model changes. For example, if a row is updated in a TableModel JTable invokes rowsUpdated. When the model changes, the view may call into any of the following methods: modelStructureChanged, allRowsChanged, rowsInserted, rowsDeleted and rowsUpdated.

So as I understand this paragraph, a cell update is a particular case of row update and as such rowsUpdated should be called and row filtered accordingly.

To illustrate what I'm saying, please consider this simple filter:

private void applyFilter() {
    DefaultRowSorter sorter = (DefaultRowSorter)table.getRowSorter();
    sorter.setRowFilter(new RowFilter() {
        @Override
        public boolean include(RowFilter.Entry entry) {
            Boolean value = (Boolean)entry.getValue(2);
            return value == null || value;
        }
    });
}

Here the third column is expected to be a Boolean and entry (row) has to be included if the cell value is either null or true. If I edit a cell placed at third column and set its value to false then I'd expect this row just "disappear" from the view. However, to accomplish this I have to set a new filter again because it doesn't seem to work "automatically".

Attaching a TableModelListener to the model as follows, I can see the update event on cell edits:

model.addTableModelListener(new TableModelListener() {
    @Override
    public void tableChanged(TableModelEvent e) {
        if (e.getType() == TableModelEvent.UPDATE) {
            int row = e.getLastRow();
            int column = e.getColumn();
            Object value = ((TableModel)e.getSource()).getValueAt(row, column);

            String text = String.format("Update event. Row: %1s Column: %2s Value: %3s", row, column, value);
            System.out.println(text);
        }
    }
});

As I've said, if I reset the filter using this TableModelListener then it works as expected:

 model.addTableModelListener(new TableModelListener() {
    @Override
    public void tableChanged(TableModelEvent e) {
        if (e.getType() == TableModelEvent.UPDATE) {
            applyFilter();
        }
    }
});

Question: is this a bug/implementation issue? Or I'm misunderstanding the API?

Here is a complete MCVE illustrating the problem.

import java.awt.BorderLayout;
import javax.swing.BorderFactory;
import javax.swing.DefaultRowSorter;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.RowFilter;
import javax.swing.SwingUtilities;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
import javax.swing.table.DefaultTableModel;
import javax.swing.table.TableModel;

public class Demo {

    private JTable table;

    private void createAndShowGUI() {

        DefaultTableModel model = new DefaultTableModel(5, 3) {
            @Override
            public boolean isCellEditable(int row, int column) {
                return column == 2;
            }

            @Override
            public Class<?> getColumnClass(int columnIndex) {
                return columnIndex == 2 ? Boolean.class : super.getColumnClass(columnIndex);
            }
        };

        model.addTableModelListener(new TableModelListener() {
            @Override
            public void tableChanged(TableModelEvent e) {
                if (e.getType() == TableModelEvent.UPDATE) {
                    int row = e.getLastRow();
                    int column = e.getColumn();
                    Object value = ((TableModel)e.getSource()).getValueAt(row, column);
                    String text = String.format("Update event. Row: %1s Column: %2s Value: %3s", row, column, value);
                    System.out.println(text);
                    // applyFilter(); un-comment this line to make it work
                }
            }
        });

        table = new JTable(model);
        table.setAutoCreateRowSorter(true);

        applyFilter();

        JPanel content = new JPanel(new BorderLayout());
        content.setBorder(BorderFactory.createEmptyBorder(8,8,8,8));
        content.add(new JScrollPane(table));

        JFrame frame = new JFrame("Demo");
        frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
        frame.add(content);
        frame.pack();
        frame.setLocationByPlatform(true);
        frame.setVisible(true);
    }

    private void applyFilter() {
        DefaultRowSorter sorter = (DefaultRowSorter)table.getRowSorter();
        sorter.setRowFilter(new RowFilter() {
            @Override
            public boolean include(RowFilter.Entry entry) {
                Boolean value = (Boolean)entry.getValue(2);
                return value == null || value;
            }
        });
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(new Runnable() {
            @Override
            public void run() {
                new Demo().createAndShowGUI();
            }
        });
    }
}

Solution

  • After doing some research and reading the API, bugs reports and Oracle forum I've found some interesting things.

    1. Set DefaultRowSorter's sortsOnUpdate property to true

    The first thing I found is we have to set sortsOnUpdate property to true in order to enable the notification chain when rowsUpdated(...) is called. Otherwise no RowSorterEvent will be fired and the view (our JTable) won't be aware that something happened and won't repaint accordingly. So making this little change:

    DefaultRowSorter sorter = (DefaultRowSorter)table.getRowSorter();
    sorter.setRowFilter(new RowFilter() {
        @Override
        public boolean include(RowFilter.Entry entry) {
            Boolean value = (Boolean)entry.getValue(2);
            return value == null || value;
        }
    });
    sorter.setSortsOnUpdates(true);
    

    We won't have to re-apply the filter on a table model update. But...

    2. There's a bug in JTable component processing the RowSorterEvent notification

    While JTable implements RowSorterListener interface, subscribes itself to the row sorter as a listener and process RowSorterEvents. there's a bug repainting the table. The odd behavior is well described in these posts:

    In a nutshell:

    When a RowSorterEvent.TYPE.SORTED event is processed by JTable, it repaints only the area related to the involded rows but not the rest of the table, which remains as it was. Let's say we edit the first row and it should be filtered now. Then the rest of the rows should be shifted up one row to top but it turns out they are not: only the first row will be correctly repainted to show the second row but the rest of the table still the same. This is in fact a bug because in this particular case the whole table needs to be repainted. See core bug # 6791934

    As a workaround we could either attach a new RowSorterListener to the RowSorter or override JTable's sorterChanged(...) as follows in order to force a whole repaint on our table (IMHO the second method is preferred).

    DefaultRowSorter sorter = (DefaultRowSorter)table.getRowSorter();
    ...
    sorter.addRowSorterListener(new RowSorterListener() {
        @Override
        public void sorterChanged(RowSorterEvent e) {
            if (e.getType() == RowSorterEvent.Type.SORTED) {
                // We need to call both revalidate() and repaint()
                table.revalidate();
                table.repaint();
            }
        }
    });
    

    Or

    JTable table = new JTable(tableModel) {
        @Override
        public void sorterChanged(RowSorterEvent e) {
            super.sorterChanged(e);
            if (e.getType() == RowSorterEvent.Type.SORTED) {
                resizeAndRepaint(); // this protected method calls both revalidate() and repaint()
            }
        }
    };
    

    3. JXTable component has a workaround to this bug

    The JXTable component that is part of SwingX library and extends from JTable doesn't have this problem because SwingLabs team has overwrote sorterChanged(...) mthod as follows to hack around this bug:

    //----> start hack around core issue 6791934: 
    //      table not updated correctly after updating model
    //      while having a sorter with filter.
        
        /**
         * Overridden to hack around core bug 
         * https://bugs.java.com/bugdatabase/view_bug?bug_id=6791934
         * 
         */
        @Override
        public void sorterChanged(RowSorterEvent e) {
            super.sorterChanged(e);
            postprocessSorterChanged(e);
        }
    
    
        /** flag to indicate if forced revalidate is needed. */
        protected boolean forceRevalidate;
        /** flag to indicate if a sortOrderChanged has happened between pre- and postProcessModelChange. */
        protected boolean filteredRowCountChanged;
        
        /**
         * Hack around core issue 6791934: sets flags to force revalidate if appropriate.
         * Called before processing the event.
         * @param e the TableModelEvent received from the model
         */
        protected void preprocessModelChange(TableModelEvent e) {
            forceRevalidate = getSortsOnUpdates() && getRowFilter() != null && isUpdate(e) ;
        }
    
        /**
         * Hack around core issue 6791934: forces a revalidate if appropriate and resets
         * internal flags.
         * Called after processing the event.
         * @param e the TableModelEvent received from the model
         */
        protected void postprocessModelChange(TableModelEvent e) {
            if (forceRevalidate && filteredRowCountChanged) {
                resizeAndRepaint();
            }
            filteredRowCountChanged = false;
            forceRevalidate = false;
        }
    
        /**
         * Hack around core issue 6791934: sets the sorter changed flag if appropriate.
         * Called after processing the event.
         * @param e the sorter event received from the sorter
         */
        protected void postprocessSorterChanged(RowSorterEvent e) {
            filteredRowCountChanged = false;
            if (forceRevalidate && e.getType() == RowSorterEvent.Type.SORTED) {
                filteredRowCountChanged = e.getPreviousRowCount() != getRowCount();
            }
        }    
    
    //----> end hack around core issue 6791934:
    

    So, this is one more reason (if some missing) to use SwingX.