5

I was working with the tree state (expanded/selected nodes) saving and made an utility class that can save and restore node states. It works fine.

But still there is one problem with JTree itself - while user is working with some JTree instance (expanding/collapsing nodes) there might be a situation where some node (hidden under another collapsed node) is expanded. Nothing special about it - that is just fine.

JTree keeps records about expanded/collapsed nodes in a separate expandedState Hashtable using node path as key and boolean as expanded state value. So when that expanded node under collapsed parent node will become visible it will still be expanded since there is a record for it in expandedState Hashtable with true value.

Situation explained on screenshots...
1. Expand root and expand some node ("glassfish4" folder) under root:
enter image description here
2. Collapse root:
enter image description here
3. Expand root again and we still see the child node ("glassfish4" folder) expanded:
enter image description here

Imagine that i have saved tree state at the screenshot #2 moment, when root is collapsed - the problem is that if i want to restore all tree node states (even for the hidden ones) i cannot expand a node under another collapsed node because this will force all parent nodes to expand. Also i cannot access expandedState Hashtable to change expanded states directly inside of it since it is declared private in JTree and there are no good ways to access it. So i cannot fully reproduce initial tree state.

So what i can do is:

  1. Forcefully access that Hashtable through reflection - really bad idea
  2. Rewrite JTree nodes expand logic - this is also a bad idea
  3. Restore all expanded states first then restore all collapsed states - that will force tree to do additional pointless repaints and a lot of additional rendering, so that is a really bad workaround i don't want to use

Maybe i am missing something else?

So basically the question is:
Is there any other way to expand the child nodes without causing parent nodes to expand?

You can find a few classes that i use to save/restore tree state below.

Simply call TreeUtils.getTreeState(tree) to retrieve JTree state and TreeUtils.setTreeState(tree,treeState) to restore JTree state. Note that tree must be using UniqueNode, otherwise those methods will throw ClassCastException - you can simply replace DefaultMutableTreeNode with UniqueNode if you have your own nodes extending DefaultMutableTreeNode.

UniqueNode.java - simple node with its own unique ID

public class UniqueNode extends DefaultMutableTreeNode implements Serializable
{
    /**
     * Prefix for node ID.
     */
    private static final String ID_PREFIX = "UN";

    /**
     * Unique node ID.
     */
    protected String id;

    /**
     * Costructs a simple node.
     */
    public UniqueNode ()
    {
        super ();
        setId ();
    }

    /**
     * Costructs a node with a specified user object.
     *
     * @param userObject custom user object
     */
    public UniqueNode ( Object userObject )
    {
        super ( userObject );
        setId ();
    }

    /**
     * Returns node ID and creates it if it doesn't exist.
     *
     * @return node ID
     */
    public String getId ()
    {
        if ( id == null )
        {
            setId ();
        }
        return id;
    }

    /**
     * Changes node ID.
     *
     * @param id new node ID
     */
    public void setId ( String id )
    {
        this.id = id;
    }

    /**
     * Changes node ID to new random ID.
     */
    private void setId ()
    {
        this.id = TextUtils.generateId ( ID_PREFIX );
    }

    /**
     * {@inheritDoc}
     */
    public UniqueNode getParent ()
    {
        return ( UniqueNode ) super.getParent ();
    }

    /**
     * Returns TreePath for this node.
     *
     * @return TreePath for this node
     */
    public TreePath getTreePath ()
    {
        return new TreePath ( getPath () );
    }
}

TreeUtils.java - utility class that saves/loads TreeState from/into JTree

public class TreeUtils
{
    /**
     * Returns tree expansion and selection states.
     * Tree nodes must be instances of UniqueNode class.
     *
     * @param tree tree to process
     * @return tree expansion and selection states
     */
    public static TreeState getTreeState ( JTree tree )
    {
        return getTreeState ( tree, true );
    }

