further refinements to tabbed table java application

April 12, 2014

I’ve been working with this personal project on and off, in the evenings after work, for several more weeks now. I’ve reached a point of diminishing returns (meaning that all I’m doing now is twiddling a few lines of code) so it’s time to ship it.

As a lead-in to the post I decided to be a bit classier (pun intended) than usual and toss out a modest UML diagram illustrating the overall OO design of the application. To draw it up I grabbed a copy of Violet, the UML editor, and sketched up a very basic UML diagram showing the relationship of all the classes in this small test application. I kept the diagramming to an absolute bare minimum, showing only the classes I wrote (and none of the JFC classes I extended), as well as just the public class methods. If you want more details the class sources are at the end of the post.

It’s been a long while since I cobbled together a formal UML diagram (other than the informal ones on a white board). In the process I was, once again, a bit torn between the usage of the aggregation (open diamond end) versus composition (filled-in diamond end). I go through this internal mental argument every time. In the end composition notation won out (yet again) because this is all about what the application is composed of. All I wanted to show was the has-a relationship of all the various classes versus the is-a relationship of inheritance, which I show between the TableDataProvider interface and the StaticTestData implementation. And for those who are interested, the dashed arrow between TableModel and StaticTestData shows the dependency for data that TableModel has on StaticTestData.

Ignoring the levels of inheritance within Java’s Foundation Classes (Swing), the design is extremely flat, with the interface the solution I came up with to separate out the test data I originally had slapped into the TabbedTables main class. It also gives me a better way to add other data sources without having to re-write any other classes. As long as the TableDataProvider implementation can provide the column names and data to be loaded into the table model, then a TableDataProvider can get its data from any source.

Some of the young turks (and old farts) will argue that UML is a waste of their time. Nobody will read it they say. Others argue that it’s a waste of time because changes to code are not reflected automatically in the design (specifically the UML diagram(s)) or vice versa. To the latter I say cry me a river.

If you can’t describe the design in a high-level understandable manner, with just enough detail to be useful, then you really don’t understand what you’re attempting to code. As for not keeping changes in code and design documents aligned, I have two responses; (1) you can always buy tools that allow for round trip engineering between UML design and code (and there are too many cheap/lazy coders who won’t do that), and (2) you should always start at the design to think about your overall changes, then work down towards the code implementation/changes. That’s a matter of discipline.

Anything worth working on should always start with a reasonably clear idea leading to a small set of succinct needs (requirements), in turn leading to a reasonable high-level design. If you can’t do that, then you’re wasting time and money and creating software that can be exploited against you by external actors if you create exploitable opportunities due to poor design, let alone poor implementation.

I’m no fan of design-it-all-up-front. You don’t have to design the world, just a reasonable first start. Whittle the big ideas down into smaller chunks, then prioritize the chunks. Execute in small enough steps to find the mistakes (in assumptions, design, and implementation) when they occur (and trust me, they will occur) and correct them. Monolithic big-bang designs, either in commercial or military projects, are epic failures just waiting to happen.

Changes since the last time I took a screen grab include setting the JTable so that it doesn’t automatically fill the viewport horizontally and locking the columns in place. As for the latter “feature”, I’m not sure who came up with the idea we needed to move our columns around. The ability to sort a column is far more useful, and simply clicking a column header to sort also has the column briefly moving, which drives me crazy. So I locked columns in place with “table.getTableHeader().setReorderingAllowed(false)” in CustomTable. I also tweaked the colors for both the cell borders and alternating rows to be the same, and the way the close button is drawn.

This screen grab shows what happens when any of the table rows are double clicked. It opens a simple detail panel on the right. Again you can look at the source code for CustomTable to see how this is done. The CloseControl was enhanced to work with both the TabbedContainer’s JTabbedPane pane tab, as well as the detail view that is opened. It changes color to red and white when hovered over (as seen above) and clicking the close button closes the detail pane and restores the look to the view in the first screen capture. While the view is primitive in this example, it proves the point that I can click any row and get the data associated with that row, even if the rows have been sorted. In a more sophisticated application I would use the data to actually reach back out and get detailed data associated with cell data in that row rather than just simply display the cells. But this vets that the logic up to this point is working and proves the basic design to be reasonably correct.

What follows are all seven source files in this project.

package tabbedtables;

import java.awt.Color;
import java.util.Enumeration;
import javax.swing.JFrame;
import javax.swing.SwingUtilities;
import javax.swing.UIDefaults;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;

public class TabbedTables {

