javaswinglayoutheightboxlayout

How to make a spoiler in Swing (and why a simple solution is not working)


I'm trying to make a spoiler effect in Swing (like <summary>/<details> tag in HTML). However, if I toggle setVisible() method, the height of my parent containers is not calculated correctly.

All my parent containers of the panels that I'm trying to show and hide use BoxLayout with Page axis.

This is my code:

public class Entry extends javax.swing.JPanel {

    public Entry() {
        initComponents();
    }

    public Entry(Node node) {
        this.node = node;
        initComponents();
        initEvents();
    }

    private void initEvents() {
        marker.addMouseListener(new MouseListener() {

            @Override
            public void mouseClicked(MouseEvent e) {
                if (!opened) open();
                else close();
            }

            @Override
            public void mousePressed(MouseEvent e) {}

            @Override
            public void mouseReleased(MouseEvent e) {}

            @Override
            public void mouseEntered(MouseEvent e) {}

            @Override
            public void mouseExited(MouseEvent e) {}

        });

        addMouseListener(listener);
    }

    public void addChild(Entry child, int pos) {
        content.add(child, pos);
        //content.validate();
    }

    public void inflate(int width) {
        if (node == null) return;
        if (node.nodeType == 1) {
            boolean isPaired = !TagLibrary.tags.containsKey(node.tagName.toLowerCase()) ||
                                TagLibrary.tags.get(node.tagName.toLowerCase());
            if (!isPaired) {
                headerTag.setText("<" + node.tagName.toLowerCase());
                headerTag2.setText(" />");
                threeDots.setText("");
                headerTag3.setText("");
                content.setVisible(false);
                footer.setVisible(false);
                marker.setVisible(false);
            } else {
                headerTag.setText("<" + node.tagName.toLowerCase());
                headerTag2.setText(">");
                headerTag3.setText("</" + node.tagName.toLowerCase() + ">");
                footerTag.setText("</" + node.tagName.toLowerCase() + ">");
            }

            int w = Math.max(Math.max(header.getMinimumSize().width, min_width), width - margin);

            content.removeAll();
            //System.out.println(getWidth());
            for (int i = 0; i < node.children.size(); i++) {
                Entry e = new Entry(node.children.get(i));
                content.add(e);
                e.inflate(w);
            }
            content.doLayout();
            if (node.children.size() > 0) {
                open();
            } else {
                close();
            }
            
        } else if (node.nodeType == 3 && !node.nodeValue.matches("\\s*")) {
            content.removeAll();
            header.setVisible(false);
            footer.setVisible(false);
            JTextArea textarea = new JTextArea();
            textarea.setText(node.nodeValue);
            textarea.setEditable(false);
            textarea.setOpaque(false);
            textarea.setBackground(new Color(255, 255, 255, 0));
            textarea.setColumns(180);
            textarea.setFont(new Font("Tahoma", Font.PLAIN, 16));
            int rows = node.nodeValue.split("\n").length;
            textarea.setRows(rows);
            textarea.addMouseListener(listener);

            int height = getFontMetrics(textarea.getFont()).getHeight() * rows;

            content.add(textarea);

            content.setOpaque(false);

            int w = Math.max(Math.max(header.getMinimumSize().width, min_width), width - margin);
            
            header.setMinimumSize(new Dimension(w, line_height));
            footer.setMinimumSize(new Dimension(w, line_height));

            content.setPreferredSize(new Dimension(w, content.getPreferredSize().height));
            ((JPanel)getParent()).setMinimumSize(new Dimension(w, line_height * 2 + content.getPreferredSize().height));
            opened = true;

            content.validate();
        } else {
            setVisible(false);
            content.removeAll();
            opened = false;
            return;
        }

        int w = Math.max(Math.max(Math.max(content.getMinimumSize().width, header.getMinimumSize().width), min_width), width - margin);

        header.setMinimumSize(new Dimension(w, line_height));
        footer.setMinimumSize(new Dimension(w, line_height));
        content.setPreferredSize(new Dimension(w, content.getPreferredSize().height));

        int height = line_height * 2 + content.getPreferredSize().height;
        if (opened) {
            setSize(w, height);
        }
    }