    /**
     * Returns tree expansion and selection states.
     * Tree nodes must be instances of UniqueNode class.
     *
     * @param tree          tree to process
     * @param saveSelection whether to save selection states or not
     * @return tree expansion and selection states
     */
    public static TreeState getTreeState ( JTree tree, boolean saveSelection )
    {
        TreeState treeState = new TreeState ();

        List<UniqueNode> elements = new ArrayList<UniqueNode> ();
        elements.add ( ( UniqueNode ) tree.getModel ().getRoot () );
        while ( elements.size () > 0 )
        {
            UniqueNode element = elements.get ( 0 );

            TreePath path = new TreePath ( element.getPath () );
            treeState.addState ( element.getId (), tree.isExpanded ( path ), saveSelection && tree.isPathSelected ( path ) );

            for ( int i = 0; i < element.getChildCount (); i++ )
            {
                elements.add ( ( UniqueNode ) element.getChildAt ( i ) );
            }

            elements.remove ( element );
        }

        return treeState;
    }

    /**
     * Restores tree expansion and selection states.
     * Tree nodes must be instances of UniqueNode class.
     *
     * @param tree      tree to process
     * @param treeState tree expansion and selection states
     */
    public static void setTreeState ( JTree tree, TreeState treeState )
    {
        setTreeState ( tree, treeState, true );
    }

    /**
     * Restores tree expansion and selection states.
     * Tree nodes must be instances of UniqueNode class.
     *
     * @param tree             tree to process
     * @param treeState        tree expansion and selection states
     * @param restoreSelection whether to restore selection states or not
     */
    public static void setTreeState ( JTree tree, TreeState treeState, boolean restoreSelection )
    {
        if ( treeState == null )
        {
            return;
        }

        tree.clearSelection ();

        List<UniqueNode> elements = new ArrayList<UniqueNode> ();
        elements.add ( ( UniqueNode ) tree.getModel ().getRoot () );
        while ( elements.size () > 0 )
        {
            UniqueNode element = elements.get ( 0 );
            TreePath path = new TreePath ( element.getPath () );

            // Restoring expansion states
            if ( treeState.isExpanded ( element.getId () ) )
            {
                tree.expandPath ( path );

                // We are going futher only into expanded nodes, otherwise this will expand even collapsed ones
                for ( int i = 0; i < element.getChildCount (); i++ )
                {
                    elements.add ( ( UniqueNode ) tree.getModel ().getChild ( element, i ) );
                }
            }
            else
            {
                tree.collapsePath ( path );
            }

            // Restoring selection states
            if ( restoreSelection )
            {
                if ( treeState.isSelected ( element.getId () ) )
                {
                    tree.addSelectionPath ( path );
                }
                else
                {
                    tree.removeSelectionPath ( path );
                }
            }

            elements.remove ( element );
        }
    }
}

TreeState.java - container class for the map that holds node states

public class TreeState implements Serializable
{
    /**
     * Tree node states.
     */
    protected Map<String, NodeState> states = new LinkedHashMap<String, NodeState> ();

    /**
     * Constructs new object instance with empty states.
     */
    public TreeState ()
    {
        super ();
    }

    /**
     * Constructs new object instance with specified states.
     *
     * @param states node states
     */
    public TreeState ( Map<String, NodeState> states )
    {
        super ();
        if ( states != null )
        {
            setStates ( states );
        }
    }

    /**
     * Returns all node states.
     *
     * @return all node states
     */
    public Map<String, NodeState> getStates ()
    {
        return states;
    }

    /**
     * Sets all node states.
     *
     * @param states all node states
     */
    public void setStates ( Map<String, NodeState> states )
    {
        this.states = states;
    }

    /**
     * Adds node state.
     *
     * @param nodeId   node ID
     * @param expanded expansion state
     * @param selected selection state
     */
    public void addState ( String nodeId, boolean expanded, boolean selected )
    {
        states.put ( nodeId, new NodeState ( expanded, selected ) );
    }

