JTable with RowSorter changes selection upon filter change

182 Views Asked by At

This is a complicated problem, and unfortunately a small SSCCE would not be possible. Therefore I have a long SCCE that demonstrates the problem. The simplified sample program uses a simplified data source - Time Zones. To use it, select a Time Zone in the table, then change the filter with the buttons at the top. Notice the text at the bottom changing to show the application selection. The undesired behavior is that when shifting to a filter that does not include the selected value, the selected value in the model is cleared. Surprisingly, when the selection is filtered out, the value is updated to being not set; but when filtered back in, the application selection is returned.

The Swing-based application's design is a Model-ViewModel-View, the Model is supposed to be the authoritative source for the application's data, including what the current selection is. The Model can have multiple ViewModel-Views displaying the data in the Model. This current selection may be reflected in multiple views. The user should be able to change the selection from any View. The selection may or may not be visible in all Views if it doesn't apply to some Views (a real-world example might be a View that shows vehicle maintenance may not show trips being taken by the vehicle).

The sample program has a JLabel as a simplified View-only of the application's selection, which displays at the bottom of the app the selection in the model.

The other more relevant View is a JTable that shows one Time Zone (as a String) per row. It has a custom ListSelectionModel as the ViewModel that forwards change requests to the application Model, and listens to changes from the application Model and applies them to the selection by calling methods on super. This works as expected, at least until filtering is applied.

The process of filtering is done mostly within the JTable and its inner classes, such as JTable$SortManager. It seems to remember and clear the selection, perform the sort and filter, and then restore the selection or nothing if the selected value is not in the newly filtered set.

Unfortunately, in the ListSelectionModel, these clearing and selecting operations are changing the underlying selection in the application Model. In my actual application, the selection loads a lot more information to display about the selection, and is a relatively expensive operation, so spurious changes to this value should be avoided.

So the question is this: Is there a way to prevent the application Model's selection from being changed when changing the table filter? I imagine the solution would fall under one of these categories:

  • There may be some way of detecting within the ListSelectionModel when the filter/sort is in progress, and not update the application model while that is happening
  • There may be something that can be overridden somewhere to change the undesired behavior

Here is the sample code:

import java.awt.BorderLayout;
import java.util.ArrayList;
import java.util.List;
import java.util.TimeZone;

import javax.swing.Box;
import javax.swing.DefaultListSelectionModel;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.ListSelectionModel;
import javax.swing.RowFilter;
import javax.swing.SwingUtilities;
import javax.swing.table.AbstractTableModel;
import javax.swing.table.TableModel;
import javax.swing.table.TableRowSorter;

