Sunday, June 15, 2008

The Many Roads to Round Buttons (Advanced LWUIT UI Customization)

One of the more important UI elements that we didn't demonstrate as part of LWUIT were irregularly shaped components, currently everything in LWUIT is a square. However, we built everything in place so it doesn't have to look that way to the casual user since components can be translucent its remarkably easy to create a rounded button.
In this post I will present several distinct approaches for creating round buttons and in them also highlight some of the more interesting and less obvious ways in which we can customize LWUIT.

Assuming we want to make all our buttons round the simplest way requires hardly any code... We can just open the resource editor and set the background image/padding to be appropriate values in order to customize the look of buttons considerably. As you can tell from the screenshots of the resource editor this is only a part of the job, we also need to disable the buttons border painting which (at the moment) can only be accomplished in code:
button.setBorderPainted(false);

This approach has some really cool benefits and some interesting drawbacks:
1. Simple - there is very little work to do in order to implement such a button.
2. Animation support - just set an animation to the background of the button with no code changes.

The drawbacks are a bit annoying:
1. Every single button must have its border disabled (wide application change).
2. Limited way to indicate that a button has focus - you can't change the image on focus, there is only one sure fire way of indicating focus using the fgSelectionColor attribute
3. Padding is hardcoded which can be an issue in some extreme cases.
4. Image scaling can look odd in some layouts.


One of the simpler approaches to rounded buttons includes setting the icon to a rounded icon, a button also provides icons for rollover and pressed allowing us to indicate the main 3 states of a button using images. I won't dwell much on this approach since its self explanatory its benefits are:

1. Perfect UI fidelity - icons aren't scaled and will look exactly as you build them.
2. State transition for the button is handled by LWUIT


The drawbacks make this appropriate only for very specific needs:

1. Code changes required globally.
2. Hardcoded text and UI size.


This is a great solution if thats what you need but its hardly global...

The next solution is simple: Derive button

When deriving a button we are essentially free to paint anything we want by overriding paint/paintBorder. This allows us to use several different "tricks" for drawing round or rounded buttons such as just "drawing" the button using the graphics primitives or drawing images as we did before. Once we derive button we can also avoid the "dirty" tricks of using padding to keep out of the edges of the button.

The first approach of drawing using graphics primitives is trivial:
Form test = new Form("Rounded Button");
Button b = new Button("Rounded") {
public void paintBorder(Graphics g) {
g.drawRoundRect(getX(), getY(), getWidth() - 1, getHeight() - 1, 8, 8);
}

public void paintBackground(Graphics g) {
if(getStyle().getBgTransparency() != 0) {
if(hasFocus()) {
g.setColor(getStyle().getBgSelectionColor());
} else {
g.setColor(getStyle().getBgColor());
}
g.fillRoundRect(getX(), getY(), getWidth() - 1, getHeight() - 1, 8, 8);
} else {
super.paintBackground(g);
}
}

public void paint(Graphics g) {
UIManager.getInstance().getLookAndFeel().drawButton(g, this);
}
};
test.addComponent(b);
test.show();
Notice that we need to override painting the background and we essentially disable translucency of buttons, the main reason for that is that LWUIT currently doesn't support translucent round rectangle drawing. If we were to paint a regular rectangle (as the default drawing does) it would "peek" from the edges of the round rectangle.

The second approach for drawing a rounded button is very similar but based on image drawing (2 images to be exact here to your right):
Form test = new Form("Rounded Button");
Button b1 = new RoundButton("Round 1");
Button b2 = new RoundButton("Round 2");
test.addComponent(b1);
test.addComponent(b2);
test.show();

class RoundButton extends Button {
private static final Image UNSELECTED;
private static final Image SELECTED;
static {
Image s = null, u = null;
try {
u = Image.createImage("/RoundButton.png");
s = Image.createImage("/RoundButtonSelected.png");
} catch(IOException err) {
err.printStackTrace();
}
UNSELECTED = u;
SELECTED = s;
}

private Image selected;
private Image unselected;

public RoundButton(String text) {
super(text);
setBorderPainted(false);

}

public void paintBackground(Graphics g) {
if(hasFocus()) {
if(selected == null || selected.getWidth() != getWidth() || selected.getHeight() != getHeight()) {
selected = SELECTED.scaled(getWidth(), getHeight());
}
g.drawImage(selected, getX(), getY());
} else {
if(unselected == null || unselected.getWidth() != getWidth() || unselected.getHeight() != getHeight()) {
unselected = UNSELECTED.scaled(getWidth(), getHeight());
}
g.drawImage(unselected, getX(), getY());
}
}

public void paint(Graphics g) {
UIManager.getInstance().getLookAndFeel().drawButton(g, this);
}
}
Notice that we kept the original selected/unselected images to scale from, this prevents repeated scaling from degrading the quality of the image to a point of being unrecognized...

Both of these approaches have benefits and drawbacks some common to both and some unique for each of them. Common benefits for deriving a button:

1. Single point of extension.
2. Ability to selectively make a individual buttons rounded.
3. Full behavior flexibility and full control over look and drawing.

The drawbacks include:

1. Global code changes to make all buttons rounded.

The primitive drawing option includes the following drawbacks too:

1. No anti-aliasing (rather ugly).
2. No translucency (this is partially fixable but is a big pain to fix).


The image based solution has the following problems:

1. Doesn't scale well.
2. Occupies allot of memory.


