May 20th, 2009

Animation blueprints for Swing – asynchronous image loading

After adding such animation effects as fading, translucency and load progress to the application window while it connects to the Amazon backend, it’s time to talk about displaying the search results. In this entry i’m going to talk about loading the album art matching the specific search string and asynchronous display of the associated images. This code is part of the Onyx project which aims to provide blueprints for animated Swing applications powered by the Trident animation library.

Here is a screenshot that illustrates the search results displayed as album art (and you can view the videos in the first part of this series):

Each album item displays the following:

  • Album art image
  • Album title
  • Album price

When the application starts, it sends a query to the Amazon backend and displays the load progress indication while the query is processed. When the query results are returned, the application adds an album overview component for each one of the results (details in the next entry). However, there is one more step that needs to be done.

The query results do not contain the actual images, but rather the URLs pointing at those images. The application sends an additional request for each one of those URLs, but we do not want the user to wait until all those images have been downloaded and scaled (if necessary). Instead, an album overview panel is immediately added to the main container, and a image begins loading. Once the image is loaded, it is scaled and then faded in in the matching component.

Here is a walkthrough for the relevant application class:

public class AlbumOverviewComponent extends JComponent {
	/**
	 * The dimensions of the overview image.
	 */
	public static final int OVERVIEW_IMAGE_DIM = 100;
 
	/**
	 * The original album art.
	 */
	private BufferedImage image;
 
	/**
	 * Indicates whether the image loading is done.
	 */
	private boolean imageLoadedDone;
 
	/**
	 * The alpha value of the image. Is updated in the fade-in timeline which
	 * starts after the image has been successfully loaded and scaled.
	 */
	private float imageAlpha;

These fields store the image itself, the alpha value (while the image is faded-in) and boolean indication that the image is available for painting. The later is needed to mark the end of loading and scaling stage.

We also have fields that store the overall component alpha, as well as the album caption and price:

	/**
	 * The album caption.
	 */
	private String caption;
 
	/**
	 * The album price.
	 */
	private String price;
 
	/**
	 * The alpha value of this component. Is updated in the fade-in timeline
	 * which starts when this component becomes a part of the host window
	 * hierarchy.
	 */
	private float alpha;

and a few static fields for the layout purposes:

	/**
	 * Component insets.
	 */
	private static final int INSETS = 7;
 
	/**
	 * Default width of this component.
	 */
	public static final int DEFAULT_WIDTH = 160;
 
	/**
	 * Default height of this component.
	 */
	public static final int DEFAULT_HEIGHT = 180;

Here is the constructor that creates a new album overview component from the album description:

/**
 * Creates a new component that shows overview informartion on the specified
 * album.
 *
 * @param albumItem
 *            Information on an album.
 */
public AlbumOverviewComponent(final Item albumItem) {
	this.caption = albumItem.getItemAttributes().getTitle();
	this.price = albumItem.getItemAttributes().getListPrice()
		.getFormattedPrice();
	this.imageLoadedDone = false;
	this.imageAlpha = 0.0f;
 
	this.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
	this.setOpaque(false);
	this.alpha = 0.0f;
 
	this.addHierarchyListener(new HierarchyListener() {
		@Override
		public void hierarchyChanged(HierarchyEvent e) {
			Timeline shownTimeline = new Timeline(
				AlbumOverviewComponent.this);
			shownTimeline.addPropertyToInterpolate("alpha", 0.0f, 1.0f);
			shownTimeline.addCallback(new Repaint(
					AlbumOverviewComponent.this));
			shownTimeline.setDuration(1000);
			shownTimeline.play();
		}
	});
 
	SwingUtilities.invokeLater(new Runnable() {
		@Override
		public void run() {
			getLoadImageScenario(albumItem).play();
		}
	});
}

Here, we:

  • Set the caption and the title from the album item (this class is generated from the Amazon E-commerce WSDL).
  • Set the alphas to zero.
  • Add a listener to fade in the component once it’s added to the window hierarchy (as in the previous entries)
  • Get the timeline scenario that will load, scale and fade in the image and play it – see below.

The most interesting code is in the getLoadImageScenario. It returns a Trident timeline scenario that has the following sequential steps:

  • Load the BufferedImage from the specified URL. This is done with a TimelineSwingWorker.
  • Scale the loaded image to fit the available dimensions. This is done with a TimelineRunnable.
  • Fade in the scaled image. This is done with a Timeline.

Let’s see the code of this method:

/**
 * Returns the timeline scenario that loads, scaled and fades in the
 * associated album art.
 *
 * @param albumItem
 *            Album item.
 * @return The timeline scenario that loads, scaled and fades in the
 *         associated album art.
 */
private TimelineScenario getLoadImageScenario(final Item albumItem) {
	TimelineScenario loadScenario = new TimelineScenario.Sequence();

It creates a sequential timeline scenario. This is a utility that allows specifying a sequence of timelines, swing workers and runnables and have them run one after another – each one waiting until the previously added one has finished.

First up is the wrapped SwingWorker that loads the image:

// load the image
TimelineSwingWorker imageLoadWorker =
		new TimelineSwingWorker() {
	@Override
	protected Void doInBackground() throws Exception {
		URL url = new URL(albumItem.getMediumImage().getURL());
		image = ImageIO.read(url);
		return null;
	}
};
loadScenario.addScenarioActor(imageLoadWorker);

The next one is the image scaler. Note that for very large images it would be better to have this as a SwingWorker as well. However, in this particular case we are using the “medium” album art images which will not be overly big, and we would be needlessly hogging SwingWorkers that are better suited to long image loading operations.

// scale if necessary
TimelineRunnable scaler = new TimelineRunnable() {
	@Override
	public void run() {
		if (image != null) {
			float vFactor = (float) OVERVIEW_IMAGE_DIM
					/ (float) image.getHeight();
			float hFactor = (float) OVERVIEW_IMAGE_DIM
					/ (float) image.getWidth();
			float factor = Math.min(1.0f, Math.min(vFactor, hFactor));
			if (factor < 1.0f) {
				// scaled to fit available area
				image = OnyxUtils.getScaledInstance(image,
					(int) (factor * image.getWidth()),
					(int) (factor * image.getHeight()),
					RenderingHints.VALUE_INTERPOLATION_BICUBIC,
					true);
			}
 
			imageLoadedDone = true;
		}
	}
};
loadScenario.addScenarioActor(scaler);

Finally, we add a timeline to fade in the scaled image:

// and fade it in
Timeline imageFadeInTimeline = new Timeline(AlbumOverviewComponent.this);
imageFadeInTimeline.addPropertyToInterpolate("imageAlpha", 0.0f, 1.0f);
imageFadeInTimeline
		.addCallback(new Repaint(AlbumOverviewComponent.this));
imageFadeInTimeline.setDuration(500);
loadScenario.addScenarioActor(imageFadeInTimeline);
 
return loadScenario;

Now we need a couple of public setters for the alpha attributes (so that the main Trident engine can interpolate them):

/**
 * Sets the alpha value for the image. Used by the image fade-in timeline.
 *
 * @param imageAlpha
 *            Alpha value for the image.
 */
public void setImageAlpha(float imageAlpha) {
	this.imageAlpha = imageAlpha;
}
 
/**
 * Sets the alpha value. Used by the fade-in timeline.
 *
 * @param alpha
 *            Alpha value for this component.
 */
public void setAlpha(float alpha) {
	this.alpha = alpha;
}

And finally, the custom painting:

@Override
protected void paintComponent(Graphics g) {
	Graphics2D g2d = (Graphics2D) g.create();
 
	g2d.setComposite(AlphaComposite.SrcOver.derive(this.alpha));
	g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
			RenderingHints.VALUE_ANTIALIAS_ON);
	g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
			RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
 
	g2d.setPaint(new GradientPaint(0, 0, new Color(0, 0, 0, 196), 0,
			DEFAULT_HEIGHT, new Color(0, 0, 0, 0)));
	g2d.fillRoundRect(0, 0, DEFAULT_WIDTH, DEFAULT_HEIGHT, 18, 18);
	g2d.drawRoundRect(0, 0, DEFAULT_WIDTH, DEFAULT_HEIGHT, 18, 18);
 
	if (this.imageLoadedDone) {
		Graphics2D g2dImage = (Graphics2D) g2d.create();
		g2dImage.setComposite(AlphaComposite.SrcOver.derive(this.alpha
			* this.imageAlpha));
		// draw the album art image
		g2dImage.drawImage(this.image, (this.getWidth() - this.image
			.getWidth()) / 2, INSETS
			+ (OVERVIEW_IMAGE_DIM - this.image.getHeight()) / 2, null);
		g2dImage.dispose();
	}
 
	g2d.setColor(Color.white);
	g2d.setFont(UIManager.getFont("Label.font").deriveFont(11.0f));
 
	FontMetrics fontMetrics = g2d.getFontMetrics();
	int textY = INSETS + OVERVIEW_IMAGE_DIM + fontMetrics.getHeight();
	int textX = INSETS;
	int textWidth = DEFAULT_WIDTH - INSETS - textX;
	OnyxUtils.paintMultilineText(g2d, this.caption, textX, textWidth,
		textY, 2);
 
	g2d.setColor(new Color(64, 140, 255));
	OnyxUtils.paintMultilineText(g2d, this.price, textX, textWidth, textY
		+ 2 * g2d.getFontMetrics().getAscent(), 1);
 
	g2d.dispose();
}

A few points about the painting:

  • The current alpha values are used to provide the overall and image fade in effects
  • The image is painted only when the imageLoadedDone is true
  • The OnyxUtils.paintMultilineText is a helper method to paint a multiline text

Here we have seen how to load the album art matching the specific search string and asynchronously display the associated images. The next entry is going to talk about scrolling the album covers showed in the container and how to add animations to the scrolling.