Search code examples
javaswingjtable

Is there a simpler approach to have a table like (sorting, changing columns width) for read-only but interactive cells


I would a Swing component that almost behave like a JTable for read-only data, but with interactive cells. The table might have hundreds of rows so adding components to the swing tree might not be the right choice.

Currently I'm hacking around the JTable by making the interactive cells editable. This fills hacky and misusing the API, however there's no choice there.

interactive jtable read-only cells

import java.awt.*;
import java.awt.event.MouseEvent;
import java.util.Objects;
import javax.swing.*;
import javax.swing.event.MouseInputAdapter;
import javax.swing.table.DefaultTableModel;
import javax.swing.table.TableCellEditor;
import javax.swing.table.TableCellRenderer;

public final class InteractiveTableCells extends JPanel {
    private InteractiveTableCells() {
        super(new BorderLayout());

        String wagonsData = "";
        Object[][] data = {
                {124, wagonsData},
                {13, wagonsData},
                {78, wagonsData},
                {103, wagonsData}
        };
        var model = new DefaultTableModel(data, new String[] {"Seats", "Train"}) {
            @Override
            public Class<?> getColumnClass(int column) {
                return getValueAt(0, column).getClass();
            }

            @Override
            public boolean isCellEditable(int row, int column) {
                return column == 1;
            }
        };
        var table = new JTable(model);
        table.setRowHeight(30);
        table.setColumnSelectionAllowed(false);

        var trainColumn = table.getColumnModel().getColumn(1);
        trainColumn.setCellRenderer(new TrainChartPanelRenderer());
        trainColumn.setCellEditor(new TrainChartPanelEditor(table));

        add(new JScrollPane(table));
        setPreferredSize(new Dimension(320, 240));
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            var frame = new JFrame("Read-only Interactive Table Cells");
            frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
            frame.getContentPane().add(new InteractiveTableCells());
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        });
    }
}

class InteractiveChartPanel extends JPanel {

    private final Rectangle wagon1 = new Rectangle(4, 2, 30, 16);
    private final Rectangle wagon2 = new Rectangle(4 + 30 + 4, 2, 30, 16);
    private final Rectangle wagon3 = new Rectangle(38 + 30 + 4, 2, 30, 16);
    private final Rectangle wagon4 = new Rectangle(72 + 30 + 4, 2, 30, 16);
    private Rectangle hoveredWagon = null;

    protected InteractiveChartPanel() {
        super();
        setOpaque(true);

        addMouseMotionListener(new MouseInputAdapter() {
            @Override
            public void mouseMoved(MouseEvent e) {
                var location = MouseInfo.getPointerInfo().getLocation();
                SwingUtilities.convertPointFromScreen(location, InteractiveChartPanel.this);

                var oldHoveredWagon = hoveredWagon;
                if (wagon1.contains(location)) {
                    hoveredWagon = wagon1;
                } else if (wagon2.contains(location)) {
                    hoveredWagon = wagon2;
                } else if (wagon3.contains(location)) {
                    hoveredWagon = wagon3;
                } else if (wagon4.contains(location)) {
                    hoveredWagon = wagon4;
                } else {
                    hoveredWagon = null;
                }

                if (!Objects.equals(oldHoveredWagon, hoveredWagon)) {
                    repaint();
                }
            }
        });
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        Graphics2D g2 = (Graphics2D) g;

        g2.setColor(Color.ORANGE);
        g2.fill(wagon1);
        g2.fill(wagon2);
        g2.fill(wagon3);
        g2.fill(wagon4);
        
        if (hoveredWagon != null) {
            g2.setColor(Color.ORANGE.darker());
            g2.fill(hoveredWagon);
        }
    }
}

class TrainChartPanelRenderer extends InteractiveChartPanel implements TableCellRenderer {
    @Override
    public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
        setBackground(isSelected ? table.getSelectionBackground() : table.getBackground());
        return this;
    }
}

class TrainChartPanelEditor extends AbstractCellEditor implements TableCellEditor {
    private final InteractiveChartPanel panel = new InteractiveChartPanel();

    protected TrainChartPanelEditor(JTable table) {
        table.addMouseMotionListener(new MouseInputAdapter() {
            @Override
            public void mouseMoved(MouseEvent e) {
                int r = table.rowAtPoint(e.getPoint());
                int c = table.columnAtPoint(e.getPoint());

                if (table.isCellEditable(r, c)
                        && (table.getEditingRow() != r || table.getEditingColumn() != c) // avoid flickering, when the mouse mouve over the same cell
                ) {
                    // Cancel previous, otherwise editCellAt will invoke stopCellEditing which
                    // actually get the current value from the editor and set it to the model (see editingStopped)
                    if (table.isEditing() && r >= 0 && c >= 0) {
                        table.getCellEditor().cancelCellEditing();
                    }
                    table.editCellAt(r, c);
                } else {
                    if (table.isEditing() || r < 0 || c < 0) {
                        table.getCellEditor().cancelCellEditing();
                    }
                }
            }
        });
        panel.addMouseListener(new MouseInputAdapter() {
            @Override
            public void mouseExited(MouseEvent e) {
                SwingUtilities.invokeLater(TrainChartPanelEditor.this::fireEditingCanceled);
            }
        });
    }

    @Override
    public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) {
        panel.setBackground(isSelected ? table.getSelectionBackground() : table.getBackground());
        return panel;
    }

    @Override
    public Object getCellEditorValue() {
        throw new IllegalStateException("Editing should have been cancelled");
    }
}

