/* Copyright (c) 2006-2007 Timothy Wall, All Rights Reserved * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * <p/> * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. */ package furbelow; import java.awt.*; import java.awt.datatransfer.Transferable; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseMotionListener; import java.util.*; import java.util.Timer; import javax.swing.*; import javax.swing.tree.*; import javax.swing.border.EmptyBorder; import javax.swing.event.TreeExpansionEvent; import javax.swing.event.TreeExpansionListener; /** * Animates moving tree cells out of the way for a potential drop. This * decorator completely over-paints the target JTree, optionally * painting a dragged item and animating creation of a space for the * dragged item to be dropped. */ // TODO: limit drag to below root node, if visible // TODO: spring-loaded folders: how to drop on/before // TODO: un-spring folders: but collapsing a previous folder is disruptive // TODO: change graphics to left of node (e.g. if next-to-last becomes last) // TODO: animate canceled drops (slide dragged object back to its home) // public class TreeAnimator extends AbstractComponentDecorator implements TreeExpansionListener { protected class DragDestination { /** Path to new parent. */ public TreePath parentPath; /** Index within new parent. */ public int index; /** Actual visible location of the insertion space. */ public int placeholderRow; public DragDestination(TreePath path, int i, int insertionSpaceRow) { this.parentPath = path; this.index = i; this.placeholderRow = insertionSpaceRow; } public String toString() { return parentPath.toString() + ":" + index + " (" + placeholderRow + ")"; } } /** * Animation repaint interval. Make this larger to slow down the * animation. */ private static final int INTERVAL = 1000 / 24; private static Timer timer = new Timer(true); static final int HORIZONTAL_THRESHOLD = 5; /** Simple decorator to provide the ghosted image being dragged. */ private final class GhostedDragImage extends AbstractComponentDecorator { private TreePath path; private Point location; private Point offset; public GhostedDragImage(TreePath path, Point origin) { super(tree, JLayeredPane.DRAG_LAYER.intValue()); this.path = path; Rectangle b = tree.getPathBounds(path); location = origin; this.offset = new Point(origin.x - b.x, origin.y - b.y); } public void setLocation(Point where, TreePath parentPath) { this.location = new Point(where); Rectangle b = tree.getPathBounds(path); Rectangle lastRow = tree.getRowBounds(tree.getRowCount()-1); int height = lastRow.y + lastRow.height; Point origin = new Point(b.x, location.y - offset.y); // Select a horizontal offset appropriate for a child of the // given parent path if (parentPath != null) { int count = path.getPathCount(); if (!tree.isRootVisible() || !tree.getShowsRootHandles()) --count; Insets insets = tree.getInsets(); int delta = (origin.x - (insets != null ? insets.left : 0)) / count; b = tree.getPathBounds(parentPath); origin.x = b.x + delta; } location.x = origin.x; location.y = Math.max(0, origin.y); location.y = Math.min(location.y, height - b.height); getPainter().repaint(); } public Point getLocation() { return location; } public Rectangle getBounds() { return new Rectangle(location.x, location.y, getPainter().getWidth(), getPainter().getHeight()); } public void paint(Graphics g) { Rectangle b = tree.getPathBounds(path); g = g.create(location.x, location.y, b.width, b.height); ((Graphics2D)g).translate(-b.x, -b.y); ((Graphics2D)g).setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f)); tree.paint(g); } } private final class Counter extends TimerTask { public boolean painted; public synchronized void painted() { painted = true; } public void run() { synchronized(bounds) { if (painted && moveTowardProjectedLocation()) { synchronized(this) { painted = false; repaint(); } } } } } private Counter counter; /** Index of insertion point. */ private int placeholderRow = -1; private TreePath placeholderParentPath; private int placeholderIndex = -1; /** Actual point used to determine placeholder row. */ private Point placeholderLocation; /** Object being dragged, if any. */ private TreePath draggedPath; private JTree tree; private Map bounds = new HashMap(); private GhostedDragImage dragImage; private Point origin; private boolean dragActive; public TreeAnimator(final JTree tree) { super(tree); this.tree = tree; } /** The default assumes any node except the root may be moved. */ protected boolean canMove(TreePath path) { Object o = path.getLastPathComponent(); return !o.equals(tree.getModel().getRoot()); } /** * Returns whether the node at the given path may be moved to the given * index on the given target path. The default disallows moves only * if the target is a descendent of the moved path. */ protected boolean canMove(TreePath fromPath, TreePath toPath, int index) { if (canMove(fromPath)) { TreePath testPath = toPath; while (testPath != null) { if (testPath.equals(fromPath)) return false; testPath = testPath.getParentPath(); } return true; } return false; } /** * Request that the node on the given path be moved to the given * index on the given target path. If toPath is the parent path to * fromPath, then the index represents the insertion index * <em>after</em> the object is removed from its current index. */ protected void moveNode(TreePath fromPath, TreePath toPath, int index) { Object moved = fromPath.getLastPathComponent(); Object movedTo = toPath.getLastPathComponent(); if (tree.getModel() instanceof DefaultTreeModel && moved instanceof MutableTreeNode && movedTo instanceof MutableTreeNode) { DefaultTreeModel treeModel = (DefaultTreeModel)tree.getModel(); MutableTreeNode child = (MutableTreeNode)moved; MutableTreeNode newParent = (MutableTreeNode)movedTo; treeModel.removeNodeFromParent(child); treeModel.insertNodeInto(child, newParent, index); } else { throw new UnsupportedOperationException("You must override move()"); } } private int getIndex(TreePath parentPath, TreePath childPath) { Object parent = parentPath.getLastPathComponent(); Object child = childPath.getLastPathComponent(); return tree.getModel().getIndexOfChild(parent, child); } /** Start a local drag. Returns whether the drag is started. */ public boolean startDrag(Point where) { draggedPath = tree.getPathForLocation(where.x, where.y); if (draggedPath != null && canMove(draggedPath)) { dragActive = true; tree.collapsePath(draggedPath); origin = where; placeholderRow = tree.getRowForPath(draggedPath); placeholderParentPath = draggedPath.getParentPath(); placeholderIndex = getIndex(placeholderParentPath, draggedPath); placeholderLocation = new Point(where); dragImage = new GhostedDragImage(draggedPath, origin); return true; } return false; } public void treeExpanded(TreeExpansionEvent e) { synchronized(bounds) { int oldRowCount = bounds.size(); int rows = tree.getRowCount(); int start = tree.getRowForPath(e.getPath()) + 1; Rectangle rect = tree.getPathBounds(e.getPath()); for (int i=0;i < rows - oldRowCount;i++) { TreePath path = tree.getPathForRow(start + i); bounds.put(path, new Rectangle(rect)); } } repaint(); } public void treeCollapsed(TreeExpansionEvent e) { synchronized(bounds) { for (Iterator i=bounds.keySet().iterator();i.hasNext();) { TreePath path = (TreePath)i.next(); if (tree.getRowForPath(path) == -1) { i.remove(); } } } repaint(); } public void setVisible(boolean visible) { super.setVisible(visible); if (visible) { tree.addTreeExpansionListener(this); int size = tree.getRowCount(); synchronized(bounds) { for (int i = 0; i < size; i++) { TreePath path = tree.getPathForRow(i); bounds.put(path, getProjectedPathBounds(path)); } } counter = new Counter(); timer.schedule(counter, INTERVAL, INTERVAL); } else { tree.removeTreeExpansionListener(this); synchronized(bounds) { bounds.clear(); } if (counter != null) { counter.cancel(); counter = null; } } } /** End an internal drag. */ public void endDrag(Point where) { if (!dragActive) throw new IllegalStateException("Not dragging"); DragDestination loc = getDragDestination(where); int draggedRow = tree.getRowForPath(draggedPath); Rectangle ghostBounds = dragImage.getBounds(); dragImage.dispose(); dragImage = null; placeholderRow = -1; placeholderParentPath = null; placeholderIndex = -1; placeholderLocation = null; if (loc != null && loc.placeholderRow != -1 && (loc.placeholderRow != draggedRow || !loc.parentPath.equals(draggedPath.getParentPath()))) { moveNode(draggedPath, loc.parentPath, loc.index); synchronized(bounds) { // Set the dragged item's location to the current ghost location bounds.put(tree.getPathForRow(loc.placeholderRow), ghostBounds); } } draggedPath = null; dragActive = false; } public void dispose() { tree.removeTreeExpansionListener(this); super.dispose(); } private boolean moveTowardProjectedLocation() { boolean changed = false; int count = 0; for (Iterator i = bounds.keySet().iterator(); i.hasNext();) { TreePath path = (TreePath)i.next(); Rectangle current = (Rectangle)bounds.get(path); if (current == null) { System.err.println("warning: no current bounds for " + path); i.remove(); continue; } Rectangle end = getProjectedPathBounds(path); if (end == null) { System.err.println("warning: no final bounds for " + path); i.remove(); continue; } if (current.x != end.x || current.y != end.y) { int xdelta = (end.x - current.x) / 2; int ydelta = (end.y - current.y) / 2; if (xdelta == 0) current.x = end.x; else current.x += xdelta; if (ydelta == 0) current.y = end.y; else current.y += ydelta; bounds.put(path, current); changed = true; ++count; } } return changed; } /** Return a proposed insertion location for the given coordinate given in * actual JTree coordinate space. If there is no vertical row change from * the current insertion position, then horizontal movement is used to * make changes in depth. * Returns null if no insertion is allowed at the given location. */ protected DragDestination getDragDestination(Point where) { int x = where.x; int y = where.y; int size = tree.getRowCount(); Rectangle appendBounds = tree.getRowBounds(size - 1); appendBounds.y += appendBounds.height; appendBounds.height = 0; int draggedRow = tree.getRowForPath(draggedPath); int cursorRow = tree.getClosestRowForLocation(x, y); TreePath parentPath = null; int index = 0; if (cursorRow == draggedRow && Math.abs(where.x-placeholderLocation.x) < HORIZONTAL_THRESHOLD) { // no-op parentPath = draggedPath.getParentPath(); index = getIndex(parentPath, draggedPath); } else if (cursorRow == 0) { // Can't insert above root if (tree.isRootVisible()) { return null; } // Insert as first child of root parentPath = new TreePath(tree.getModel().getRoot()); index = 0; } else { // Use the previous path as reference int priorRow = cursorRow - 1; if (draggedRow <= priorRow) { ++priorRow; } TreePath priorPath = tree.getPathForRow(priorRow); boolean isLeaf = tree.getModel().isLeaf(priorPath.getLastPathComponent()); if (!isLeaf && tree.isExpanded(priorPath)) { parentPath = priorPath; index = 0; } else { parentPath = priorPath.getParentPath(); index = getIndex(parentPath, priorPath) + 1; index = adjustIndex(parentPath, index); } } // If there's no row change, check for horizontal movement to // change the target depth if (cursorRow == placeholderRow && horizontalMovementAllowed(cursorRow, draggedRow)) { // move deeper if (where.x >= placeholderLocation.x + HORIZONTAL_THRESHOLD) { if (parentPath.equals(placeholderParentPath)) { index = placeholderIndex; } else { while (!parentPath.getParentPath().equals(placeholderParentPath)) { parentPath = parentPath.getParentPath(); } index = tree.getModel().getChildCount(parentPath.getLastPathComponent()); index = adjustIndex(parentPath, index); } } // move up in hierarchy else if (where.x <= placeholderLocation.x - HORIZONTAL_THRESHOLD) { if (placeholderParentPath.getParentPath() != null) { parentPath = placeholderParentPath.getParentPath(); index = getIndex(parentPath, placeholderParentPath) + 1; index = adjustIndex(parentPath, index); } else { parentPath = placeholderParentPath; index = placeholderIndex; } } else { parentPath = placeholderParentPath; index = placeholderIndex; } } return new DragDestination(parentPath, index, cursorRow); } /** Disallow horizontal movement if the preceding and following nodes * have the same parent, or if the dragged node is the first child and * there are subsequent children. */ private boolean horizontalMovementAllowed(int cursorRow, int draggedRow) { int priorRow = cursorRow - 1; if (draggedRow <= priorRow) { ++priorRow; } TreePath priorPath = tree.getPathForRow(priorRow); int nextRow = cursorRow; if (draggedRow <= nextRow) { ++nextRow; } TreePath nextPath = tree.getPathForRow(nextRow); TreePath parentPath = priorPath.getParentPath(); if (parentPath != null) { if (nextPath != null) { if (parentPath.equals(nextPath.getParentPath()) && parentPath.equals(placeholderParentPath)) { return false; } } } if (placeholderParentPath.equals(priorPath) && priorPath.isDescendant(nextPath)) { return false; } return true; } private int adjustIndex(TreePath parentPath, int index) { if (parentPath.equals(draggedPath.getParentPath())) { int draggedIndex = getIndex(parentPath, draggedPath); if (draggedIndex < index) { --index; } } return index; } /** Invoke this method as the cursor location changes. */ public void setPlaceholderLocation(Point where) { if (!dragActive) throw new IllegalStateException("Not dragging"); // Avoid painting focus border and/or selection bgs, kind of a hack getPainter().requestFocus(); tree.clearSelection(); // end hack DragDestination loc = getDragDestination(where); TreePath parentPath = null; if (loc != null && draggedPath != null) { if (canMove(draggedPath, loc.parentPath, loc.index)) { int lastRow = placeholderRow; parentPath = loc.parentPath; setPlaceholderRow(loc.placeholderRow); if (lastRow != loc.placeholderRow || !parentPath.equals(placeholderParentPath) || Math.abs(where.x - placeholderLocation.x) >= HORIZONTAL_THRESHOLD) { placeholderLocation = new Point(where); } placeholderParentPath = parentPath; placeholderIndex = loc.index; } } dragImage.setLocation(where, parentPath); } protected int getPlaceholderRow() { return placeholderRow; } private void setPlaceholderRow(int idx) { if (idx != placeholderRow) { placeholderRow = idx; repaint(); } } private Rectangle getProjectedPathBounds(TreePath path) { Rectangle pathBounds = tree.getPathBounds(path); if (draggedPath != null) { int row = tree.getRowForPath(path); int removalRow = tree.getRowForPath(draggedPath); Rectangle draggedBounds = tree.getPathBounds(draggedPath); if (removalRow < row && row <= placeholderRow) { pathBounds.y -= draggedBounds.height; } else if (placeholderRow <= row && row < removalRow) { pathBounds.y += draggedBounds.height; } } return pathBounds; } /** Returns the bounds of the current path, which may be in motion * towards its final destination. */ private Rectangle getCurrentCellBounds(TreePath path) { synchronized(bounds) { Rectangle after = getProjectedPathBounds(path); Rectangle current = (Rectangle)bounds.get(path); if (current != null) { after.x = current.x; after.y = current.y; } return after; } } public void paint(Graphics g) { boolean db = tree.isDoubleBuffered(); tree.setDoubleBuffered(false); try { Rectangle b = getDecorationBounds(); g.setColor(tree.getBackground()); g.fillRect(b.x, b.y, b.width, b.height); int prevIndex = -1; Rectangle prevBounds = null; // Draw last to first, to avoid having children occlude parents // when nodes expand for (int i = tree.getRowCount()-1; i >=0; i--) { TreePath path = tree.getPathForRow(i); if (path.equals(draggedPath)) { continue; } // visible bounds of the row (may be in motion) Rectangle visibleBounds = getCurrentCellBounds(tree.getPathForRow(i)); // actual offset of the row in the tree Rectangle treeRowBounds = tree.getRowBounds(i); // If there's a gap between the previous and current rows, // repeat the left-most graphics of the current row in the gap if (prevIndex != -1 && prevBounds.y > visibleBounds.y + visibleBounds.height) { Rectangle space = new Rectangle(0, visibleBounds.y + visibleBounds.height, prevBounds.x, prevBounds.y - visibleBounds.y - visibleBounds.height); Rectangle prevTreeRowBounds = tree.getRowBounds(prevIndex); for (int j = 0; j < space.height; j++) { Graphics g2 = g.create(space.x, space.y + j, space.width, 1); ((Graphics2D)g2).translate(0, -prevTreeRowBounds.y - 1); tree.paint(g2); } } Graphics g2 = g.create(0, visibleBounds.y, visibleBounds.x + visibleBounds.width, visibleBounds.height); ((Graphics2D)g2).translate(0, -treeRowBounds.y); tree.paint(g2); prevIndex = i; prevBounds = visibleBounds; } if (counter != null) counter.painted(); } finally { tree.setDoubleBuffered(db); } } /** * Simple JTree-local drag/drop handler. Invokes the animator * according to user input. A similar method could be used to accept * drags originating outside of the JTree. */ static class Listener extends MouseAdapter implements MouseMotionListener { private TreeAnimator animator; private boolean dragActive; private Point origin; public Listener(TreeAnimator smoother) { this.animator = smoother; } private boolean sufficientMove(Point where) { int dx = Math.abs(origin.x - where.x); int dy = Math.abs(origin.y - where.y); return Math.sqrt(dx * dx + dy * dy) > 5; } public void mousePressed(MouseEvent e) { origin = e.getPoint(); } public void mouseReleased(MouseEvent e) { if (dragActive) { animator.endDrag(e.getPoint()); dragActive = false; } } public void mouseDragged(MouseEvent e) { if (!dragActive) { if (sufficientMove(e.getPoint())) { dragActive = animator.startDrag(origin); } } if (dragActive) animator.setPlaceholderLocation(e.getPoint()); } public void mouseExited(MouseEvent e) { if (dragActive) animator.setPlaceholderLocation(e.getPoint()); } public void mouseEntered(MouseEvent e) { if (dragActive) animator.setPlaceholderLocation(e.getPoint()); } public void mouseMoved(MouseEvent e) { } } /** * Throw up a frame to demonstrate the animator at work. */ public static void main(String[] args) { JFrame f = new JFrame("Animated Tree Effects"); f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); final JTree tree = new JTree(); tree.setFont(tree.getFont().deriveFont(Font.BOLD, tree.getFont().getSize() * 1.4f)); TreeAnimator animator = new TreeAnimator(tree); Listener listener = new Listener(animator); tree.addMouseListener(listener); tree.addMouseMotionListener(listener); JLabel label = new JLabel("Drag items to reorder"); label.setBorder(new EmptyBorder(4, 4, 4, 4)); label.setFont(label.getFont().deriveFont(Font.BOLD, label.getFont().getSize() * 2)); label.putClientProperty("decorator", new AbstractComponentDecorator(label, -1) { public void paint(Graphics g) { Rectangle b = getDecorationBounds(); ((Graphics2D)g).setPaint(new GradientPaint(0, b.height / 2, UIManager.getColor("Tree.selectionBackground"), b.width / 2, b.height / 2, Color.white)); g.fillRect(b.x, b.y, b.width, b.height); } }); f.getContentPane().add(label, BorderLayout.NORTH); f.getContentPane().add(new JScrollPane(tree)); f.pack(); f.setVisible(true); } }