    private static void dumpLookAndFeelDefaults() {
        UIDefaults defaults = UIManager.getLookAndFeelDefaults();
        Enumeration enums = UIManager.getLookAndFeelDefaults().keys();
        while (enums.hasMoreElements()) {
            Object key = enums.nextElement();
            Object val = defaults.get(key);
            System.out.println(key.toString() + " = " + (val != null ? val.toString() : "NULL"));
        }
    }

    private static void createGUI() {
        try {
            // Set system Java L&F (Windows for Windows, Gnome for Java...)
            //
            UIManager.setLookAndFeel(
                    UIManager.getSystemLookAndFeelClassName());
        }
        catch (UnsupportedLookAndFeelException |
                ClassNotFoundException |
                InstantiationException |
                IllegalAccessException exception) {
            // silently handle exception
        }

        // Turn off the dashed line around the active tab. Make it transparent.
        //
        UIManager.put("TabbedPane.focus", new Color(0,0,0,0));

        //dumpLookAndFeelDefaults();

        TabbedContainer tabbedContainer = new TabbedContainer();
        tabbedContainer.addTable("Tab 1", new CustomTable(new StaticTestData()));
        tabbedContainer.addTable("Tab 2", new CustomTable(new StaticTestData()));
        tabbedContainer.addTable("Tab 3", new CustomTable(new StaticTestData()));
        tabbedContainer.addTable("Tab 4", new CustomTable(new StaticTestData()));

        JFrame frame = new JFrame("Test tabs and tables");

        frame.setContentPane(tabbedContainer);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setSize(800, 600);
        frame.setVisible(true);
    }

    /**
     * @param args the command line arguments
     */
    public static void main(String[] args) {
        SwingUtilities.invokeLater(new Runnable() {
            @Override
            public void run() {
                createGUI();
            }
        });
    }
}
package tabbedtables;

import javax.swing.JComponent;
import javax.swing.JTabbedPane;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;

public class TabbedContainer extends JTabbedPane {
    private int lastActiveTab = 0;

    public TabbedContainer() {
        addChangeListener( new ChangeListener() {
           @Override
           public void stateChanged(ChangeEvent event) {
               JTabbedPane source = (JTabbedPane)event.getSource();
               if (getTabCount() == 0) return;
               if (lastActiveTab >= getTabCount()) lastActiveTab = getTabCount() - 1;
               if (source.getTabComponentAt(lastActiveTab) == null) return;
               ((CloseControl)source.getTabComponentAt(lastActiveTab)).setButtonEnabled(false);
               lastActiveTab = source.getSelectedIndex();
               ((CloseControl)source.getTabComponentAt(lastActiveTab)).setButtonEnabled(true);
           }
        });
    }

    public void addTable(String tabTitle, JComponent table) {
        int lastTabCount = getTabCount();
        addTab(tabTitle, table);
        CloseControl ctc = new CloseControl(this);
        setTabComponentAt(getTabCount() - 1, ctc);
        setSelectedIndex(getTabCount() - 1);

        if (lastTabCount > 0) {
            lastActiveTab = lastTabCount;
        }
    }
}
package tabbedtables;

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.GridLayout;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTable;
import javax.swing.ListSelectionModel;
import javax.swing.SwingConstants;
import javax.swing.table.TableCellRenderer;

public class CustomTable extends JPanel {
    private JSplitPane splitPane;
    private JPanel rightDetail = null;
    private JLabel rightText;