    /**
     * Returns whether node with the specified ID is expanded or not.
     *
     * @param nodeId node ID
     * @return true if node with the specified ID is expanded, false otherwise
     */
    public boolean isExpanded ( String nodeId )
    {
        final NodeState state = states.get ( nodeId );
        return state != null && state.isExpanded ();
    }

    /**
     * Returns whether node with the specified ID is selected or not.
     *
     * @param nodeId node ID
     * @return true if node with the specified ID is expanded, false otherwise
     */
    public boolean isSelected ( String nodeId )
    {
        final NodeState state = states.get ( nodeId );
        return state != null && state.isSelected ();
    }
}

NodeState.java - single node expansion/selection state

public class NodeState implements Serializable
{
    /**
     * Whether node is expanded or not.
     */
    protected boolean expanded;

    /**
     * Whether node is selected or not.
     */
    protected boolean selected;

    /**
     * Constructs empty node state.
     */
    public NodeState ()
    {
        super ();
        this.expanded = false;
        this.selected = false;
    }

    /**
     * Constructs node state with the specified expansion and selection states.
     *
     * @param expanded expansion state
     * @param selected selection state
     */
    public NodeState ( boolean expanded, boolean selected )
    {
        super ();
        this.expanded = expanded;
        this.selected = selected;
    }

    /**
     * Returns whether node is expanded or not.
     *
     * @return true if node is expanded, false otherwise
     */
    public boolean isExpanded ()
    {
        return expanded;
    }

    /**
     * Sets whether node is expanded or not.
     *
     * @param expanded whether node is expanded or not
     */
    public void setExpanded ( boolean expanded )
    {
        this.expanded = expanded;
    }

    /**
     * Returns whether node is selected or not.
     *
     * @return true if node is selected, false otherwise
     */
    public boolean isSelected ()
    {
        return selected;
    }

    /**
     * Sets whether node is selected or not.
     *
     * @param selected whether node is selected or not
     */
    public void setSelected ( boolean selected )
    {
        this.selected = selected;
    }
}

By the way, setTreeState method avoids restoring expanded states under collapsed nodes at the moment:

        // Restoring expansion states
        if ( treeState.isExpanded ( element.getId () ) )
        {
            tree.expandPath ( path );

            // We are going futher only into expanded nodes, otherwise this will expand even collapsed ones
            for ( int i = 0; i < element.getChildCount (); i++ )
            {
                elements.add ( ( UniqueNode ) tree.getModel ().getChild ( element, i ) );
            }
        }
        else
        {
            tree.collapsePath ( path );
        }

Method that gathers child nodes called only if the parent node is expanded. So all child nodes under collapsed nodes are ignored. If you change that behavior you will see the problem i described in the beginning of this question - parent nodes will get expanded.

Mikle Garin
  • 10,083
  • 37
  • 59
  • +1 please is possible to catch those events, trace from TreeModelListener, by using TreeExpansionListener and TreeWillExpandListener (or bug in your L&F, standard issues with JList, JComboBox, JSpinner and JTree and its Model_To_View) – mKorbel Aug 15 '13 at 10:43
  • good question - difficult to answer: afair, even the JTree is only a slave of the expansion state, the real controller is the AbstractLayoutCache created and used by the ui delegate. So I suspect (never tried) that a real solution will envolve a custom layoutCache which requires custom uis ... – kleopatra Aug 15 '13 at 10:51
  • @mKorbel its indeed an option to listen for tree expansion and expand the hidden nodes when they will become visible, but that is a workaround - a better one, but still a workaround. What i want is to restore tree state at once without leaving any traces (like lazy expansion listeners) of restoration "tool" presence. – Mikle Garin Aug 15 '13 at 10:55
  • @kleopatra yeh, i have just noticed AbstractLayoutCache in BasicTreeUI class - i wonder if it keeps state copy of what JTree saves or... – Mikle Garin Aug 15 '13 at 10:59
  • yeah, it keeps a parallel treeModel containing all nodes that had been expanded ever – kleopatra Aug 15 '13 at 11:01
  • @kleopatra well, that seems strange but anyway - thanks for pointing this out. I will investigate it and see if it can be changed (anyway i have my own UIs for all components, so its not a big deal). I am just afraid that if i change it it will not get synchronized with the model in JTree and that might cause serious issues. – Mikle Garin Aug 15 '13 at 11:04
  • @Mikle Garin interesting is fact that one (or more???) of Nodes is spontaneously expanded, I thinkg something about this caching is obviously again in aephyr treasury in connection with his TreeSorter – mKorbel Aug 15 '13 at 11:07
  • @kleopatra including metadata for MutableTreeNode??? – mKorbel Aug 15 '13 at 11:10
  • @mKorbel which metaData? The cached (parallel) model has nodes which contain the original node as userObject (in VariableHeightLayoutCache, the fixed variant keeps less state) – kleopatra Aug 15 '13 at 11:13
  • @kleopatra metadata == cached (in my reduced word translator), you are right I can debug that, really something is there :-) – mKorbel Aug 15 '13 at 11:18