Bonus, in my case the last row might have longer content, and I'm not quite sure how to adjust the row height dynamically when the JTable is resized for example. And without triggering an infinite loop as calling setRowHeight(row) in the cell renderer can trigger a relayout and then invoke the cell renderer again.


Solution

  • You don’t need to deal with cell editors if you only want a hover highlighting. Just a mouse(motion)listener on the table itself, to update the affected cells, is enough.

    For example

    import java.awt.*;
    import java.awt.event.MouseAdapter;
    import java.awt.event.MouseEvent;
    import javax.swing.*;
    import javax.swing.table.AbstractTableModel;
    import javax.swing.table.DefaultTableCellRenderer;
    import java.util.List;
    
    public final class InteractiveTableCells {
        public static void main(String... args) {
            if(!EventQueue.isDispatchThread()) {
                EventQueue.invokeLater(InteractiveTableCells::main);
                return;
            }
            var frame = new JFrame("Read-only Interactive Table Cells");
            frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
            frame.setContentPane(createContent());
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        }
    
        private static Container createContent() {
    
            Integer[] data = { 124, 13, 78, 103 };
    
            var model = new AbstractTableModel() {
                @Override
                public int getRowCount() {
                    return data.length;
                }
    
                @Override
                public int getColumnCount() {
                    return 2;
                }
    
                @Override
                public String getColumnName(int column) {
                    return column == 0? "Seats": "Train";
                }
    
                @Override
                public Class<?> getColumnClass(int column) {
                    return column == 0? Integer.class: String.class;
                }
    
                @Override
                public Object getValueAt(int rowIndex, int columnIndex) {
                    return columnIndex == 0? data[rowIndex]: "";
                }
            };
            var table = new JTable(model);
            table.setRowHeight(30);
            table.setColumnSelectionAllowed(false);
    
            var trainRenderer = new TrainRenderer();
            var defCellRenderer = new DefaultTableCellRenderer();
            defCellRenderer.setIcon(trainRenderer);
            table.getColumnModel().getColumn(1).setCellRenderer(
                (comp, value, selected, focus, row, column) -> {
                    trainRenderer.paintingActive = row == trainRenderer.activeRow;
                    return defCellRenderer.getTableCellRendererComponent(
                            comp, value, selected, focus, row, column);
                });
    
            var mouseListener = new MouseAdapter() {
                @Override
                public void mouseMoved(MouseEvent e) {
                    Point p = e.getPoint();
                    check(table.rowAtPoint(p), table.columnAtPoint(p), p);
                }
    
                @Override
                public void mouseExited(MouseEvent e) {
                    check(-1, -1, null);
                }
    
                private void check(int r, int c, Point p) {
                    int lastActive = trainRenderer.activeRow;
                    if(c != 1) {
                        trainRenderer.activeRow = -1;
                        r = -1;
                    }
                    if(r < 0 && lastActive < 0) return;
                    if(r >= 0) {
                        var rect = table.getCellRect(r, c, false);
                        p.x -= rect.x;
                        p.y -= rect.y;
                        int oldCar = trainRenderer.activeCar;
                        if(!trainRenderer.check(p, r)) r = -1;
                        else if(r != lastActive || trainRenderer.activeCar != oldCar)
                            table.repaint(rect);
                    }
                    if(r != lastActive && lastActive >= 0) {
                        table.repaint(table.getCellRect(lastActive, 1, false));
                    }
                }
            };
            table.addMouseMotionListener(mouseListener);
            table.addMouseListener(mouseListener);
            var sp = new JScrollPane(table);
            sp.setPreferredSize(new Dimension(320, 240));
            return sp;
        }
    }
    
    class TrainRenderer implements Icon {
        private final Rectangle wagon1 = new Rectangle(4, 2, 30, 16);
        private final Rectangle wagon2 = new Rectangle(4 + 30 + 4, 2, 30, 16);
        private final Rectangle wagon3 = new Rectangle(38 + 30 + 4, 2, 30, 16);
        private final Rectangle wagon4 = new Rectangle(72 + 30 + 4, 2, 30, 16);
        private final List<Rectangle> allWagons = List.of(wagon1, wagon2, wagon3, wagon4);
    
        int activeRow = -1, activeCar = -1;
        boolean paintingActive;
    
        @Override
        public void paintIcon(Component c, Graphics g, int x, int y) {
            Graphics2D g2 = (Graphics2D)g;
            g2.translate(x, y);
            for(int i = 0, num = allWagons.size(); i < num; i++) {
                g2.setColor(
                    paintingActive && activeCar == i? Color.ORANGE.darker(): Color.ORANGE);
                g2.fill(allWagons.get(i));
            }
            g2.translate(-x, -y);
        }
    
        boolean check(Point p, int row) {
            for(int i = 0, num = allWagons.size(); i < num; i++) {
                if(allWagons.get(i).contains(p)) {
                    activeRow = row;
                    activeCar = i;
                    return true;
                }
            }
            activeRow = -1;
            activeCar = -1;
            return false;
        }
    
        @Override
        public int getIconWidth() {
            return wagon4.x + wagon4.width;
        }
    
        @Override
        public int getIconHeight() {
            return wagon4.y + wagon4.height;
        }
    }
    

    Note that I also removed all unnecessary subclass relationships, compare with Prefer composition over inheritance?

    By using the default cell renderer, you get the look&feel’s highlighting, as well as some performance optimizations for free.