Dynamic user interface is only skin deep

Java skins are an alternative approach to a pluggable look and feel

A skin is a collection of images and a definition file, which together describe an application interface. You have no doubt come across applications using skins already. On Windows, Linux, and other operating systems, an increasing number of applications (and games) allow users to completely change the look and feel of the user interface. The packages used to implement these design changes are usually called skins.

Winamp -- an MP3 player by Nullsoft (see Resources for more info) -- is one application that uses skins. Nullsoft actively encourages skin development for Winamp, and, according to its Website, more than 5,000 skins are currently available. Games such as Quake III Arena use skins to allow players to select the characters who will represent them during play, and create entirely new characters. Skins even help you alter the look of your operating system desktop according to your whims.

Why should you use skins? Well, if you write applets, especially for the Internet, and cannot guarantee that your user base already has JDK 1.2 installed, or has access to broadband, there is significant overhead with downloading Swing. The same argument applies if you write a downloadable application, or one in which the application size may be an issue.

Another advantage to using skins is you can fit a pre-existing library of lightweight components into a dynamic (skin-based) model without too much pain. If you have ever worked with a designer, then you probably spent hours reworking your layouts to make a minor modification to the interface design. Use a skin, and the design can be altered in a few moments without the need to recompile. Better yet, skins allow you to hand over complete control of the interface layout to your designer.

There are also marketing considerations. Winamp might not have been as popular if more than 5,000 designs were not available for download. But skins' big selling point for me is the fact that after I brand an application for one company, I can rebrand it for another company, or for a shrink-wrapped version, quickly and easily.

Note: to download the complete source code for this article as a zip file or in tar.gz format, see Resources.

How it works

The specification of a basic customizable interface is relatively simple. You'll need:

  • A new layout manager
  • A properties file that contains information about how the interface is laid out
  • An ImageLabel (lightweight) component for noninteractive areas of the screen
  • An ImageButton (lightweight) component for interactive areas of the screen
  • Other lightweight components as necessary for the application

The following code samples define the most basic lightweight components -- the ImageLabel and ImageButton. Those who already have a library of lightweight components should skip ahead to the layout manager definition.

ImageLabel.java

The following code implements the ImageLabel lightweight component, rendering an image onto a supplied graphics context. Please note that a utility class -- Utils -- is used for loading images:

import java.awt.Component;
import java.awt.Graphics;
import java.awt.Image;
/**
 * A lightweight component which simply renders an image
 */
public class ImageLabel extends Component {
 /**
  * the image to be rendered
  */
  Image image = null;
 /**
  * set the image for this component
  */
  public void setImage(String imageFilename) throws InterruptedException {
    image = ImageUtils.getImage(imageFilename);
  }
 /**
  * draw the image onto the Graphics context
  */
  public void paint(Graphics g) {
    if (image != null)
      
g.drawImage(image,getLocation().x,getLocation().y,getSize().width,getSize()
.height,null);
  }
}

ImageButton.java

The ImageButton component is similar to ImageLabel, but has additional methods for focused and pressed images:

  • public void setImageFocus(String imageFilename) throws InterruptedException
  • public void setImagePress(String imageFilename) throws InterruptedException

In addition, ImageButton requires functionality to support interaction:

  • public void addActionListener(ActionListener listener)
  • public void removeActionListener(ActionListener listener
  • )

  • public void processMouseEvent(MouseEvent e)

The layout manager -- SkinLayout.java

SkinLayout is constructed with one of two methods specifying the location of the properties file (either an input stream or a filename):

  • public SkinLayout(InputStream in) throws IOException
  • public SkinLayout(String layoutFile) throws FileNotFoundException, IOException

The file is read into a Properties object ready for use. Components can be added to the layout using two methods. The first method conforms to the 1.1 version of addLayoutComponent(). The constraint must be a string. This string is the name of the component and is used to retrieve layout information from the Properties object:

  • public void addLayoutComponent(Component comp, Object constraints)
  • public void addLayoutComponent(String name, Component comp)

The positioning and sizing of components in a container are performed by the layoutContainer() method:

public void layoutContainer(Container target) {
  synchronized (target.getTreeLock()) {
    try {
      // call processAttributes for the container itself ("" = no component name)
      if (layoutProperties.containsKey("attributes")) {
        processAttributes("", target);
      }
      Enumeration e = components.keys();
      while (e.hasMoreElements()) {
        String s    = e.nextElement().toString();
        if (!components.containsKey(s)) {
          System.out.println("error. component missing");
        }
        Component c = (Component)components.get(s);
        // minimum attributes need to layout a component
        int xpos    = new Integer(layoutProperties.get(s + 
".xpos").toString()).intValue();
        int ypos    = new Integer(layoutProperties.get(s + 
".ypos").toString()).intValue();
        int width   = new Integer(layoutProperties.get(s + 
".width").toString()).intValue();
        int height  = new Integer(layoutProperties.get(s + 
".height").toString()).intValue();
        c.setLocation(xpos,ypos);
        c.setSize(width,height);
        // visible is not a mandatory attribute, so we check if it's there first
        if (layoutProperties.containsKey(s + ".visible") &&
            layoutProperties.get(s + 
".visible").toString().equalsIgnoreCase("false")) {
          c.setVisible(false);
        }
        // now check for custom attributes
        if (layoutProperties.containsKey(s + ".attributes")) {
          processAttributes(s + ".", c);
        }
      }
    }
    catch (Exception ex) {
      ex.printStackTrace();
    }
  }
}

The layoutContainer() method also calls processAttributes() (which is private within the SkinLayout class) to attempt to set other properties on a component, such as its image:

