javahtmljtextpanejscrollbarhtmleditorkit

JScrollBar + JTextPane with HTML not properly scrolling to maximum value


I have the following problem in a project of mine, took me a while to figure out whats causing the problem, and I can reproduce it with this simple code which I attached.

I am dynamically adding content to a JTextPane with a HTMLEditorKit. I set autoscroll to off because I want to control it manually (when user scrolled up, to stop, and when an event is triggered to get activated again).

The problem now is, when I set the value of the JScrollBar to it's maximum value, it's a different one, just the moment, after having content inserted to the HTMLDocument. When I trigger the setValue again a second time manually, it scrolls to the correct maximum value.

It seems the JScrollBar is not aware about the correct maximumValue just right after adding to the HTMLDocument, and just a (delayed) time later.

Using

caret.setUpdatePolicy(DefaultCaret.ALWAYS_UPDATE);

is not a solution, because it also doesn't work properly. It doesn't scroll to the maximum value too, leaving a view pixel below, which I don't want.

Here is the full code reproducing the issue. If you click on the right button (add & scroll), it inserts a DIV element to the body. The moment the last visible line is reached, it doesn't scroll correctly to the last maximum value, the last line is hidden. But when you just click on the left button manually to trigger a second scrollToEnd(), it scrolls correctly to the maximum value.

Code:

/*
 * To change this license header, choose License Headers in Project Properties.
 * To change this template file, choose Tools | Templates
 * and open the template in the editor.
 */
package javaapplication26;

import java.io.IOException;
import java.math.BigInteger;
import java.security.SecureRandom;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.text.BadLocationException;
import javax.swing.text.DefaultCaret;
import javax.swing.text.Element;
import javax.swing.text.html.HTMLDocument;
import javax.swing.text.html.HTMLEditorKit;

public class NewJFrame extends javax.swing.JFrame {

    /**
     * Creates new form NewJFrame
     */
    public NewJFrame() {

        initComponents();

        this.setSize(500, 200);
        this.setLocationRelativeTo(null);

        this.jTextPane1.setEditorKit(new HTMLEditorKit());
        this.jTextPane1.setContentType("text/html");

        this.jTextPane1.setText("<html><body><div id=\"GLOBALDIV\"></div></body></html>");

        this.jScrollPane1.setHorizontalScrollBarPolicy(javax.swing.ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
        this.jScrollPane1.setVerticalScrollBarPolicy(javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED);

        DefaultCaret caret = (DefaultCaret) this.jTextPane1.getCaret();
        caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE);

        this.jScrollPane1.setAutoscrolls(false);
        this.jTextPane1.setAutoscrolls(false);
    }

    private void scrollToEnd() {

        this.jScrollPane1.getVerticalScrollBar().setValue(this.jScrollPane1.getVerticalScrollBar().getMaximum());
        //this.jTextPane1.setCaretPosition(this.jTextPane1.getDocument().getLength());
    }

    /**
     * This method is called from within the constructor to initialize the form.
     * WARNING: Do NOT modify this code. The content of this method is always
     * regenerated by the Form Editor.
     */
    @SuppressWarnings("unchecked")
    // <editor-fold defaultstate="collapsed" desc="Generated Code">                          
    private void initComponents() {

        jPanel1 = new javax.swing.JPanel();
        jScrollPane1 = new javax.swing.JScrollPane();
        jTextPane1 = new javax.swing.JTextPane();
        jPanel2 = new javax.swing.JPanel();
        jButton1 = new javax.swing.JButton();
        jButton2 = new javax.swing.JButton();

        setDefaultCloseOperation(javax.swing.WindowConstants.EXIT_ON_CLOSE);

        jPanel1.setLayout(new java.awt.BorderLayout());

        jScrollPane1.setViewportView(jTextPane1);

        jPanel1.add(jScrollPane1, java.awt.BorderLayout.CENTER);

        getContentPane().add(jPanel1, java.awt.BorderLayout.CENTER);

        jButton1.setText("Scroll to end");
        jButton1.addActionListener(new java.awt.event.ActionListener() {
            public void actionPerformed(java.awt.event.ActionEvent evt) {
                jButton1ActionPerformed(evt);
            }
        });
        jPanel2.add(jButton1);

        jButton2.setText("Add & scroll");
        jButton2.addActionListener(new java.awt.event.ActionListener() {
            public void actionPerformed(java.awt.event.ActionEvent evt) {
                jButton2ActionPerformed(evt);
            }
        });
        jPanel2.add(jButton2);

        getContentPane().add(jPanel2, java.awt.BorderLayout.PAGE_END);

        pack();
    }// </editor-fold>                        