    public void setWidth(int width) {
        int w = Math.max(Math.max(header.getMinimumSize().width, min_width), width - margin);
        setPreferredSize(new Dimension(w, getPreferredSize().height));
        header.setMinimumSize(new Dimension(w, line_height));
        footer.setMinimumSize(new Dimension(w, line_height));
        content.setPreferredSize(new Dimension(w, content.getPreferredSize().height));
        Component[] c = content.getComponents();
        for (int i = 0; i < c.length; i++) {
            if (c[i] instanceof Entry) {
                ((Entry)c[i]).setWidth(w);
            } else {
                c[i].setSize(w, c[i].getMaximumSize().height);
                c[i].setMaximumSize(new Dimension(w, c[i].getMaximumSize().height));
            }
        }
    }

    public static final int min_width = 280;
    public static final int line_height = 26;
    public static final int margin = 30;

    MouseListener listener = new MouseListener() {

        @Override
        public void mouseClicked(MouseEvent e) {}

        @Override
        public void mousePressed(MouseEvent e) {}

        @Override
        public void mouseReleased(MouseEvent e) {}

        @Override
        public void mouseEntered(MouseEvent e) {
            updateChildren(true);
            repaint();
        }

        @Override
        public void mouseExited(MouseEvent e) {
            updateChildren(false);
            repaint();
        }
    };

    private void updateChildren(boolean value) {
        hovered = value;
        Component[] c = content.getComponents();
        for (int i = 0; i < c.length; i++) {
            if (c[i] instanceof Entry) {
                ((Entry)c[i]).updateChildren(value);
            }
        }
    }

    @Override
    public void paintComponent(Graphics g) {
        if (hovered) {
            g.clearRect(0, 0, getWidth(), getHeight());
            g.setColor(new Color(190, 230, 255, 93));
            g.fillRect(0, 0, getWidth(), getHeight());
        } else {
            g.clearRect(0, 0, getWidth(), getHeight());
            g.setColor(new Color(255, 255, 255));
            g.fillRect(0, 0, getWidth(), getHeight());
        }
        //super.paintComponent(g);
    }

    private boolean hovered = false;

    public void open() {
        marker.setIcon(new javax.swing.ImageIcon(getClass().getResource("/resources/triangle.png")));
        threeDots.setVisible(false);
        headerTag3.setVisible(false);
        content.setVisible(true);
        footer.setVisible(true);
        opened = true;

        //getParent().getParent().setPreferredSize(new Dimension(getParent().getPreferredSize().width, getParent().getPreferredSize().height + delta));
        //getParent().getParent().setPreferredSize(new Dimension(getParent().getParent().getSize().width, getParent().getParent().getSize().height + delta));
        //((JComponent)getParent()).validate();
    }

    public void close() {
        marker.setIcon(new javax.swing.ImageIcon(getClass().getResource("/resources/triangle2.png")));
        content.setVisible(false);
        footer.setVisible(false);
        threeDots.setVisible(has_children);
        marker.setVisible(has_children);
        headerTag3.setVisible(true);
        opened = false;

        //getParent().setPreferredSize(new Dimension(getParent().getPreferredSize().width, getParent().getPreferredSize().height - delta));
        //getParent().getParent().setPreferredSize(new Dimension(getParent().getParent().getSize().width, getParent().getParent().getSize().height - delta));
        //((JComponent)getParent().getParent()).revalidate();
    }

    public void openAll() {
        open();
        Component[] c = content.getComponents();
        for (int i = 0; i < c.length; i++) {
            if (c[i] instanceof Entry) {
                ((Entry) c[i]).openAll();
            }
        }
    }

    public void closeAll() {
        close();
        Component[] c = content.getComponents();
        for (int i = 0; i < c.length; i++) {
            if (c[i] instanceof Entry) {
                ((Entry) c[i]).closeAll();
            }
        }
    }