    public CustomTable(TableDataProvider tableData) {
        super(new GridLayout(1,0,4,4));

        JTable table = new JTable(new TableModel(tableData.getColumnNames(), tableData.getTableData())) {
            //
            // A simple renderer to render every odd row in the table as
            // a color other than white, or the default background color.
            //
            @Override
            public Component prepareRenderer(TableCellRenderer renderer, int row, int col) {
                Component component = super.prepareRenderer(renderer, row, col);
                //
                // Alternate row color
                //
                if (!isRowSelected(row))
                    component.setBackground(row % 2 == 0 ? getBackground() : Color.decode("0xEED8AE"));
                return component;
            }
        };

        table.setAutoResizeMode(JTable.AUTO_RESIZE_OFF);
        table.setFillsViewportHeight(true);
        table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
        table.setAutoCreateRowSorter(true);
        table.getTableHeader().setReorderingAllowed(false);
        table.setGridColor(Color.decode("0xEED8AE"));

        // Look for a double mouse click to select a given cell in the table.
        //
        table.addMouseListener(new MouseAdapter() {
            @Override
            public void mouseClicked(MouseEvent event) {
                if (event.getClickCount() == 2) {
                    JTable table = (JTable)event.getSource();
                    int row = table.getSelectedRow();
                    int col = table.getSelectedColumn();

                    // If we haven't added the right detail pane yet, add it
                    // and make the split pane's divider wide enough to
                    // easily drag. This will honor the user's arbitrary
                    // width setting for any following detail clicks.
                    //
                    if (splitPane.getRightComponent() == null) {
                        splitPane.setRightComponent(rightDetail);
                        splitPane.setDividerSize(4);
                    }

                    // Simple detailed view of a given row.
                    // Don't do like I did the first time and get cell values
                    // from the table model, especially after a sort.
                    // The table view is where you want to get detailed data.
                    //
                    rightText.setText("<html><h1>Detailed Information</h1>" +
                            "<h2>Row clicked:" + Integer.toString(row) + "</h2>" +
                            "<h2>Column clicked: " + Integer.toString(col) + "</h2>" +
                            "<h3>Column 1: " + table.getValueAt(row, 0) + "</h3>" +
                            "<h3>Column 2: " + table.getValueAt(row, 1) + "</h3>" +
                            "<h3>Column 3: " + table.getValueAt(row, 2) + "</h3>" +
                            "<h3>Column 4: " + table.getValueAt(row, 3) + "</h3>" +
                            "</html>");
                }
            }
        });

        //JTableHeader tableHeader = table.getTableHeader();
        //tableHeader.setReorderingAllowed(false);

        // Ceate the split pane, but DON'T ADD the right detail panel. We'll
        // add it when someone first double-clicks on a table row.
        //
        splitPane = new JSplitPane(
                JSplitPane.HORIZONTAL_SPLIT,
                new JScrollPane(table, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED,
                JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED), null);
        splitPane.setDividerSize(0);
        add(splitPane);

        rightDetail = new JPanel(new BorderLayout());
        JPanel closeRightTop = new CloseControl("Placeholder text...", splitPane);
        rightDetail.add(closeRightTop, BorderLayout.PAGE_START);
        rightText = new JLabel();
        rightText.setVerticalAlignment(SwingConstants.TOP);
        rightDetail.add(rightText, BorderLayout.CENTER);
    }
}
package tabbedtables;

import java.awt.BasicStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSplitPane;
import javax.swing.JTabbedPane;
import javax.swing.UIManager;
import javax.swing.plaf.basic.BasicButtonUI;

/**
 * Creates a button to be added to the tab on a JTabbedPane allowing the end
 * user to close any arbitrary tab. This is a feature that's been available for
 * about a decade (at least) on just about any major application you care to
 * name, such as Firefox and Chrome.
 */
public class CloseControl extends JPanel {
    private JTabbedPane parentTabbedPane;
    private JSplitPane parentSplitPane;
    private CloseButton closeButton;
    private boolean repaintButton = false;