/**
 * For a specified component name, find its string of attributes, then
 * attempt to find a corresponding set method in the component for each
 * attribute. The set method must take a string as its parameter.
 * eg. for an attribute string "gobutton.attributes=image=temp.gif,
 * text=Test" we will attempt to find a setImage and setText method in
 * the component 'gobutton' with a single String parameter
 */
private void processAttributes(String s, Component c) throws Exception {
  StringTokenizer st, attr_st;
  // retrieve the list of methods
  Method methods[] = c.getClass().getMethods();
  if (methods != null && methods.length > 0) {
    // tokenize the attributes, delimited by comma's
    st = new StringTokenizer(layoutProperties.get(s + 
"attributes").toString(),",");
    // loop for each individual attribute
    while (st.hasMoreTokens()) {
      attr_st = new StringTokenizer(st.nextToken(),"=");
      if (attr_st.countTokens() != 2) {
        throw new Exception("invalid attribute string");
      }
      String attribute = attr_st.nextToken();
      String value     = attr_st.nextToken();
      // find the associated method
      // if found, then invoke it with the attribute value
      boolean found = false;
      for (int i = 0; i <methods.length; i++) {
        if (methods[i].getName().equalsIgnoreCase("set" + attribute) &&
            methods[i].getParameterTypes().length == 1 &&
            
methods[i].getParameterTypes()[0].getName().equals("java.lang.String")) {
          found = true;
          Object values[] = { value };
          methods[i].invoke(c,values);
        }
      }
      if (!found) {
        System.out.println("could not find a corresponding method for set" 
+
                attribute + " in [" + s + "]");
      }
    }
    attr_st = null;
  }
  st = null;
}

Layout properties file

The properties file will contain some basic information about the application screen, plus all the components used on that screen. The following example includes settings for the application width and height in addition to attributes for one button. These attributes will set the image, the pressed image, and the focused image.

width=230
height=42
back.width=50
back.height=40
back.xpos=0
back.ypos=0
back.visible=true
back.attributes=image=back1_nf.gif,imagepress=back1_press.gif, imagefocus=back1_f.gif

Putting it all together

For testing purposes, TestApplet.java implements part of a toolbar (three buttons), along with two buttons to change the layout of the toolbar. (If you have a browser that supports JDK 1.1, click TestApplet.html to see this applet running.) The init() method of TestApplet is as follows:

public void init () {
  try {
    // call setup at the beginning so any class can load images using a static 'function' call
    Utils.setup(this);
    // create a new layout
    layout = new SkinLayout(Utils.getFileFromServer(getCodeBase() + 
"layout1.properties"));
    this.setLayout(layout);
    // construct buttons
    backButton = new ImageButton();
    forwardButton = new ImageButton();
    stopButton = new ImageButton();
    end = new ImageLabel();
    layout1Button = new ImageButton();
    layout2Button = new ImageButton();
    this.add(backButton,"back");
    this.add(forwardButton,"forward");
    this.add(stopButton,"stop");
    this.add(end,"end");
    this.add(layout1Button,"layout1");
    this.add(layout2Button,"layout2");
    // the action listener used for changing layouts
    TestAppletActionListener listener = new TestAppletActionListener(this);
    layout1Button.addActionListener(listener);
    layout2Button.addActionListener(listener);
    this.doLayout();
    this.setVisible(true);
  }
  catch (Exception e) {
    e.printStackTrace();
    this.stop();
  }
}

The paint/update methods use double-buffering to eliminate flicker and paint, our lightweight components:

/**
 * overrides the applet paint method (for double-buffering)
 */
public void paint(Graphics g) {
  update(g);
}
/**
 * overrides the applet update method (for double-buffering).
 * Drawing of lightweight components happens here.
 */
public void update(Graphics g) {
  int x,y;
  Dimension d = getSize();
  //Create the offscreen graphics context, if no good one exists.
  if (gOffScreen == null ||
      d.width != dimOffScreen.width ||
      d.height != dimOffScreen.height) {
    dimOffScreen = d;
    imgOffScreen = createImage(d.width, d.height);
    gOffScreen = imgOffScreen.getGraphics();
  }
  // clear the offscreen graphics context
  gOffScreen.clearRect(0,0,d.width,d.height);
  // paint buttons
  if (backButton.isVisible())
    backButton.paint(gOffScreen);
  if (forwardButton.isVisible())
    forwardButton.paint(gOffScreen);
  if (stopButton.isVisible())
    stopButton.paint(gOffScreen);
  if (end.isVisible())
    end.paint(gOffScreen);
  if (layout1Button.isVisible())
    layout1Button.paint(gOffScreen);
  if (layout2Button.isVisible())
    layout2Button.paint(gOffScreen);
  g.drawImage(imgOffScreen,0,0,this);
  d = null;
}

What's missing?

At the moment, the custom attributes you can set for AWT (Abstract Windowing Toolkit) components, when using SkinLayout, are fairly limited. The layoutContainer() method of SkinLayout requires that a set method in a component have a single string as a parameter. This means that an attribute such as background=#FF00FF (which could be used to set the background color of a component) will not work successfully on an AWT component -- unless we subclass it to provide a setBackground(String color) method.

However, if we altered our tagging structure slightly, the layout manager could attempt to construct an object (such as a color) from the hex color code, and pass that into the set method. We could also extend the SkinLayout to handle specific set cases -- for example, handling setFont() and setBackground() calls.

Yet another alternative would be to use XML to define our user interface, which could provide enormous opportunities for extending the capabilities of the layout manager. Also missing is a packaging structure for the skins. The layout property file could be zipped up, along with the images it needs to implement the interface. The SkinLayout manager would then need to know how to unzip the package to retrieve properties and images.

1 2 Page
Join the discussion
Be the first to comment on this article. Our Commenting Policies
See more