    public boolean opened = false;

    public Node node;

    @SuppressWarnings("unchecked")
    // <editor-fold defaultstate="collapsed" desc="Generated Code">
    private void initComponents() {

        header = new javax.swing.JPanel();
        headerMargin = new javax.swing.JPanel();
        marker = new javax.swing.JLabel();
        headerTag = new javax.swing.JLabel();
        attributes = new javax.swing.JPanel();
        headerTag2 = new javax.swing.JLabel();
        threeDots = new javax.swing.JLabel();
        headerTag3 = new javax.swing.JLabel();
        content = new javax.swing.JPanel();
        footer = new javax.swing.JPanel();
        footerMargin = new javax.swing.JPanel();
        footerTag = new javax.swing.JLabel();

        setBackground(new java.awt.Color(255, 255, 255));
        setLayout(new javax.swing.BoxLayout(this, javax.swing.BoxLayout.PAGE_AXIS));

        header.setBackground(new java.awt.Color(255, 255, 255));
        header.setAlignmentX(0.0F);
        header.setMaximumSize(new java.awt.Dimension(32767, 26));
        header.setMinimumSize(new java.awt.Dimension(280, 26));
        header.setOpaque(false);
        header.setPreferredSize(new java.awt.Dimension(280, 26));
        header.setLayout(new java.awt.FlowLayout(java.awt.FlowLayout.LEADING, 0, 2));

        headerMargin.setBorder(javax.swing.BorderFactory.createEmptyBorder(2, 0, 0, 5));
        headerMargin.setMaximumSize(new java.awt.Dimension(30, 26));
        headerMargin.setOpaque(false);
        headerMargin.setPreferredSize(new java.awt.Dimension(30, 26));

        marker.setHorizontalAlignment(javax.swing.SwingConstants.TRAILING);
        marker.setIcon(new javax.swing.ImageIcon(getClass().getResource("/resources/triangle.png"))); // NOI18N
        marker.setCursor(new java.awt.Cursor(java.awt.Cursor.DEFAULT_CURSOR));
        marker.setPreferredSize(new java.awt.Dimension(22, 22));

        javax.swing.GroupLayout headerMarginLayout = new javax.swing.GroupLayout(headerMargin);
        headerMargin.setLayout(headerMarginLayout);
        headerMarginLayout.setHorizontalGroup(
            headerMarginLayout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
            .addGroup(headerMarginLayout.createSequentialGroup()
                .addComponent(marker, javax.swing.GroupLayout.PREFERRED_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.PREFERRED_SIZE)
                .addContainerGap(javax.swing.GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE))
        );
        headerMarginLayout.setVerticalGroup(
            headerMarginLayout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
            .addGroup(headerMarginLayout.createSequentialGroup()
                .addComponent(marker, javax.swing.GroupLayout.PREFERRED_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.PREFERRED_SIZE)
                .addContainerGap(javax.swing.GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE))
        );

        header.add(headerMargin);

        headerTag.setFont(new java.awt.Font("Arial", 1, 16)); // NOI18N
        headerTag.setForeground(new java.awt.Color(102, 0, 153));
        headerTag.setText("<body");
        header.add(headerTag);

        attributes.setMaximumSize(new java.awt.Dimension(32767, 26));
        attributes.setOpaque(false);
        attributes.setPreferredSize(new java.awt.Dimension(0, 26));
        attributes.setLayout(new javax.swing.BoxLayout(attributes, javax.swing.BoxLayout.LINE_AXIS));
        header.add(attributes);

        headerTag2.setFont(new java.awt.Font("Arial", 1, 16)); // NOI18N
        headerTag2.setForeground(new java.awt.Color(102, 0, 153));
        headerTag2.setText(">");
        header.add(headerTag2);

        threeDots.setHorizontalAlignment(javax.swing.SwingConstants.CENTER);
        threeDots.setText("...");
        threeDots.setPreferredSize(new java.awt.Dimension(19, 20));
        header.add(threeDots);

        headerTag3.setFont(new java.awt.Font("Arial", 1, 16)); // NOI18N
        headerTag3.setForeground(new java.awt.Color(102, 0, 153));
        headerTag3.setText("</body>");
        header.add(headerTag3);

        add(header);

        content.setBorder(javax.swing.BorderFactory.createEmptyBorder(0, 30, 0, 0));
        content.setAlignmentX(0.0F);
        content.setOpaque(false);
        content.setLayout(new javax.swing.BoxLayout(content, javax.swing.BoxLayout.PAGE_AXIS));
        add(content);

        footer.setBackground(new java.awt.Color(255, 255, 255));
        footer.setAlignmentX(0.0F);
        footer.setMaximumSize(new java.awt.Dimension(32767, 26));
        footer.setOpaque(false);
        footer.setPreferredSize(new java.awt.Dimension(91, 26));
        footer.setLayout(new java.awt.FlowLayout(java.awt.FlowLayout.LEADING, 0, 2));

        footerMargin.setOpaque(false);
        footerMargin.setPreferredSize(new java.awt.Dimension(30, 26));

        javax.swing.GroupLayout footerMarginLayout = new javax.swing.GroupLayout(footerMargin);
        footerMargin.setLayout(footerMarginLayout);
        footerMarginLayout.setHorizontalGroup(
            footerMarginLayout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
            .addGap(0, 30, Short.MAX_VALUE)
        );
        footerMarginLayout.setVerticalGroup(
            footerMarginLayout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
            .addGap(0, 26, Short.MAX_VALUE)
        );

        footer.add(footerMargin);

        footerTag.setFont(new java.awt.Font("Arial", 1, 16)); // NOI18N
        footerTag.setForeground(new java.awt.Color(102, 0, 153));
        footerTag.setText("</body>");
        footer.add(footerTag);

        add(footer);
    }// </editor-fold>


