8

I have a JTable which can have rows dynamically added by the user. It sits in a JScrollPane, so as the number of rows gets large enough, the scroller becomes active. My desire is that when the user adds a new row, the scroller moves all the way to the bottom, so that the new row is visible in the scrollpane. I'm currently (SSCCE below) trying to use a table model listener to detect when the row is inserted, and force the scrollbar all the way down when the detection is made. However, it seems this detection is "too early," as the model has updated but the new row has not actually been painted yet, so what happens is the scroller moves all the way to the bottom just before the new row is inserted, and then the new row is inserted just below the end of the pane (out of visibility).

Obviously this approach is wrong somehow. What is the correct approach?

import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;

import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JScrollBar;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTable;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
import javax.swing.table.DefaultTableModel;

public class TableListenerTest {

    private JFrame frame;
    private JScrollPane scrollPane;
    private JTable table;
    private DefaultTableModel tableModel;

    public static void main(String[] args) {
        EventQueue.invokeLater(new Runnable() {
            public void run() {
                try {
                    TableListenerTest window = new TableListenerTest();
                    window.frame.setVisible(true);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });
    }

    public TableListenerTest() {
        initialize();
    }

    private void initialize() {
        frame = new JFrame();
        frame.setBounds(100, 100, 450, 200);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.getContentPane().setLayout(new BoxLayout(frame.getContentPane(), BoxLayout.Y_AXIS));

        JSplitPane splitPane = new JSplitPane();
        frame.getContentPane().add(splitPane);

        scrollPane = new JScrollPane();
        scrollPane.setPreferredSize(new Dimension(100, 2));
        splitPane.setLeftComponent(scrollPane);

        tableModel = new DefaultTableModel(new Object[]{"Stuff"},0);
        table = new JTable(tableModel);
        scrollPane.setViewportView(table);
        table.getModel().addTableModelListener(new TableModelListener() {
            public void tableChanged(TableModelEvent e) {
                if (e.getType() == TableModelEvent.INSERT) {
                    JScrollBar scrollBar = scrollPane.getVerticalScrollBar();
                    scrollBar.setValue(scrollBar.getMaximum());
                }
            }
        });

        JButton btnAddRow = new JButton("Add Row");
        btnAddRow.addMouseListener(new MouseAdapter() {
            @Override
            public void mouseClicked(MouseEvent e) {
                tableModel.addRow(new Object[]{"new row"});
            }
        });
        splitPane.setRightComponent(btnAddRow);
    }
}

EDIT: Updated SSCCE below based on trashgod's request. This version still does not work, however, if I move the scrolling logic from the table model listener to the button listener as he did, then it does work!

import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Rectangle;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;

import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTable;
import javax.swing.event.TableModelEvent;
import javax.swing.event.TableModelListener;
import javax.swing.table.DefaultTableModel;

    public class TableListenerTest {

        private JFrame frame;
        private JScrollPane scrollPane;
        private JTable table;
        private DefaultTableModel tableModel;

        public static void main(String[] args) {
            EventQueue.invokeLater(new Runnable() {
                public void run() {
                    try {
                        TableListenerTest window = new TableListenerTest();
                        window.frame.setVisible(true);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            });
        }

        public TableListenerTest() {
            initialize();
        }

        private void initialize() {
            frame = new JFrame();
            frame.setBounds(100, 100, 450, 200);
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.getContentPane().setLayout(new BoxLayout(frame.getContentPane(), BoxLayout.Y_AXIS));

            JSplitPane splitPane = new JSplitPane();
            frame.getContentPane().add(splitPane);

            scrollPane = new JScrollPane();
            scrollPane.setPreferredSize(new Dimension(100, 2));
            splitPane.setLeftComponent(scrollPane);

            tableModel = new DefaultTableModel(new Object[]{"Stuff"},0);
            table = new JTable(tableModel);
            scrollPane.setViewportView(table);
            table.getModel().addTableModelListener(new TableModelListener() {
                public void tableChanged(TableModelEvent e) {
                    if (e.getType() == TableModelEvent.INSERT) {
                        int last = table.getModel().getRowCount() - 1;
                        Rectangle r = table.getCellRect(last, 0, true);
                        table.scrollRectToVisible(r);
                    }
                }
            });

            JButton btnAddRow = new JButton("Add Row");
            btnAddRow.addMouseListener(new MouseAdapter() {
                @Override
                public void mouseClicked(MouseEvent e) {
                    tableModel.addRow(new Object[]{"new row"});
                }
            });
            splitPane.setRightComponent(btnAddRow);
        }
    }
The111
  • 5,757
  • 4
  • 39
  • 55

2 Answers2

8

This example uses scrollRectToVisible() to (conditionally) scroll to the last cell rectangle. As a feature you can click on the thumb to suspend scrolling and release to resume.

private void scrollToLast() {
    if (isAutoScroll) {
        int last = table.getModel().getRowCount() - 1;
        Rectangle r = table.getCellRect(last, 0, true);
        table.scrollRectToVisible(r);
    }
}

Addendum: I tried scrollRectToVisible in my SSCCE, and it still exhibits the same problem.

This Action provides both mouse and keyboard control:

JButton btnAddRow = new JButton(new AbstractAction("Add Row") {

    @Override
    public void actionPerformed(ActionEvent e) {
        tableModel.addRow(new Object[]{"new row"});
        int last = table.getModel().getRowCount() - 1;
        Rectangle r = table.getCellRect(last, 0, true);
        table.scrollRectToVisible(r);
    }
});

enter image description here

Addendum: Here's a variation on your example that illustrates a revised layout strategy.

/** @see https://stackoverflow.com/a/14429388/230513 */
public class TableListenerTest {

    private static final int N = 8;
    private JFrame frame;
    private JScrollPane scrollPane;
    private JTable table;
    private DefaultTableModel tableModel;

    public static void main(String[] args) {
        EventQueue.invokeLater(new Runnable() {

            @Override
            public void run() {
                TableListenerTest window = new TableListenerTest();
                window.frame.setVisible(true);
            }
        });
    }

    public TableListenerTest() {
        initialize();
    }

    private void initialize() {
        frame = new JFrame();
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setLayout(new BoxLayout(frame.getContentPane(), BoxLayout.X_AXIS));
        tableModel = new DefaultTableModel(new Object[]{"Stuff"}, 0);
        for (int i = 0; i < N; i++) {
            tableModel.addRow(new Object[]{"new row"});
        }
        table = new JTable(tableModel) {

            @Override
            public Dimension getPreferredScrollableViewportSize() {
                return new Dimension(200, table.getRowHeight() * N);
            }
        };
        scrollPane = new JScrollPane();
        scrollPane.setViewportView(table);
        JButton btnAddRow = new JButton(new AbstractAction("Add Row") {

            @Override
            public void actionPerformed(ActionEvent e) {
                tableModel.addRow(new Object[]{"new row"});
                int last = table.getModel().getRowCount() - 1;
                Rectangle r = table.getCellRect(last, 0, true);
                table.scrollRectToVisible(r);
            }
        });
        frame.add(scrollPane);
        frame.add(btnAddRow);
        frame.pack();
    }
}
Community
  • 1
  • 1
trashgod
  • 203,806
  • 29
  • 246
  • 1,045
  • Thanks, but it doesn't seem to directly solve my problem, which makes me wonder if it is impossible? In your example you're constantly checking once per second whether or not you should scroll to the end. However, I want that to happen as soon as "add row" is clicked. If I click the button a bunch in your app, it suffers the same problem mine does (but "fixes" itself in less than 1 second). Is it impossible to have the scrolling happen as soon as the user clicks the button, because the model and GUI are on separate threads I'm guessing? – The111 Jan 20 '13 at 21:22
  • I should add that I tried `scrollRectToVisible` in my SSCCE and it still exhibits the same problem, which is perhaps more relevant than what I just wrote above. – The111 Jan 20 '13 at 21:24
  • I've elaborated above; please update your question with your current code. – trashgod Jan 21 '13 at 00:44
  • Thanks for the elaboration. This technique (adding the scroll logic to the button listener rather than the table model listener) does seem to work. It is not quite the code structure I'd desired, but I guess I can adapt. I update my OP with the second version of code I was trying, which still didn't work (still trying to accomplish the scrolling in response to the table model insert event). I'd still be curious why that doesn't work. My best guess now is that the model listener fires too early (as soon as the insert event begins), whereas moving that logic to the button listener... – The111 Jan 21 '13 at 00:56
  • ... completely after the table add row call, ensures that the insert event has fully completed. What I'd really like is a table event that fires when the insert is complete and the table reflects the model, but I guess that is not possible. – The111 Jan 21 '13 at 00:57
  • 1
    I've updated the example for easier testing. One way to sequence events is to wrap the scroll manipulation in `EventQueue.invokeLater()`. – trashgod Jan 21 '13 at 01:11
  • Isn't entire `initialize` method (and therefore all of the code) already happening on the EDT because of the `invokeLater()` wrapper around the entire `main` method? – The111 Jan 21 '13 at 01:29
  • Yes, but the goal is to _sequence_ events, as promised by `EventQueue`. The original [example](http://stackoverflow.com/a/7519403/230513) does this from another thread, and it's legal to do it from the EDT. – trashgod Jan 21 '13 at 01:36
1

However, it seems this detection is "too early,"

For emphasis (@trashgod already mentioned it in a comment): that's true - and expected and a fairly general issue in Swing :-)

The table - and any other view with any model, not only data but also selection, adjustment, ... - is listening to the model to update itself. So a custom listener is just yet another listener in the line (with serving sequence undefined). If it wants to do something that depends on the view state it has to make sure to do so after all internal update is ready (in this concrete context, the internal update includes the update of the adjustmentModel of the vertical scrollBar) Postponing the custom processing until the internals are done, is achieved by wrapping SwingUtilities.invokeLater:

TableModelListener l = new TableModelListener() {
    @Override
    public void tableChanged(TableModelEvent e) {
        if (e.getType() == TableModelEvent.INSERT) {
            invokeScroll();
        }
    }

    protected void invokeScroll() {
        SwingUtilities.invokeLater(new Runnable() {
            public void run() {
                int last = table.getModel().getRowCount() - 1;
                Rectangle r = table.getCellRect(last, 0, true);
                table.scrollRectToVisible(r);
            }
        });
    }
};
table.getModel().addTableModelListener(l);
kleopatra
  • 51,061
  • 28
  • 99
  • 211
  • Thanks for the response. I think it may have helped me understand something I didn't get when @trashgod said it. He mentioned wrapping my scroll call in an `invokeLater` as you did, and I thought that wouldn't accomplish anything since my entire program was already in such a wrapper. I noticed now that the wrapper creates a **new** `Runnable`, which I guess is what achieves the "sequencing" he was talking about, guaranteeing that this new runnable doesn't run until the line it follows in the other runnable is complete. Is that correct at all? – The111 Jan 27 '13 at 22:26