1 Answers1

1

Why not restore the state by performing the same actions as described, first set the sub nodes to expanded then set their parent node to collapsed as necessary?

The only difference to your current code is to use two iterations instead of one. First iterate and expand where desired, then iterate and collapse where desired.

The tree should paint a single time anyway due to the repaint logic.

Holger
  • 285,553
  • 42
  • 434
  • 765
  • I said in the question post "why not?": "Restore all expanded states first then restore all collapsed states - that will force tree to do additional pointless repaints and a lot of additional rendering, so that is a really bad workaround i don't want to use." It might (and it will on a big tree with lots of expansions) repaint a few times and that is what i don't want to happen. That is really bad workaround if you are going to use that i a large application with huge tree structure. – Mikle Garin Aug 26 '13 at 17:37
  • As I pointed out there is no impact on rendering. The JTree will invoke repaint which will do nothing but schedule a paint but all scheduled paints are coerced into a single paint operation. There might be some overhead when the JTree tries to calculate the affected regions which can be avoided by calling `RepaintManager.currentManager(tree).addDirtyRegion(tree, 0, 0, tree.getWidth(), tree.getHeight())` to mark the entire tree as dirty which simplifies any further calculation. – Holger Aug 27 '13 at 17:02
  • As soon as i expand or collapse any node - tree schedules a repaint. Repaint manager will indeed merge those repaints, but only if all expand and collapse actions are made within 1 call to EDT. In my case i have asynchronous childs loading and expand/collapse calls will be made within a few invokeLater calls (as soon as the required child nodes are loaded) and repaint will occur between those calls. I hope that makes the case clear - i just didn't want to make the example complicated by adding unnecessary async node childs loading as it doesn't affect the base logic for this case. – Mikle Garin Aug 27 '13 at 20:37
  • 1
    So you have an asynchronous tree build and restore collapsed/expanded state several times? What do you do if this interferes with user actions changing the collapsed/expanded state of your tree? As far as I understand you have to update the state of a certain subtree right after loading so it makes no difference for that subtree if you do that single iteration or double iteration. That particular piece of code you posted will trigger a single paint operation in either case. The numbers of invokeLater()s you schedule will not change, it depends just on the asynchronous operation. – Holger Aug 28 '13 at 07:35
  • Didn't think about it that way. Loading expansions into the async tree will not look good in that specific case (with expanded nodes under collapsed ones). I guess i should just drop that feature since its not that critical and might produce a lot of garbage code... Thanks for the discussion :) – Mikle Garin Aug 28 '13 at 13:20
  • About user actions interfering with auto-expansion in async tree - if i keep visible rect on the same nodes user is working with and do not change selection - it should be fine. Well, it might actually expand some collapsed by user nodes (and that will look odd), but i can simply stop loading childs for the collapsed by user path. – Mikle Garin Aug 28 '13 at 13:23