    // Variables declaration - do not modify
    private javax.swing.JPanel attributes;
    private javax.swing.JPanel content;
    private javax.swing.JPanel footer;
    private javax.swing.JPanel footerMargin;
    private javax.swing.JLabel footerTag;
    private javax.swing.JPanel header;
    private javax.swing.JPanel headerMargin;
    private javax.swing.JLabel headerTag;
    private javax.swing.JLabel headerTag2;
    private javax.swing.JLabel headerTag3;
    private javax.swing.JLabel marker;
    private javax.swing.JLabel threeDots;
    // End of variables declaration

}
public class Node {
    
    public Node() {}

    public Node(Node parent_node) {
        if (parent_node.nodeType == 1) {
            parent = parent_node;
            parent_node.addChild(this);
        }
    }

    public Node(int node_type) {
        nodeType = node_type;
    }

    public Node(Node parent_node, int node_type) {
        if (parent_node.nodeType == 1) {
            parent = parent_node;
            parent_node.addChild(this);
        }
        nodeType = node_type;
    }

    public boolean addChild(Node node) {
        if (nodeType == 1) {
            children.add(node);
            return true;
        }
        return false;
    }

    public Node parent;
    public Vector<Node> children = new Vector<Node>();
    public LinkedHashMap<String, String> attributes = new LinkedHashMap<String, String>();
    public Node previousSibling;
    public Node nextSibling;
    public String tagName = "";
    public int nodeType = 3;
    public String nodeValue = "";
}
public class TagLibrary {
    public static void init() {
        if (init) return;

        tags.put("br", false);
        tags.put("hr", false);
        tags.put("link", false);
        tags.put("img", false);
        tags.put("a", true);
        tags.put("span", true);
        tags.put("div", true);
        tags.put("p", true);
        tags.put("sub", true);
        tags.put("sup", true);
        tags.put("b", true);
        tags.put("i", true);
        tags.put("u", true);
        tags.put("s", true);
        tags.put("strong", true);
        tags.put("em", true);
        tags.put("quote", true);
        tags.put("cite", true);
        tags.put("table", true);
        tags.put("thead", true);
        tags.put("tbody", true);
        tags.put("cite", true);
        tags.put("head", true);
        tags.put("body", true);

        leaves.add("style");
        leaves.add("script");

        init = true;
    }

