Sunday, April 30, 2006

Smooth JList Drop Target Animation


Web Start and source.

I ran across this nice effect for indicating a drop target in a JList. The ghosted image of an item dragged from the list appears to push aside other entries as it moves through the list of items. A very simple method results in very smooth animation that can span several cells at once. The basic idea is to maintain a list of current positions for all cells, and gradually adjust those positions to match what their final positions would be with a proposed insertion. The animation consists of moving half the distance from the current to the final position (rounding up the final pixel difference). The problem with the sample implementation is that it requires tweaks to both the list model and its LAF UI.

I thought it would be nice to be able to apply this effect to any list without having to replace the LAF UI. Since lists render themselves cell by cell anyway, it shouldn't be that hard to just trick the list into painting each cell where we want it, instead of in its default location.

I focused on just handling an internal drag, but the API on the smoother could easily be tweaked to accept native drags from external sources.

First, to separate the functionality from user triggers, I decided to put the mouse event handling into the demo code. I've found that in UI testing, it's almost always beneficial to be able to drive bits of the UI from direct programmatic methods (would that Swing followed this pattern, so it would be easier to test). The mouse listener decides when to start, update, and end the drag, and calls in to the smoother's corresponding methods.

The smoother itself is a decorator which performs the painting of individual cells by having the original list paint itself fully and then clipping out the unwanted stuff. This could be optimized by just asking for the rendering components, but that potentially omits additional decoration provided by the LAF. So I locate the cell I want within the full list as painted by the LAF, and copy that image to its "floating" location.

    // Paint the background for the insertion point
Rectangle b = getDecorationBounds();
g.setColor(list.getBackground());
g.fillRect(b.x, b.y, b.width, b.height);
for (int i=0;i < list.getModel().getSize();i++) {
if (i == draggedIndex)
continue;
Rectangle r = getCurrentCellBounds(i);
Graphics g2 = g.create(r.x, r.y, r.width, r.height);
Rectangle r2 = list.getCellBounds(i, i);
((Graphics2D)g2).translate(0, -r2.y);
list.paint(g2);
}

The Graphics object is set up to paint only in the desired area, then translated such that the JList will paint the desired cell into that area. We skip the index being dragged, since we want to handle that separately.

To draw the ghosted image, I simply used another decorator that uses the JList to render the dragged item. As the insertion location is updated by the mouse listener, the ghost image adjusts its vertical drawing position (being careful to clamp the vertical bounds to the start and end of the list).

The insertion point could use something more fancy if needed, like using the cell renderer or explicitly painting the rectangle. Just leaving the background exposed works well in this case, though.

A timer task periodically moves each cell's current location toward its final location, and triggers a repaint if any of the locations actually changes. This should be optimized to track a smaller range of cells (like only those visible).

The basic calculation of a cell's desired position simply calculates its normal position, then adjusts for any preceding dragged item (to be removed) and any preceding insertion point (to be inserted). The current locations start off with no dragged item or insertion point, and gradually float to their final positions based on a dragged item and possibly changing insertion point. Note that if you rapidly drag across several items, they all will float smoothly even as their destination changes.

11 comments:

Anthony said...

This is a slick, but noticeable improvement to playing the "guess where it will insert" game. Most important is that the end user will appreciate this bit of GUI magic whether or not they realize it.

Anonymous said...

Hey, this is really cool - it would be nice though if you could subclass JList and create a "JSmoothList" so we don't have to try and rip up your code to use it.

I tried the following to make your class reusable, and my app went nuts (weird painting, crazy-huge preferred sizes, etc). Can you see anything wrong with it?

-Ismail Degani
deganii@gmail.com

public class JSmoothDropList extends JList{
List data;

public JSmoothDropList() {
data = new ArrayList();
setModel(new AbstractListModel() {
public int getSize() {
return data.size();
}
public Object getElementAt(int index) {
return data.get(index);
}
});

DropSmoother smoother = new DropSmoother(this) {
protected void move(int fromIndex, int toIndex) {
Object o = data.remove(fromIndex);
data.add(toIndex, o);
revalidate();
repaint();
}
};

Listener listener = new Listener(smoother);
addMouseListener(listener);
addMouseMotionListener(listener);
}

public void resetData(List data)
{
this.data = data;
revalidate();
repaint();
}
}

Anonymous said...

Hey, this is really cool - it would be nice though if you could subclass JList and create a "JSmoothList" so we don't have to try and rip up your code to use it.

I tried the following to make your class reusable, and my app went nuts (weird painting, crazy-huge preferred sizes, etc). Can you see anything wrong with it?

-Ismail Degani
deganii@gmail.com

public class JSmoothDropList extends JList{
List data;

public JSmoothDropList() {
data = new ArrayList();
setModel(new AbstractListModel() {
public int getSize() {
return data.size();
}
public Object getElementAt(int index) {
return data.get(index);
}
});

DropSmoother smoother = new DropSmoother(this) {
protected void move(int fromIndex, int toIndex) {
Object o = data.remove(fromIndex);
data.add(toIndex, o);
revalidate();
repaint();
}
};

Listener listener = new Listener(smoother);
addMouseListener(listener);
addMouseMotionListener(listener);
}

public void resetData(List data)
{
this.data = data;
revalidate();
repaint();
}
}

technomage said...

Works for me. Make sure you use the latest jar file with source (the main class is named "ListAnimator" rather than "DropSmoother").

Anonymous said...

There is a bug in the getCurrentCellBounds() method in ListAnimator.java.

It throws a NullPointerException cause 'r' is null.

This happens when you put 3 items on a list and try to drag the 2nd item below the 3rd.

Anthony Perritano said...

this is great. do u have an implementation for JTrees DND?

Anonymous said...

How about multiple selection? It seems possible but tricky to extend the framework to do that, especially for multiple interval.

technomage said...

It's tricky if you want to excise all the dragged items from the original list. Internally you'd just need to iterate over the items to be excised, instead of only doing one item.

But really, in what context does it make sense to reposition a discontiguous set of items into a single location?

Bart Massey said...

I'd realy like to use your code for some stuff, but there's no license information in it anywhere that I could find. Could you drop me some email at bart at cs dot pdx dot edu to discuss putting an open source license on the code?

Thanks much for this interesting work!

technomage said...

LGPL, as stated in the project page details at https://sourceforge.net/projects/furbelow.

Anonymous said...

Hi, How to do a JList drag & drop element like an image to JScrollPane which also loaded with an image?