Animation blueprints for SWT – asynchronous image loading

August 9th, 2009

After adding such animation effects as fading 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 Granite project which aims to provide blueprints for animated SWT 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 Canvas {
   /**
    * The dimensions of the overview image.
    */
   public static final int OVERVIEW_IMAGE_DIM = 100;

   /**
    * The original album art.
    */
   private Image 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;

   /**
    * The alpha value of the border. Is updated in the fade-in timeline which
    * starts when the mouse moves over this component.
    */
   private float borderAlpha;

These fields store the image itself, the alpha value for the image (while the image is faded-in), the alpha value for the border (for the rollover pulsation animation) 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 int 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 information on the specified
    * album.
    *
    * @param albumItem
    *            Information on an album.
    */
   public AlbumOverviewComponent(Composite parent, final Item albumItem) {
      super(parent, SWT.DOUBLE_BUFFERED | SWT.TRANSPARENT);
      this.caption = albumItem.getItemAttributes().getTitle();
      this.price = albumItem.getItemAttributes().getListPrice()
            .getFormattedPrice();
      this.imageLoadedDone = false;
      this.imageAlpha = 0.0f;

      this.setCursor(new Cursor(Display.getDefault(), SWT.CURSOR_HAND));
      this.alpha = 0;

      final Timeline rolloverTimeline = new Timeline(this);
      rolloverTimeline.addPropertyToInterpolate("borderAlpha", 0.0f, 0.6f);
      rolloverTimeline.addCallback(new SWTRepaintCallback(
            AlbumOverviewComponent.this));
      rolloverTimeline.setEase(new Spline(0.7f));
      rolloverTimeline.setDuration(800);
      this.addMouseTrackListener(new MouseTrackAdapter() {
         @Override
         public void mouseEnter(MouseEvent e) {
            rolloverTimeline.playLoop(RepeatBehavior.REVERSE);
         }

         @Override
         public void mouseExit(MouseEvent e) {
            rolloverTimeline.playReverse();
         }
      });

      this.addControlListener(new ControlAdapter() {
         @Override
         public void controlResized(ControlEvent e) {
            if (borderAlpha > 0.0f)
               rolloverTimeline.playReverse();
         }
      });

      Timeline shownTimeline = new Timeline(AlbumOverviewComponent.this);
      shownTimeline.addPropertyToInterpolate("alpha", 0, 255);
      shownTimeline.addCallback(new SWTRepaintCallback(
            AlbumOverviewComponent.this));
      shownTimeline.setDuration(1000);
      shownTimeline.play();

      Display.getDefault().asyncExec(new Runnable() {
         @Override
         public void run() {
            getLoadImageScenario(albumItem).play();
         }
      });
   }

This constructor:

  • Initializes the album caption and price from the album item (this class is generated from the Amazon E-commerce WSDL)
  • Creates a rollover timeline to interpolate the borderAlpha property
  • Registers a mouse listener to loop the rollover timeline when the mouse enters this component, and play this timeline in reverse (once) when the mouse exits the component
  • Registers a control listener to play the rollover timeline in reverse when the component is resized (gets new bounds)
  • Creates and plays a timeline that fades in the component by interpolating the alpha property
  • 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 Image from the specified URL. This is done with a custom wrapper around an Eclipse Job.
  • 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 Job that loads the image:

      // load the image
      EclipseJobTimelineScenarioActor imageLoadWorker = new EclipseJobTimelineScenarioActor(
            "Load image") {
         @Override
         protected IStatus run(IProgressMonitor monitor) {
            try {
               URL url = new URL(albumItem.getMediumImage().getURL());
               image = new Image(Display.getDefault(), url.openStream());
               return Status.OK_STATUS;
            } catch (Throwable t) {
               t.printStackTrace();
               return Status.CANCEL_STATUS;
            }
         }
      };
      loadScenario.addScenarioActor(imageLoadWorker);

The next one is the image scaler. Note that for very large images it would be better to have this as an Eclipse Job 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 jobs 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.getImageData().height;
               float hFactor = (float) OVERVIEW_IMAGE_DIM
                     / (float) image.getImageData().width;
               float factor = Math.min(1.0f, Math.min(vFactor, hFactor));
               if (factor < 1.0f) {
                  // scaled to fit available area
                  image = GraniteUtils.getScaledInstance(image,
                        (int) (factor * image.getImageData().width),
                        (int) (factor * image.getImageData().height));
               }

               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 SWTRepaintCallback(
            AlbumOverviewComponent.this));
      imageFadeInTimeline.setDuration(500);
      loadScenario.addScenarioActor(imageFadeInTimeline);

      return loadScenario;
   }