    private static boolean init = false;

    public static Hashtable<String, Boolean> tags = new Hashtable<String, Boolean>();
    public static Vector<String> leaves = new Vector<String>();
}

Main class:

public class WebInspectorTest {

    private static Node prepareTree() {
        Node root = new Node(1);
        root.tagName = "body";

        Node p = new Node(root, 1);
        p.tagName = "p";

        Node text1 = new Node(p, 3);
        text1.nodeValue = "This is a ";
        
        Node i = new Node(p, 1);
        i.tagName = "i";

        Node text2 = new Node(i, 3);
        text2.nodeValue = "paragraph";

        return root;
    }


    public static void main(String[] args) {

        try {
            UIManager.setLookAndFeel(
                UIManager.getSystemLookAndFeelClassName());
        } catch (Exception e) {}

        final Node root = prepareTree();
        if (root == null) return;

        final JFrame frame = new JFrame("Document Inspector");
        JPanel cp = new JPanel();
        cp.setBorder(BorderFactory.createEmptyBorder(9, 10, 9, 10));
        frame.setContentPane(cp);
        cp.setLayout(new BorderLayout());

        final JPanel contentpane = new JPanel();
        contentpane.setBackground(Color.WHITE);
        contentpane.setOpaque(true);

        final int width = 490, height = 418;

        final JScrollPane scrollpane = new JScrollPane(contentpane);
        scrollpane.setOpaque(false);
        scrollpane.getInsets();

        cp.add(scrollpane);
        scrollpane.setBackground(Color.WHITE);
        scrollpane.setOpaque(true);
        scrollpane.setPreferredSize(new Dimension(width, height));

        frame.pack();
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
        SwingUtilities.invokeLater(new Runnable() {

            @Override
            public void run() {
                TagLibrary.init();
                final Entry rootEntry = new Entry(root);
                contentpane.add(rootEntry);

                final JScrollPane sp = scrollpane;

                int width = sp.getVerticalScrollBar().isVisible() ? sp.getWidth() - sp.getVerticalScrollBar().getPreferredSize().width - 12 : sp.getWidth() + sp.getVerticalScrollBar().getPreferredSize().width;
                rootEntry.inflate(width);

                contentpane.addComponentListener(new java.awt.event.ComponentAdapter() {
                    @Override
                    public void componentMoved(java.awt.event.ComponentEvent evt) {}

                    @Override
                    public void componentResized(java.awt.event.ComponentEvent evt) {
                        int width = sp.getVerticalScrollBar().isVisible() ? sp.getWidth() - sp.getVerticalScrollBar().getPreferredSize().width - 12 : sp.getWidth() - 12;
                        rootEntry.setWidth(width);
                    }
                });
            }

        });

    }
}

What is wrong here? I tried setting the new size of the immediate parent directly, that does not help the situation.

Calling revalidate() on the parents does not change anything, too.

