Sunday, March 31, 2013

Auto-collapsing Tree in Java

I'm writing a program in Java using Swing to build the user interface. Programming is not exactly my strong suit, and building graphical user interfaces is pretty much my Kryptonite. So it's no big shock that progress is slow.

One of the controls in my interface is a tree (instance of the JTree class). To keep the interface clean, I want the act of expanding any node to automatically collapse any currently open sibling node (and any open descendants of that sibling). In other words, only one path from the root node to a leaf node should be expanded at any given time.

This seems both simple and something that would be commonplace, so I naively assumed there would be some property setting in JTree to enforce this. Not only did I not find such a property (or method), but I pretty much wore out one of Google's servers looking in vain for any discussion or sample code relating to this. I did not even find any unanswered questions about it online. So either it is not as common a requirement as I thought or my search technique is atrophying.

I eventually found a way that I think works, although it may be a bit inefficient (a hallmark of my coding). Here's a snippet that demonstrates it, applied to an instance mainTree of the JTree class that is defined elsewhere.


    // listen for tree expansion and collapse the previously open path
    mainTree.addTreeWillExpandListener(new TreeWillExpandListener() {
      @Override
      public void treeWillExpand(TreeExpansionEvent event)
                  throws ExpandVetoException {
        TreePath target = event.getPath();  // the path that will expand
        TreePath parent = target.getParentPath();  // parent of the target
        // get the currently expanded descendants of the parent note
        Enumeration<TreePath> expanded = mainTree.getExpandedDescendants(parent);
        // copy the enumeration to a nonvolatile list (collapsing things
        // will alter the enumeration on the fly)
        ArrayList<TreePath> open = new ArrayList<>();
        while (expanded != null && expanded.hasMoreElements()) {
          open.add(expanded.nextElement());
        }
        // no reason to collapse the parent; it will just reexpand when
        // the target expands
        open.remove(parent);        
        // sort the list so that longer paths (nodes deeper in the tree) are 
        // closed first -- this prevents closed nodes from reopening as their
        // descendants are closed
        Collections.sort(open, new Comparator<TreePath>() {
          @Override
          public int compare(TreePath o1, TreePath o2) {
            return -Integer.compare(o1.getPathCount(), o2.getPathCount());
          }
        });
        // now collapse open paths, starting at their lowest levels and
        // working back up the tree toward the common parent
        for (TreePath p : open) {
          mainTree.collapsePath(p);
        }
      };

      @Override
      public void treeWillCollapse(TreeExpansionEvent event) 
                  throws ExpandVetoException {
      }
    });

I'll point out a few key features:
  • I'm attaching a TreeWillExpandListener, which is called after the click that tells Swing a node needs to be expanded but before the expansion actually takes place.
  • The getExpandedDescendants method returns an enumeration of expanded nodes (in the form of TreePath instances). From the Java 7 documentation for this method:
If you expand/collapse nodes while iterating over the returned Enumeration this may not return all the expanded paths, or may return paths that are no longer expanded.
I speak from experience: they're not kidding. In order to collapse everything in the enumeration, I first convert it into a list (ArrayList).
  •  I remove the parent node from the results of the enumeration. It's harmless to collapse the parent, but also pointless: the parent will re-expand when the target child expands.
  •  If you need to collapse a path that descends more than one level from the common parent, you need to do it in reverse order (from the lowest expanded node back toward the parent). Otherwise, as you collapse descendants of some node, Java will re-expand the ancestor. In genealogical terms, if your sister and nephew are currently expanded, and its your turn to expand, the order of collapse has to be nephew first, then sister. Otherwise, if your sister is collapsed first, the act of collapsing the nephew appears to cause Swing to expand the sister. So I sort the list of TreePaths to collapse using an anonymous comparator class that sorts in reverse length order (longest path first -- don't miss the minus sign).
  • The listener listens for both will-expand and will-collapse signals. I left the will-collapse part empty because it's irrelevant to my application.

No comments:

Post a Comment

If this is your first time commenting on the blog, please read the Ground Rules for Comments. In particular, if you want to ask an operations research-related question not relevant to this post, consider asking it on OR-Exchange.