    /**
     * Constructed with the parent JTabbledPane reference.
     * @param pane parent JTabbedPane
     */
    public CloseControl(final JTabbedPane pane) {
        super(new FlowLayout(FlowLayout.LEFT,0,0));

        if (pane == null) throw new NullPointerException("JTabbedPane is null");

        parentTabbedPane = pane;
        setOpaque(false);

        JLabel label = new JLabel() {
            @Override
            public String getText() {
                int index = pane.indexOfTabComponent(CloseControl.this);
                if (index != -1) return pane.getTitleAt(index);
                return null;
            }
        };

        label.setBorder(BorderFactory.createEmptyBorder(0,0,0,5));
        add(label);
        add(Box.createHorizontalStrut(10));
        closeButton = new CloseButton();
        closeButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                int index = parentTabbedPane.indexOfTabComponent(CloseControl.this);
                if (index != -1) parentTabbedPane.remove(index);
            }
        });
        add(closeButton);
        setBorder(BorderFactory.createEmptyBorder(1,2,1,2));
    }

    /**
     *
     * @param title
     * @param component
     */
    public CloseControl(final String title, final JSplitPane component) {
        super(new BorderLayout());
        parentSplitPane = component;
        setOpaque(false);
        JLabel label = new JLabel() {
            @Override
            public String getText() {
                return title;
            }
        };
        label.setBorder(BorderFactory.createEmptyBorder(0,0,0,5));
        add(label, BorderLayout.LINE_START);
        closeButton = new CloseButton();
        closeButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent event) {
                parentSplitPane.setRightComponent(null);
                parentSplitPane.setDividerSize(0);
            }
        });
        add(closeButton, BorderLayout.LINE_END);
        setBorder(BorderFactory.createEmptyBorder(2,2,2,2));
        repaintButton = true;
    }

    public void setButtonEnabled(boolean enable) {
        closeButton.setEnabled(enable);
    }

    /*
    * This is where the key work gets done. Create a button and then
    * associate mouse listeners to indicate when the mouse moves into and
    * out of a given instance, as well as close the associated tab when
    * mouse clicked.
    */
    private class CloseButton extends JButton {
        public CloseButton() {
            setPreferredSize(new Dimension(18, 18));
            setToolTipText("Close");
            setUI(new BasicButtonUI());
            setContentAreaFilled(false);
            setOpaque(true);
            setFocusable(false);
            setBorder(BorderFactory.createEmptyBorder());
            setBorderPainted(false);
            setRolloverEnabled(true);
        }

        @Override
        public void updateUI() {}

        @Override
        protected void paintComponent(Graphics graphics) {
            Graphics2D g2 = (Graphics2D) graphics.create();
            g2.setRenderingHints(new RenderingHints(RenderingHints.KEY_ANTIALIASING,
                    RenderingHints.VALUE_ANTIALIAS_ON));
            g2.setStroke(new BasicStroke(3, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_ROUND));

            if (getModel().isRollover()) {
                g2.setColor(Color.decode("0xCF0000"));
                g2.fillRect(0, 0, getWidth(), getHeight());
                g2.setColor(Color.WHITE);
            }
            else {
                if (repaintButton == true) {
                    g2.setColor(UIManager.getColor("Button.background"));
                    g2.fillRect(0, 0, getWidth(), getHeight());
                }
                g2.setColor(Color.decode("0x707070"));
            }

            // Draw an 'X'. Firt stroke is from upper left to lower right.
            // Second stroke is from lower left to upper right.
            //
            int cornerOffset = 4;
            g2.drawLine(cornerOffset-1, cornerOffset-1, getWidth() - cornerOffset, getHeight() - cornerOffset);
            g2.drawLine(getWidth() - cornerOffset, cornerOffset-1, cornerOffset-1, getHeight() - cornerOffset);
            g2.dispose();
        }
    }
}
package tabbedtables;

import javax.swing.table.AbstractTableModel;

public class TableModel extends AbstractTableModel {
    private final String[] columnNames;
    private Object[][] data = null;

    public TableModel(final String[] columnNames, final Object[][] initialData) {
        this.columnNames = columnNames;
        data = initialData;
    }

    @Override
    public int getColumnCount() {
        return columnNames.length;
    }

    @Override
    public int getRowCount() {
        return data != null ? data.length : 0 ;
    }

    @Override
    public String getColumnName(int column) {
        return columnNames[column];
    }

    @Override
    public Object getValueAt(int row, int col) {
        return data != null ? data[row][col] : null ;
    }

    @Override
    public Class getColumnClass(int col) {
        return getValueAt(0, col).getClass();
    }

    /**
     * We want to make the tables read-only. We'll use a complex dialog to
     * do any editing.
     *
     * @param row
     * @param col
     * @return
     */
    @Override
    public boolean isCellEditable(int row, int col) {
        return false;
    }

    @Override
    public void setValueAt(Object value, int row, int col) {
        if (data != null) {
            data[row][col] = value;
            fireTableCellUpdated(row, col);
        }
    }
}
package tabbedtables;

public interface TableDataProvider {
    String[] getColumnNames();
    Object[][] getTableData();
}
package tabbedtables;

public class StaticTestData implements TableDataProvider {
    public static String[] columnNames = {
        "Column 1", "Column 2", "Column 3", "Column 4"
    };

    public static Object[][] sampleData =
    {
        { "sample 1", "sample e", "sample", "sample" },
        { "sample 2", "sample d", "sample", "sample" },
        { "sample 3", "sample c", "sample", "sample" },
        { "sample 4", "sample b", "sample", "sample" },
        { "sample 5", "sample a", "sample", "sample" },
        { "sample", "sample x", "sample", "sample" },
        { "sample", "sample y", "sample", "sample" },
        { "sample", "sample z", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
        { "sample", "sample", "sample", "sample" },
    };

    @Override
    public String[] getColumnNames() {
        return columnNames;
    }

    @Override
    public Object[][] getTableData() {
        return sampleData;
    }
}