To see the effect you can click on a triangle on the left from the first letter in any text line (I can't attach image files here, triangle and triangle2 are two copies of a small 10x10 solid filled blue triangle looking down and right, respectively).

If you try to close the root, it will not open correctly anymore. Also, after the root is close, it gets moved to the center of the parent JScrollPane.

UPDATE: Here is the updated code that seems to fix the "jumping root to center" problem and to fix the minimize/maximize behavior in general. But, the <i> element is still jumping around when being toggled.

public void open() {
        marker.setIcon(new javax.swing.ImageIcon(getClass().getResource("/resources/triangle.png")));
        threeDots.setVisible(false);
        headerTag3.setVisible(false);
        content.setVisible(true);
        footer.setVisible(true);
        opened = true;

        int w = Math.max(Math.max(content.getMinimumSize().width, header.getMinimumSize().width), min_width);
        int height = opened ? line_height * 2 + content.getPreferredSize().height : line_height;
        if (content.getMinimumSize().height > content.getPreferredSize().height) {
            content.setPreferredSize(content.getMinimumSize());
        }
        setSize(w, height);
        setPreferredSize(null);
    }

    public void close() {
        int delta = line_height + content.getPreferredSize().height;
        marker.setIcon(new javax.swing.ImageIcon(getClass().getResource("/resources/triangle2.png")));
        content.setVisible(false);
        footer.setVisible(false);
        boolean has_children = node.children.size() > 0;
        threeDots.setVisible(has_children);
        marker.setVisible(has_children);
        headerTag3.setVisible(true);
        opened = false;

        int w = Math.max(getParent().getSize().width, Math.max(Math.max(content.getMinimumSize().width, header.getMinimumSize().width), min_width));
        int height = opened ? line_height * 2 + content.getPreferredSize().height : line_height;
        setSize(w, height);

        if (getParent().getParent() instanceof Entry) {
            getParent().setSize(new Dimension(getParent().getPreferredSize().width, getParent().getPreferredSize().height - delta));
        } else {
            setPreferredSize(new Dimension(w, height));
        }
    }

UPDATE 2: Seems that the "root entry centering" problem can be fixed another way: I can just set Layout Manager of my root panel inside JScrollPane to null. It also fixes the need to make strange manipulations in resize handler method, where I was substracting empirically found 12 number from the new width to keep the scrolling off when it is not really needed.

But again, the jumping on toggle elements in the middle are still there.

UPDATE 3: I wrote my own very simple layout manager, but it is still not working correctly. In fact it works even worse than BoxLayout. I don't understand, why.

import java.awt.Component;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Insets;
import java.awt.LayoutManager;

public class LinearLayout implements LayoutManager {

    public LinearLayout() {
        this(X_AXIS);
    }

    public LinearLayout(int direction) {
        this.direction = direction;
    }

    public LinearLayout(int direction, int gap) {
        this.direction = direction;
        this.gap = gap;
    }

    @Override
    public void addLayoutComponent(String name, Component comp) {}

    @Override
    public void removeLayoutComponent(Component comp) {}

    @Override
    public Dimension preferredLayoutSize(Container parent) {
        int width = 0;
        int height = 0;
        Insets insets = parent.getInsets();
        Component[] c = parent.getComponents();
        if (direction == X_AXIS) {
            for (int i = 0; i < c.length; i++) {
                if (c[i].getSize().height > height) {
                    height = c[i].getSize().height;
                }
                width += c[i].getSize().width + gap;
            }
        } else {
            for (int i = 0; i < c.length; i++) {
                if (c[i].getSize().width > width) {
                    width = c[i].getSize().width;
                }
                height += c[i].getSize().height + gap;
            }
        }

        width += insets.left + insets.right;
        height += insets.top + insets.bottom;
        return new Dimension(width, height);
    }

    @Override
    public Dimension minimumLayoutSize(Container parent) {
        return preferredLayoutSize(parent);
    }

    @Override
    public void layoutContainer(Container parent) {
        Dimension dim = preferredLayoutSize(parent);
        Component[] c = parent.getComponents();
        Insets insets = parent.getInsets();
        int x = insets.left;
        int y = insets.top;
        for (int i = 0; i < c.length; i++) {
            if (direction == X_AXIS) {
                c[i].setBounds(x, y, c[i].getSize().width, dim.height);
                x += c[i].getSize().width + gap;
            } else {
                c[i].setBounds(x, y, dim.width, c[i].getSize().height);
                y += c[i].getSize().height + gap;
            }
        }
    }

    private int direction = 0;
    private int gap = 0;

    public static final int X_AXIS = 0;
    public static final int Y_AXIS = 1;

}

Solution

  • It seems that, as people in the comments said, I should had defined my own sizing methods (getPreferredSize/getMinimumSize/getMaximumSize) implementation. I implemented them for some of my panels and the problem was solved.