public class TableProblem
    extends JFrame
{
    public static final class ApplicationModel
    {
        String[] data = TimeZone.getAvailableIDs();
        public String[] getData() { return data; }

        private String modelSelection;
        public String getModelSelection() { return modelSelection; }
        public void setModelSelection(String value) { modelSelection = value; fireModelSelectionChange(); }

        private void fireModelSelectionChange()
        { selectionListeners.forEach(l -> l.modelSelectionChanged(modelSelection, findModelIndex(modelSelection))); }

        private int findModelIndex(String value)
        {
            if (value != null)
                for (int i = 0; i < data.length; i++)
                    if (value.equals(data[i]))
                        return i;
            return -1;
        }
        private List<ApplicationModelSelectionListener> selectionListeners = new ArrayList<>();
        public void addSelectionListener(ApplicationModelSelectionListener l) { selectionListeners.add(l); }
    }

    public interface ApplicationModelSelectionListener
    {
        void modelSelectionChanged(String selection, int selectedModelIndex);
    }

    /** This class acts as the selection ViewModel.  The actual model is the
    * passed-in ApplicationModel.
    */
    public final class TimeZoneListSelectionModel
        extends DefaultListSelectionModel
        implements ApplicationModelSelectionListener
    {
        private final ApplicationModel appMdl;
        private static final long serialVersionUID = 1L;

        private TimeZoneListSelectionModel(ApplicationModel appMdl)
        {
            setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
            this.appMdl = appMdl;
            appMdl.addSelectionListener(this);
        }

        // Requests to ListSelectionModel to modify the selection are passed
        // to the appMdl
        @Override
        public void clearSelection()
        {
            appMdl.setModelSelection(null);
        }

        @Override
        public void setSelectionInterval(int index0, int index1)
        {
            int modelIdx = tbl.convertRowIndexToModel(index0);
            String value = appMdl.getData()[modelIdx];
            appMdl.setModelSelection(value);
        }

        @Override
        public void addSelectionInterval(int index0, int index1)
        {
            int modelIdx = tbl.convertRowIndexToModel(index0);
            String value = appMdl.getData()[modelIdx];
            appMdl.setModelSelection(value);
        }

        // Notification from the app model about selection change gets
        // percolated back to the user interface
        @Override
        public void modelSelectionChanged(String selection, int selectedModelIndex)
        {
            if (selectedModelIndex == -1)
            {
                super.clearSelection();
                return;
            }
            int viewIndex = tbl.convertRowIndexToView(selectedModelIndex);
            if (viewIndex == -1)
                super.clearSelection();
            else
                super.setSelectionInterval(viewIndex, viewIndex);
        }
    }

    public static final class TimeZoneTableModel
        extends AbstractTableModel
    {
        private static final long serialVersionUID = 1L;
        private final String[] data;

        public TimeZoneTableModel(String[] data)
        {
            this.data = data;
        }

        @Override public int getRowCount() { return data.length; }

        @Override public int getColumnCount() { return 1; }

        @Override
        public Object getValueAt(int rowIndex, int columnIndex)
        {
            if (columnIndex == 0)
                return data[rowIndex];
            throw new IllegalArgumentException("columnIndex="+columnIndex+" should be < 1");
        }

        @Override public String getColumnName(int column)
        { return "Time Zone"; }
    }

    private static final class StringRowFilter
        extends RowFilter<TableModel, Integer>
    {
        private String prefix;
        public void setPrefix(String value) { prefix = value; rowSorter.sort(); }

        private final TableRowSorter<TableModel> rowSorter;

        public StringRowFilter(TableRowSorter<TableModel> rowSorter)
        {
            this.rowSorter = rowSorter;
        }

        @Override
        public boolean include(
            Entry<? extends TableModel, ? extends Integer> entry)
        {
            if (prefix == null)
                return true;
            String lowerCase = entry.getStringValue(0).toLowerCase();
            return lowerCase.startsWith(prefix);
        }
    }

    private static final long serialVersionUID = 1L;

    public static void main(String[] args)
    {
        ApplicationModel appMdl = new ApplicationModel();
        SwingUtilities.invokeLater(() -> new TableProblem(appMdl).setVisible(true));
    }

    private final JTable tbl;

    public TableProblem(ApplicationModel appMdl)
    {
        super("View-ModelView-Model Test");
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        TimeZoneTableModel mdl = new TimeZoneTableModel(appMdl.getData());

        tbl = new JTable(mdl);
        tbl.setAutoCreateRowSorter(true);
        TimeZoneListSelectionModel tzListSelectionModel = new TimeZoneListSelectionModel(appMdl);
        tbl.setSelectionModel(tzListSelectionModel);

        @SuppressWarnings("unchecked")
        TableRowSorter<TableModel> rowSorter = (TableRowSorter<TableModel>)tbl.getRowSorter();
        StringRowFilter filter = new StringRowFilter(rowSorter);
        rowSorter.setRowFilter(filter);

        Box filterButtons = createFilterButtons(filter);

        Box vbox = Box.createVerticalBox();
        vbox.add(filterButtons);
        vbox.add(new JScrollPane(tbl));

        JLabel mdlSelect = new JLabel("App Model selection: ");
        appMdl.addSelectionListener((selection, selectedModelIndex) ->
            mdlSelect.setText("App Model selection: " + selection + " (" +
                selectedModelIndex + ")"));
        vbox.add(mdlSelect);

        add(vbox, BorderLayout.CENTER);

        pack();
    }

    private static Box createFilterButtons(StringRowFilter filter)
    {
        Box filterButtons = Box.createHorizontalBox();
        filterButtons.add(new JLabel("Filter: "));
        for (String filterStr : "All,Africa,America,Antarctica,Asia,Australia,Canada,Europe,Pacific,Us".split(","))
            addFilterButton(filter, filterButtons, filterStr);
        return filterButtons;
    }

    private static void addFilterButton(StringRowFilter filter,
        Box filterButtons, String buttonName)
    {
        String filterPrefix = "All".equals(buttonName) ? null : buttonName.toLowerCase();
        JButton asiaButton = new JButton(buttonName);
        asiaButton.addActionListener(ae -> filter.setPrefix(filterPrefix));
        filterButtons.add(asiaButton);
    }
}
1