Normally I would pick the image based round button but there are more than one ways to accomplish this, one of the cool features of LWUIT is painters which provide the ability to skin arbitrary components. Lets say I want "some" of my buttons to have rounded edges and also "some" of my labels to have them too, if I derive button I would need to derive label too thus creating multiple subclasses and polluting my code. Painters decouple the painting of a components background from the component itself thus allowing us to plugin any drawing code we may desire, normally they are implicitly installed by the theme/look and feel to draw the background image or color. You can create your own painter which is a remarkably powerful tool since it can be installed selectively onto any component to provide your own rendering logic:
class RoundButtonPainter implements Painter {
private static final Image UNSELECTED;
private static final Image SELECTED;
static {
Image s = null, u = null;
try {
u = Image.createImage("/RoundButton.png");
s = Image.createImage("/RoundButtonSelected.png");
} catch(IOException err) {
err.printStackTrace();
}
UNSELECTED = u;
SELECTED = s;
}

private Image selected;
private Image unselected;
private Component cmp;
public RoundButtonPainter(Component cmp) {
this.cmp = cmp;
cmp.getStyle().setBgTransparency(0);
((Button)cmp).setBorderPainted(false);
}

public void paint(Graphics g, Rectangle rect) {
if(cmp.hasFocus()) {
if(selected == null || selected.getWidth() != cmp.getWidth() || selected.getHeight() != cmp.getHeight()) {
selected = SELECTED.scaled(cmp.getWidth(), cmp.getHeight());
}
g.drawImage(selected, cmp.getX(), cmp.getY());
} else {
if(unselected == null || unselected.getWidth() != cmp.getWidth() || unselected.getHeight() != cmp.getHeight()) {
unselected = UNSELECTED.scaled(cmp.getWidth(), cmp.getHeight());
}
g.drawImage(unselected, cmp.getX(), cmp.getY());
}
}
}

Form test = new Form("Rounded Button");
Button b1 = new Button("Round 1");
Button b2 = new Button("Round 2");
b1.getStyle().setBgPainter(new RoundButtonPainter(b1));
b2.getStyle().setBgPainter(new RoundButtonPainter(b2));
test.addComponent(b1);
test.addComponent(b2);
test.show();
You can do the same for labels etc...

What if I wanted the round button to apply to every single button out there without code changes... Sure I could use the bgImage feature mentioned before but then I won't have the flexibility of rendering as I see fit. The solution for this problem is to derive the look and feel in order to override the rendering of all buttons!
UIManager.getInstance().setLookAndFeel(new RoundButtonLookAndFeel());
Resources r1 = Resources.open("/javaTheme.res");
UIManager.getInstance().setThemeProps(r1.getTheme(r1.getThemeResourceNames()[0]));

Form test = new Form("Rounded Button");
Button b1 = new Button("Round 1");
Button b2 = new Button("Round 2");
test.addComponent(b1);
test.addComponent(b2);
test.show();

class RoundButtonLookAndFeel extends DefaultLookAndFeel {
private static final Image UNSELECTED;
private static final Image SELECTED;
static {
Image s = null, u = null;
try {
u = Image.createImage("/RoundButton.png");
s = Image.createImage("/RoundButtonSelected.png");
} catch(IOException err) {
err.printStackTrace();
}
UNSELECTED = u;
SELECTED = s;
}

private Image selected;
private Image unselected;
public void bind(Component cmp) {
if(cmp instanceof Button) {
cmp.getStyle().setBgTransparency(0);
((Button)cmp).setBorderPainted(false);
}
}

public void drawButton(Graphics g, Button cmp) {
if(cmp.hasFocus()) {
if(selected == null || selected.getWidth() != cmp.getWidth() || selected.getHeight() != cmp.getHeight()) {
selected = SELECTED.scaled(cmp.getWidth(), cmp.getHeight());
}
g.drawImage(selected, cmp.getX(), cmp.getY());
} else {
if(unselected == null || unselected.getWidth() != cmp.getWidth() || unselected.getHeight() != cmp.getHeight()) {
unselected = UNSELECTED.scaled(cmp.getWidth(), cmp.getHeight());
}
g.drawImage(unselected, cmp.getX(), cmp.getY());
}
setFG(g, cmp);
super.drawButton(g, cmp);
}
}
Notice how similar this code is to the rounded button code, yet this one applies globally to all buttons using the look and feel. We need to disable some button defaults in the bind method and we then draw the button as we would when deriving the button manually.
The result is identical to the result of the previous example made by deriving the button itself only this result applies globally to every single button we create, the look and feel is limited mostly by your imagination and the cool thing about it is that it is a single PLUGGABLE extension layer that can be replaced on the fly!

Last but not least, we can combine the look and feel with painters to provide even more extensibility and pluggability as such:
class RoundButtonPainterLookAndFeel extends DefaultLookAndFeel {
public void bind(Component cmp) {
if(cmp instanceof Button) {
cmp.getStyle().setBgPainter(new RoundButtonPainter(cmp));
}
}
}
How cool is that!

4 comments:

  1. hai,
    a very nice work.i have query.if i want my image to be button and on selection my image should zoom a little as in sonyericsson. as in second example instead of
    roundedbutton(String text)
    {
    super(text)
    }
    here my button size is restricted because of the text size
    if i want my button size exactly same size as that of image

    what should i change in the code given above

    ReplyDelete
  2. pardon, but I really thought this must be a joke...what good is all this, if you have no anti-aliasing?

    ReplyDelete
  3. When created on platforms that support anti-aliasing such as CDC, Android etc. it works seamlessly.
    The image buttons can be anti-aliased in advance, my hastily created gimp graphics shouldn't be used as an example.

    ReplyDelete
  4. I don't see the problem? setEnabled(false)... do whatever in a new thread when done call setEnabled(true);

    ReplyDelete