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.
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.
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.