Now we need the 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 for the border. Used by the rollover timeline.
    *
    * @param borderAlpha
    *            Alpha value for the border.
    */
   public void setBorderAlpha(float borderAlpha) {
      this.borderAlpha = borderAlpha;
   }

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

And finally, the custom painting:

      this.addPaintListener(new PaintListener() {
         @Override
         public void paintControl(PaintEvent e) {
            GC gc = e.gc;
            gc.setAlpha(alpha);
            gc.setAntialias(SWT.ON);

            Pattern pattern = new Pattern(e.display, 0, 0, 0,
                  DEFAULT_HEIGHT, e.display
                        .getSystemColor(SWT.COLOR_BLACK), 196,
                  e.display.getSystemColor(SWT.COLOR_BLACK), 0);
            gc.setBackgroundPattern(pattern);
            gc.setForegroundPattern(pattern);
            gc.fillRoundRectangle(0, 0, DEFAULT_WIDTH - 1,
                  DEFAULT_HEIGHT - 1, 18, 18);
            gc.drawRoundRectangle(0, 0, DEFAULT_WIDTH - 1,
                  DEFAULT_HEIGHT - 1, 18, 18);
            pattern.dispose();

            if (borderAlpha > 0.0f) {
               // show the pulsating bluish outline of the rollover album
               Color borderColor = new Color(e.display, 64, 140, 255);
               Pattern borderPattern = new Pattern(e.display, 0, 0, 0,
                     DEFAULT_HEIGHT, borderColor,
                     (int) (196 * borderAlpha), borderColor, 0);
               LineAttributes currLineAttr = gc.getLineAttributes();
               gc.setLineAttributes(new LineAttributes(2.0f,
                     SWT.CAP_ROUND, SWT.JOIN_ROUND));
               gc.setForegroundPattern(borderPattern);
               gc.drawRoundRectangle(1, 1, DEFAULT_WIDTH - 2,
                     DEFAULT_HEIGHT - 2, 18, 18);
               gc.setLineAttributes(currLineAttr);
               borderPattern.dispose();
               borderColor.dispose();
            }

            if (imageLoadedDone) {
               gc.setAlpha((int) (alpha * imageAlpha));
               // draw the album art image
               gc.drawImage(image, (getBounds().width - image
                     .getImageData().width) / 2,
                     INSETS
                           + (OVERVIEW_IMAGE_DIM - image
                                 .getImageData().height) / 2);
               gc.setAlpha(alpha);
            }

            FontData fontData = gc.getDevice().getSystemFont()
                  .getFontData()[0];
            gc.setFont(new Font(gc.getDevice(), fontData.getName(), 9,
                  SWT.NORMAL));

            FontMetrics fontMetrics = gc.getFontMetrics();
            int textY = INSETS + OVERVIEW_IMAGE_DIM
                  + fontMetrics.getDescent();
            int textX = INSETS;
            int textWidth = DEFAULT_WIDTH - INSETS - textX;

            gc
                  .setForeground(gc.getDevice().getSystemColor(
                        SWT.COLOR_WHITE));
            GraniteUtils.paintMultilineText(AlbumOverviewComponent.this,
                  gc, caption, textX, textWidth, textY, 2);

            gc.setForeground(new Color(gc.getDevice(), 64, 140, 255));
            GraniteUtils.paintMultilineText(AlbumOverviewComponent.this,
                  gc, price, textX, textWidth, textY + 2
                        * fontMetrics.getHeight(), 1);
         }
      });

A few points about the painting:

  • The current alpha values are used to provide the fade-in effects on the overall component, the loaded image and the pulsating rollover effect on the border
  • The image is painted only when the imageLoadedDone is true
  • The GraniteUtils.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.