There are 1 best solutions below

0
Pixelstix On

A disappointing and unsatisfying solution to this problem is upon requesting a change of the filter value, before telling the JTable's RowSorter, clear out the currently selected item in the model.

This is a change in the behavior, where the selection gets cleared, but prevents spurious clearing and resetting of the value when clicking through the filter values.

The change to the example code would involve passing the selection model to the action of the filter button. The three methods that would change are below.

public TableProblem(ApplicationModel appMdl)
{
    super("View-ModelView-Model Test");
    setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

    TimeZoneTableModel mdl = new TimeZoneTableModel(appMdl.getData());

    tbl = new JTable(mdl);
    tbl.setAutoCreateRowSorter(true);
    TimeZoneListSelectionModel tzListSelectionModel = new TimeZoneListSelectionModel(appMdl);
    tbl.setSelectionModel(tzListSelectionModel);

    @SuppressWarnings("unchecked")
    TableRowSorter<TableModel> rowSorter = (TableRowSorter<TableModel>)tbl.getRowSorter();
    StringRowFilter filter = new StringRowFilter(rowSorter);
    rowSorter.setRowFilter(filter);

    Box filterButtons = createFilterButtons(filter, tzListSelectionModel);

    Box vbox = Box.createVerticalBox();
    vbox.add(filterButtons);
    vbox.add(new JScrollPane(tbl));

    JLabel mdlSelect = new JLabel("App Model selection: ");
    appMdl.addSelectionListener((selection, selectedModelIndex) ->
        mdlSelect.setText("App Model selection: " + selection + " (" +
            selectedModelIndex + ")"));
    vbox.add(mdlSelect);

    add(vbox, BorderLayout.CENTER);

    pack();
}

private static Box createFilterButtons(StringRowFilter filter,
    TimeZoneListSelectionModel tzListSelectionModel)
{
    Box filterButtons = Box.createHorizontalBox();
    filterButtons.add(new JLabel("Filter: "));
    for (String filterStr : "All,Africa,America,Antarctica,Asia,Australia,Canada,Europe,Pacific,Us".split(","))
        addFilterButton(filter, filterButtons, filterStr, tzListSelectionModel);
    return filterButtons;
}

private static void addFilterButton(StringRowFilter filter,
    Box filterButtons, String buttonName,
    TableProblem.TimeZoneListSelectionModel tzListSelectionModel)
{
    String filterPrefix = "All".equals(buttonName) ? null : buttonName.toLowerCase();
    JButton asiaButton = new JButton(buttonName);
    asiaButton.addActionListener(ae -> {
        tzListSelectionModel.clearSelection();
        filter.setPrefix(filterPrefix);
    });
    filterButtons.add(asiaButton);
}

Notice I will not be marking this Answer as the solution because it is more of a workaround, and an actual solution to the problem is preferred.