    private void jButton2ActionPerformed(java.awt.event.ActionEvent evt) {                                         

        try {

            HTMLDocument doc = (HTMLDocument) this.jTextPane1.getDocument();
            HTMLEditorKit editorKit = (HTMLEditorKit) this.jTextPane1.getEditorKit();

            SecureRandom random = new SecureRandom();
            String htmlCode = "<div style=\"background-color: #FFFF22; height: 12px; font-size: 12;\">"+new BigInteger(64, random).toString(64)+"</div>";

            //editorKit.insertHTML(doc, doc.getLength(), htmlCode, 0, 0, null);
            Element element = doc.getElement("GLOBALDIV");

            if (element != null) {
                doc.insertBeforeEnd(element, htmlCode);
            }

            this.scrollToEnd();
        } catch (BadLocationException ex) {
            Logger.getLogger(NewJFrame.class.getName()).log(Level.SEVERE, null, ex);
        } catch (IOException ex) {
            Logger.getLogger(NewJFrame.class.getName()).log(Level.SEVERE, null, ex);
        }
    }                                        

    private void jButton1ActionPerformed(java.awt.event.ActionEvent evt) {                                         

        this.scrollToEnd();
    }                                        

    /**
     * @param args the command line arguments
     */
    public static void main(String args[]) {
        /* Set the Nimbus look and feel */
        //<editor-fold defaultstate="collapsed" desc=" Look and feel setting code (optional) ">
        /* If Nimbus (introduced in Java SE 6) is not available, stay with the default look and feel.
         * For details see http://download.oracle.com/javase/tutorial/uiswing/lookandfeel/plaf.html 
         */
        try {
            for (javax.swing.UIManager.LookAndFeelInfo info : javax.swing.UIManager.getInstalledLookAndFeels()) {
                if ("Nimbus".equals(info.getName())) {
                    javax.swing.UIManager.setLookAndFeel(info.getClassName());
                    break;
                }
            }
        } catch (ClassNotFoundException ex) {
            java.util.logging.Logger.getLogger(NewJFrame.class.getName()).log(java.util.logging.Level.SEVERE, null, ex);
        } catch (InstantiationException ex) {
            java.util.logging.Logger.getLogger(NewJFrame.class.getName()).log(java.util.logging.Level.SEVERE, null, ex);
        } catch (IllegalAccessException ex) {
            java.util.logging.Logger.getLogger(NewJFrame.class.getName()).log(java.util.logging.Level.SEVERE, null, ex);
        } catch (javax.swing.UnsupportedLookAndFeelException ex) {
            java.util.logging.Logger.getLogger(NewJFrame.class.getName()).log(java.util.logging.Level.SEVERE, null, ex);
        }
        //</editor-fold>

        /* Create and display the form */
        java.awt.EventQueue.invokeLater(new Runnable() {
            public void run() {
                new NewJFrame().setVisible(true);
            }
        });
    }

    // Variables declaration - do not modify                     
    private javax.swing.JButton jButton1;
    private javax.swing.JButton jButton2;
    private javax.swing.JPanel jPanel1;
    private javax.swing.JPanel jPanel2;
    private javax.swing.JScrollPane jScrollPane1;
    private javax.swing.JTextPane jTextPane1;
    // End of variables declaration                   
}

This code replacement works though, but leaves a small gap, also not properly scrolling to the maximum value:

this.jTextPane1.setCaretPosition(0);
this.jTextPane1.setCaretPosition(this.jTextPane1.getDocument().getLength());

Solution

  • When you insert the div into the document, the document model is being updated immediately. However, the JTextPane only receives a notification that it is invalid and needs to be laid out. This notification creates an event on the EDT which is only processed after the current event (triggered by the button clicked) has finished.

    Thus, at the moment when you invoke scrollToEnd(), the revalidation of the JTextPane is still pending, and the height of the text pane is still too small.

    In order to get the sequence of events right, you need to schedule the invokation of scrollToEnd() in the EDT, by using invokeLater:

    SwingUtilities.invokeLater(new Runnable(){
        public void run(){
             scrollToEnd();
        }
    });