The Source for Java Technology Collaboration
User: Password:



Kirill Grouchnikov's Blog

Swing Archives


Animating layouts part VI - welcome to the real world

Posted by kirillcool on October 30, 2006 at 11:02 PM | Permalink | Comments (0)

This is the sixth part in a series about automatically animated layouts in Swing applications.

  • The first part introduced the TransitionLayout. This part showed animated versions of BorderLayout and FlowLayout.
  • The second part showed the TransitionLayout applied to a (at least partially) real-world image viewer that allows live thumbnail resizing.
  • The third part described the current implementation and alternatives that were considered.
  • The fourth part showed a simple demo of an application that TransitionLayout installed on different containers.
  • The fifth part introduced overlapping / layered transitions over the same component.

Once i started to test the TransitionLayout on the Substance test application (and not on a simple app shown in the fourth part of this series), i saw all sorts of visual inconsistencies. The most glaring of these were on borders and on desktop panes.

As you may already know, the borders are not really a part of the look-and-feel domain. Sure, the look-and-feel can install the borders on the component hierarchy, but only if the currently set border is not a UIResource. Hence, if you have installed a TitledBorder, a LineBorder or some custom border, it will remain installed and respected by all core and most third-party LAFs. When the border is painted? The answer is in a quite convoluted JComponent.paint(Graphics) method. It is done after the component is painted (by the relevant LAF delegate) and before the component's children are painted. What does it mean? That the neither TransitionLayout nor the look-and-feel can change the translucency of the border painting without replacing the border itself.

The answer, therefore is quite simple. After a TransitionLayout is installed, it is called every time that the layout should be performed. When the layoutContainer is called, we scan the entire component hierarchy and replace all the borders with the TransitionBorder implementation which simply delegates all the logic to the original border. The only thing it adds is to set the opacity on the Graphics in the paintBorder implementation. When the TransitionLayout is unset, it restores the original borders. Special care needs to be taken in order to preserve the UIResource-ness of the installed border - see the code (scroll down to the installBorders method.

Is it an optimal solution? Not really, since it may interfere with the application code. The application code may have some property change listeners on the components, and it may also have some hard-coded assumptions about the installed borders (like changing the text of the TitledBorder or the color of the LineBorder). If you have better alternatives - add them in the comment section.

Another issue that i ran into was with the JDesktopPane. This drove me crazy for a few good hours of debugging and perusing the code. The problem was this - whenever a transition (fade-in / fade-out) involved a panel with JDesktopPane, the background of that pane was opaque. After losing some hair (well, that would've happened anyway), i found two very interesting pieces of code deep down in Swing

The first is the JDesktopPane.isOpaque() which always returns true (huh?). The second is in the JLayeredPane.paint(Graphics) which paints the entire component in the currently set background color / light gray if it's null when the component is opaque. Since JDesktopPane extends the JLayeredPane, the translucency settings set in the look-and-feel delegate are simply ignored. As far as i can tell, this is the only core Swing component that has painting logic outside of the LAF delegates. It is quite strange, but i guess that the TransitionLayout is pushing Swing into directions not thought of ten years ago :)

Thankfully, the implementation of the JLayeredPane.paint(Graphics) left a loophole that i have been able to exploit. If a completely transparent color is set as a background, the background of JDesktopPane is not filled. All that was left to do in the desktop pane delegate was to fill the background based on the current transition opacity and then paint the component itself.

Is it an optimal solution? Almost. The only thing left to do in the next version is to respect the original background color set on the desktop pane in the painting and restore it when the LAF is unset. Will it provide a complete solution? No - since the getBackground method will return an incorrect (completely transparent) value.

There were some other less interesting issues encountered and fixed along the way. As always, i'm thoroughly impressed by what Swing allows us to do and as always, i learned something new about it. The code is the release candidate stage as of today and will be officially released on November 13 (unless some real big problems are found).



Animating your lists, tables and trees - part II

Posted by kirillcool on October 20, 2006 at 10:31 PM | Permalink | Comments (5)

The first part of this series introduced animation fades on lists, trees and tables in Swing applications running under the latest development version of Substance look-and-feel. The release candidate for version 3.1 (code-named Honolulu) is on October 30, and there is still room to add minor user-requested features. The users who download and try the development drops inevitably face defects, but they also have a great opportunity to comment and affect new features. One of the users (thanks, Raj) requested that the table rollover animation should highlight the entire row.

Now, this is not as straightforward as it sounds, since the JTable API provides two methods, setRowSelectionAllowed and setColumnSelectionAllowed. When both are set to either true or false, clicking a cell selects that cell. When the first is set to true and the second to false, clicking on a cell selects the entire row. When the first is set to false and the second to true, clicking on a cell selects the entire column.

The latest version of 3.1dev drop features the rollover animation effects that follow the same algorithm. Click on a play button below to view a short screencast that illustrates this feature.



Improving the user experience with scroll bars

Posted by kirillcool on September 14, 2006 at 12:12 PM | Permalink | Comments (11)

One of the more interesting UI rules is the Fitts' law that estimates the time that it takes to complete a movement to a specific UI target. One of the consequences of this law is that if you have a small target located far away, it will take a long time to target it. One option is to make the target bigger, and another option is to make the target closer.

One of the examples of more "problematic" UI target are the scrollbar buttons. They are usually quite small (12-16 pixels) and are situated on the opposite sides of the scrollbar. We can pretty much rule out the option to make them bigger, so we're left with making them closer. Apple's scrollbars "fix" address this issue by putting the scroll buttons together (see the screenshots at this HIG topic.

Now you can get the same and more in the latest drop of Substance 3.1. The SubstanceLookAndFeel.SCROLL_PANE_BUTTONS_POLICY client property specifies the location for scrollbar buttons. The value should be one of the SubstanceConstants.ScrollPaneButtonPolicyKind enum. Available values:

  • NONE - no buttons are shown.
  • OPPOSITE - the default policy. The decrease button is on one side of the scroll bar, the increase button is on another side of the scroll bar.
  • ADJACENT - the decrease button is right next to the increase button of the scroll bar.
  • MULTIPLE - the combination of two. There are two decrease buttons, one on each side of the scroll bar (one of them is right next to the increase button).
  • MULTIPLE_BOTH - Two pairs of decrease / increase buttons on each side of the scroll bar.

The following screenshot shows horizontal scrollbar under different policies and orientations. The first scrollbar is under the default OPPOSITE policy. The second scrollbar is under the ADJACENT policy in LTR application. The third scrollbar is under the MULTIPLE policy in LTR application. The fourth scrollbar is under the ADJACENT policy in RTL application. The fifth scrollbar is under the MULTIPLE policy in RTL application. The sixth scrollbar is under the NONE policy. The seventh scrollbar is under the MULTIPLE_BOTH policy.

The following screenshot shows vertical scrollbar under different policies. The first scrollbar is under the default OPPOSITE policy. The second scrollbar is under the ADJACENT policy. The third scrollbar is under the MULTIPLE policy. The fourth scrollbar is under the NONE policy. The fifth scrollbar is under the MULTIPLE_BOTH policy.

Thanks for the comments and suggesting the NONE and MULTIPLE_BOTH policies. Thanks to Romain for his suggestion to make the default gradient painter less dark.



How color-blind people see your UIs

Posted by kirillcool on September 12, 2006 at 08:48 PM | Permalink | Comments (9)

The color blindness is the inability to perceive differences between some or all colors that other people can distinguish. According to the medical studies, eight to ten percent of male population suffers from some kind of color blindness (figure for female population is much lower). What does it mean to the average Swing developer? Well, if you rely too much on color differences, you may be not conveying the information as well as you thought. For example, red-green blindness (protanopia) may cause the user to miss validation information conveyed by the text component background (light pink for invalid value as opposed to light green for validated field).

One of the previous entries on skinning the Swing UIs using the new complex themes in Substance look and feel showed an easy way to bring some colors to your UI. Now you can run your application in debug UI mode (turned on by the -Dsubstancelag.debugUiMode VM flag) and have a live preview of your UI as viewed by the color-blind population.

Here is a screenshot of a sample UI running under Mango skin:

Once your application is configured with the above VM flag, clicking on the title pane brings the debug UI menu:

Select the Color blindness menu and then one of the color blindness kinds. This is how the same UI will look to protanopes (reduced shades of red, see how the pink turns into saturated blue):

This is how the same UI will look to deuteranopes (reduced shades of green, see how the greenish background turns into almost gray):

This is how the same UI will look to tritanopes (reduced shades of blue, see how the yellow turns into light pink):

Note that currently only the theme itself is changed. This means that if you use custom colors on borders, renderers, backgrounds etc, they will be preserved. The next version most probably will use glass pane in order to convert the entire UI into color-blind version (the UI itself will not be interactive).

The implementation is based on the algorithm described in PDF article



Precise micro-design in modern look and feels

Posted by kirillcool on September 08, 2006 at 10:41 PM | Permalink | Comments (3)

JGoodies Looks was and undoubtedly remains the pioneer and the leader in the precise micro-design of Swing forms. It's easy to see but hard to achieve, and this comes at cost of elaborate manipulations with insets, borders, preferred sizes and custom layout managers. This entry by Joshua shows the same improvements applied to the Windows LAF in Mustang (at least for the text components, combo boxes and spinners).

Let's see a few screenshots from the core LAFs in Tiger and Mustang. First, we have Metal / Ocean under Tiger 5.0_08 with the combo box much higher than other controls and the bottom spinner button sticking out on the bottom side:

Now, we have Metal / Ocean under Mustang b96 with the same visual problems + grown password field (described here):

Next stop is Windows LAF. Under Tiger in Windows 2000 it looks even more inconsistent. Text field and spinner are 20 pixels high, combo and formatted text field are 22 pixels high, password field is 24 pixels high:

Under Mustang in Windows 2000 we have less inconsistency. Text field, formatted text field and password field are 20 pixels high, combo is 22 pixels high, spinner is 18 pixels high:

Under Tiger in Windows XP we have a total new set of incosistencies. Text field is 19 pixels high, formatted text field and combo are 22 pixels high, password field is 16 pixels high and spinner is 20 pixels high:

Under Mustang in Windows XP we have perfect consistency (everything is 20 pixels high) but somehow the combo text has been truncated:

Only recently one of the Substance users (thanks Raj) pointed my attention to the inconsistency in all the available Substance releases. The text fields and the spinner were 18 pixels high and the combo was 24 pixels high:

This has been fixed in the latest drop of the next version (3.1 code-named Honolulu), so that all the controls are 20 pixels high:

If you are highly concerned about precise micro-design, i highly recommend using the JGoodies look and feel and layout. Thanks to Karsten for his continuing efforts in this area (the rest of us are trying to catch up). By the way, you are welcome to read this entry on my blog that dates back more than a year ago. Frankly, i have absolutely no recollection of having written that (almost), and i start to think that i might have been wrong back then :)



The default look and feel breaks visual compatibility in Mustang

Posted by kirillcool on September 08, 2006 at 04:28 PM | Permalink | Comments (6)

One of the requirements imposed on the default Ocean theme was to Not Break Stuff. So now adjustments were made to the l&f which would have broken existing applications; the sizing of widgets is the same and everything should just work out of the box; it's just the skin of that l&f that's different (and, I think you'll agree, better) (quoting Chet's original post).

Here are the screenshots of the same exact application under Tiger 5.0_08 and Mustang 6.0_b98:

Digging in the code of the MetalLookAndFeel locates this suspicious entry:


   "PasswordField.echoChar", (char)0x2022,

which appears to be the result of fixing this bug. So, somebody suggested making this change for Metal as well, and now the default echo char is 2 pixels wider...

Don't be alarmed if one of your forms will become a little garbled :)



Collaboration between two look-and-feel projects

Posted by kirillcool on August 30, 2005 at 01:43 PM | Permalink | Comments (6)

Werner Randelshofer is the developer of very popular Quaqua look and feel. As said on the project main page, Quaqua is focused on fixing minor bugs and glitches in Apple's implementation of the AHIG as well as providing enhancements that make your application fit nicely into Mac OS X. Unfortunately, this excellent look and feel is available for Macintosh only, and until the last release, there wasn't a lot of code which could have been shared with other platforms.

However, the new release 3.3 features an excellent implementation of color chooser dialog UI delegate (see left screenshot below), the first sight of which immediately made me want to have such delegate in my own Substance look and feel (which takes many of its ideas from Mac 10.4 already). Two choices presented themselves immediately: reinvent the wheel or ask to share the code. Undoubtedly, the first choice would be the choice of many open-source developers - a nice-looking wheel waiting to be reinvented. This would mean, of course, an inevitable peek at the source code, and a well-known dilemma, to copy as is or to pretend that you are writing something completely new while you are looking at somebody else's code all along. The second way involves a simple question ("Would you be kind enough to allow sharing some of your code as long as the licenses match and the copyright / credits are in place?"), a short wait, and a sigh of relief when the positive answer comes through.

The sigh of relief stems from many reasons.
  • The code already exists, and fits my needs nicely (otherwise why would I want to use it).
  • Somebody else wrote it and actively maintains it, so any bugs will be fixed (hopefully) by that person :), even double smiley :-)
  • New features / enhancements / versions will be incorporated as is (giving added value to your code without a lot of investment on your part)
  • Last but not the least - this is the essence of open source. Share, share, share. And reuse.
The whole process took about two hours, of which
  • Five minutes were spent on extracting the relevant package for color chooser components, additional helper classes and setting five properties in the UIManager
  • An hour on creating a crayon palette (bottom-right on the right image), as the original palette is most probably copyrighted by Apple
  • An hour on creating listeners for custom sliders of Quaqua color chooser panels for rollover effects (to be consistent with the rest of Substance look and feel.
And finally, the results (Quaqua on Macintosh on the left, Substance on Windows XP on the right):


You can try the Web Start version of Substance look and feel test application (go to Dialogs tab and click Color button). Note that you will need Tiger+ for running this application
Technorati Profile

Showing licenses for your application

Posted by kirillcool on August 13, 2005 at 08:46 AM | Permalink | Comments (11)

Most of the nowadays open-source and commercial products extensively use other products. Although the "not invented here" syndrome is infective, things such as look-and-feel, layout managers, graph libraries, reporting tools etc. are taken from well-known and actively maintained third party sites. These tools come in variety of licenses, most of which require you to include the corresponding licenses along with the executable version of your own library. Here is a graphical way to organize all licenses in one user-friendly window.

Here is a screenshot of license viewer for JAXB Workshop that uses ten other libraries (click screenshot to view full-scale version):

The left panel shows tree with all third-party libraries (the root is your own library). Libraries can be grouped (in screenshot above, JUNG library uses three other libraries, which are grouped under it). Each tree entry shows the library name and the license abbreviation. Clicking on a tree entry shows the corresponding license in the right text pane, along with the library name, library URL and license full name above the license.

The source for the license viewer can be found in the CVS repository at this link. Sample code for using the license viewer is:
    
LicenseDialog licenseDialog = new LicenseDialog(
  ownerFrame,      // your application frame
  "JAXB Workshop", // library name
  new LicenseDialog.LibraryDetails(
    "https://jaxb-workshop.dev.java.net/", // library URL
    LicenseDialog.LicenseType.BSD,         // license enum
    "license/bsd_license.txt"));           // resource name for license text
The license viewer comes with about ten pre-defined licenses (such as Apache, BSD, LGPL etc.), which can be referenced using enum value as above. For licenses that are not pre-defined:
     
licenseDialog.addNewRootLibrary(
  "SJSXP",  // library name
  new LicenseDialog.LibraryDetails(
    "https://sjsxp.dev.java.net/",                   // library URL
    "Sun BCL",                                       // license abbreviation
    "Sun Binary Code License",                       // license full name 
    LicenseDialog.LicenseType.getLicenseIcon("10"),  // icon for license entry in the tree
    "license/sun_bcl_license.txt"));                 // resource name for license text
The license viewer provides a helper function for creating standard-looking icon (getLicenseIcon used above). For defining library groups:
     
licenseDialog.addNewChildLibrary(
    "JUNG",                   // parent library name
    "Commons Collections",    // library name
    new LicenseDialog.LibraryDetails(
      "http://jakarta.apache.org/commons/collections/",   // library URL
      LicenseDialog.LicenseType.APACHE2,                  // license enum
      "license/apache2_license.txt"));                    // resource name for license text
As can be seen, you are responsible for bundling the license text itself as a resource with your application.
     
licenseDialog.expandAll(true);
licenseDialog.setLicenseTextAreaFont(
   new Font("Tahoma", Font.PLAIN, 12));
These two functions expand all branches in the license tree and set font for the license text pane. Finally, set preferred size and location for the license viewer and show it:
     
Dimension dim = new Dimension(800, 600);
licenseDialog.setSize(dim);
licenseDialog.setPreferredSize(dim);
licenseDialog.setResizable(false);
licenseDialog.pack();
licenseDialog.setLocation(...);
licenseDialog.setVisible(true);
You can see the license viewer in action by WebStarting this link (requires JVM 5.0+) and going to Help -> View licenses.

The next logical step would be to create a new project on java.net that will provide such viewer along with bundled versions of most-popular licenses. Volunteers (a slight chance to win immortal glory is implicitly guaranteed)?

How to create scalable icons with Java2D

Posted by kirillcool on July 23, 2005 at 12:54 AM | Permalink | Comments (11)

In one of my previous entries i've shown how to use Java2D to create layered icons for your application. Unfortunately, most of the time we think about icons in pixel-precision format, instead of thinking of them as vector graphics. Let's see an example first:
icons.png

The icons are shown starting from 10*10 to 36*36 size. As you can see, the icon components are nicely scaled (including inner graphics width, highlight in the top-left corner and so on). What's more important, each row is created by the same function that gets an icon size as a single parameter. Let's see the code of the function that created the first two rows:

First, let's define a function that will return us an Icon:
     
   public static Icon getSuccessMarkerIcon(int dimension) {
      return new ImageIcon(getSuccessMarker(dimension));
   }
Now, we define a function that creates a BufferedImage:
     
   public static BufferedImage getSuccessMarker(int dimension) {
First, we create a new image and set it to anti-aliased mode:
     
      // new RGB image with transparency channel
      BufferedImage image = new BufferedImage(dimension, dimension,
            BufferedImage.TYPE_INT_ARGB);

      // create new graphics and set anti-aliasing hint
      Graphics2D graphics = (Graphics2D) image.getGraphics().create();
      graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
            RenderingHints.VALUE_ANTIALIAS_ON);

Fill the background:
     
      // green background fill
      graphics.setColor(new Color(0, 196, 0));
      graphics.fillOval(0, 0, dimension - 1, dimension - 1);
Create a white spot in the top-left corner (to simulate 3D shining effect) - note that we set clipping area to the icon circle (so that all the rest will remain transparent when our icon will be shown on non-white background):
     
      // create spot in the upper-left corner using temporary graphics
      // with clip set to the icon outline
      GradientPaint spot = new GradientPaint(0, 0, new Color(255, 255, 255,
            200), dimension, dimension, new Color(255, 255, 255, 0));
      Graphics2D tempGraphics = (Graphics2D) graphics.create();
      tempGraphics.setPaint(spot);
      tempGraphics.setClip(new Ellipse2D.Double(0, 0, dimension - 1,
            dimension - 1));
      tempGraphics.fillRect(0, 0, dimension, dimension);
      tempGraphics.dispose();
Draw the outline (must be done after the white gradient so the outline is not affected by it):
     
      // draw outline of the icon
      graphics.setColor(new Color(0, 0, 0, 128));
      graphics.drawOval(0, 0, dimension - 1, dimension - 1);
Compute the stroke width for the V sign. This sign is created using the same path with different strokes, one for the outer rim (wider), and one for the inner filling (narrower).
     
      // draw the V sign
      float dimOuter = (float) (0.5f * Math.pow(dimension, 0.75));
      float dimInner = (float) (0.28f * Math.pow(dimension, 0.75));
Create a GeneralPath for the V sign
     
      // create the path itself
      GeneralPath gp = new GeneralPath();
      gp.moveTo(0.25f * dimension, 0.45f * dimension);
      gp.lineTo(0.45f * dimension, 0.65f * dimension);
      gp.lineTo(0.85f * dimension, 0.12f * dimension);
Draw the path twice
     
      // draw blackish outline
      graphics.setStroke(new BasicStroke(dimOuter, BasicStroke.CAP_ROUND,
            BasicStroke.JOIN_ROUND));
      graphics.setColor(new Color(0, 0, 0, 196));
      graphics.draw(gp);
      // draw white inside
      graphics.setStroke(new BasicStroke(dimInner, BasicStroke.CAP_ROUND,
            BasicStroke.JOIN_ROUND));
      graphics.setColor(Color.white);
      graphics.draw(gp);
Dispose of the temp graphics and return the image
     
      // dispose
      graphics.dispose();
      return image;
   }
For slightly curved mark (the second row), use the following path:
     
      GeneralPath gp = new GeneralPath();
      gp.moveTo(0.25f * dimension, 0.45f * dimension);
      gp.quadTo(0.35f * dimension, 0.52f * dimension, 0.45f * dimension,
            0.65f * dimension);
      gp.quadTo(0.65f * dimension, 0.3f * dimension, 0.85f * dimension,
            0.12f * dimension);
The code for the other rows is very similar. One trick for triangles is to create the perimeter path that specifies rounded corners (using quadTo as above).

Using this technique, you no longer have to bundle icons of different sizes in your application. In the example below, there are three versions of the same error icon, one 9*9 (overlayed on top of another icon in the tree cell renderer), another 11*11 (in the left gutter panel) and the last 13*13 (in the message table panel). All of them were created on the fly without the need to worry if you have the corresponding icon bundled with your application.
milano.png

Porting small library from Java 5.0 to Java 1.4 - could it be any harder?

Posted by kirillcool on July 15, 2005 at 11:45 AM | Permalink | Comments (4)

And so, last evening I set out to provide 1.4-compatible version of my Substance look-and-feel library. What promised to be an easy task (a couple of changes per file, given not too excessive use of generics), turned out to be a frustrating and valuable experience not only with Swing classes, but with Java 5.0 features in general.

First off, I should say that at work we write for JDK 1.4.2. For me, last year that amounted to roughly 2600 hours spent on writing 1.4.2-compliant code. At home (for a few projects here on java.net) it's been 5.0 from the beginning. Last year that amounted to roughly 700 hours spent on using 5.0 features. Not claiming to know my way through all new features in 5.0 (there were way too many hands raised on Joshua Bloch's "Collections Connection" when he asked to see who knows everything in 5.0), I feel comfortable with all new language features. Contrary to what some claim (he looks suspiciously familiar), generics in the "light" version (no wildcards, no "super" or "extends" etc.) make the code look better and more robust. As a collection guru, you may think that nobody can forget that exact value type stored in some map, but what about your average programmer who left last week and whose bugs you need to fix? Your best hope would be that he documented the correct types of keys and values in javadoc, otherwise you would either hunt all the puts, or debug until your fingers are blue and ready to fall off.

And so, i thought. 77 files, 440KB of code. How hard can it be? It shouldn't even matter how many files or how many lines of code. Just take Retroweaver, add ant task and lean back. The "lean back" part ended abruptly, seeing that i have used quite a handful of methods that were new in 5.0 (which i used). Here lies the main deception of Retroweaver - its abilities are very limited. It can handle generics, enhanced for loops, enums, iterables, but that's about it. A class that's new in 5.0, or a new method on existing class - you're on your own. And that certainly discourages the use of Retroweaver - I moved to 5.0 not only because of the language features. I want to use new stuff like Formatter, Scanner, concurrency, ExecutorService and even String.replace that gets two CharSequences as parameters. And so, the first big disappointment was - if you want to port your library back to 1.4, you can't use any 5.0 new stuff. That sounds obvious, but not so if you start writing a new library straight in 5.0.

The next step was to see the functions that were not present in 1.4 and replace them with the corresponding counterparts from 1.4. That sounds easy, but when you extend functionality of the existing Java classes, it's not. For look-and-feel, you extend the existing UI delegates and plug-in your stuff. The stuff you plug should involve only drawing, so that couldn't change much between different JDK versions, right? Right and wrong. It doesn't change much, but a lot of stuff gets exposed in later JDK versions that was private in the previous ones. One of the best examples here would be createScrollButton in TabbedPaneUI. When you set your tabbed pane to work in SCROLL_TAB_LAYOUT mode, the tabs that overflow the available space are not visible, and can be "scrolled to" using two small arrow buttons. The default implementation draws the button on its own, ignoring the installed ButtonUI, so you would want to override this behaviour to provide consistent look and feel to all the buttons. In 5.0 it's very easy - you are override createScrollButton() function, it's called and then Swing plugs in all the listeners on those buttons. In 1.4 - big surprise. Pretty much everything in BaseTabbedPaneUI is private, the buttons, the listener, the layout. And everything is not only wired, but also hardcoded for the class names. And you can't make your button inherit from the inner button - its class is private. What you can do - the good old "copy paste". That's what is done in JGoodies - its TabbedPaneUI is a complete rip-off (with a few tweaks to plug its own buttons), spanning 3092 lines. My original 5.0 version of TabbedPaneUI is 235 lines. And that brought the second disappointment - there are very good reasons to use new functions. That sounds obvious too, but yet again if you start out with new functionality and then force yourself to go back, you'll have to implement it yourself.

At this point, the next step was obvious - the code must be rewritten. But should it still be in 5.0? That's a tough question. You can integrate Retroweaver in your ant script and run it everytime you change something. You can pay close attention to javadoc comments of every single function that you intend to use. You can set your compiler to 1.4 settings, but it will not catch use of new 5.0 functions. Or, you can just take your code and backport it to 1.4 yourself. After all, you are not using any new stuff (apart from generics and enums), and you are not burdened with all the bookkeeping.

A few clicks in Eclipse, and the project was configured to 1.4. Thirty seconds later, the build is over, and on 77 files I had 856 errors. About 30% were on generics (both parametrized collections and missing casts on retrieving objects), 30% were on @Override (you use that a lot writing look-and-feel under 5.0), and 35% were on enums. The most unexpected part was for enums - you just get used to having name(), values() and using them in switch statements. The harsh 1.4 reality sets in, and even when you use Bloch's enum pattern, you still need to provide name(), values() and rewrite your switch statements. Couple of hours later - all the errors were gone (except the functions that were not available in 1.4), and the code was uglier than before, especially when you need to store Longs in a Map (autoboxing is nice), or iterating over entries in Set or array. The third disappointment set in during the two hours frantic coding - removing 5.0 language features takes more that it seems. Less obvious (maybe because it's sold as "syntactic change" only), but time-consuming nonetheless.

When the last errors were fixed, I run the test application that tests all component UIs. Instantly i was greated with the long despised "use getRootPane().add()" message. Quick fix, the application runs, but:
  • background of menu and menu items is dark gray instead of nice light gray. The same for combobox.
  • default button behaviour is transferred to the clicked button.
  • the buttons are not "rollover-enabled"
  • dialog root pane is not opaque
  • selected items in list are not highlighted
  • sliders have background for ticks
  • progress bar is off by 1 pixel in both width and height
  • root pane header is off by 1 pixel in width
All these were either fixed or corrected for 5.0. Now i have to introduce "bug fixes" myself. That's when the last disappointment sets in - each new JDK version fixes existing bugs in older versions. Obvious as well, but quite unexpected when all you want is to run your library in older JDK and just want it to show the same behaviour.

With all of the above, twenty four hours later, the library has not been ported back, but a few lessons have been learned.

How to create pulsating buttons in your Look and Feel

Posted by kirillcool on May 06, 2005 at 04:44 AM | Permalink | Comments (0)

In one of the more popular commercial look and feels, Alloy, the default focused button has nice animation effect - its inner border fades in and out in cycles. In Macinstosh OSX, the default button has pulsating effect to visually indicate that the corresponding action will be taken when the user hits the "Enter" key. The underlying code is not very complicated, although there are few spots for potential resource leaks.

The code below is a part of Substance Look and Feel (currently under development). You can run a light-weight Web Start application to see the pulsating effect. In the dialog, click "Open new dialog" button to open a new dialog. Click "Close all dialogs" to close all dialogs that you have opened. Here a few screenshots that show the pulsating effect:
0.png 1.png 2.png 3.png 4.png 5.png
Animated version (GIF with colors lost due to compression):
anim.gif
Note how the default button changes its lightness and still retains the 3D quality (with the soft curved lighting in its upper half).

First, we need to decide what button should be pulsating. Every top component (that has a root pane) can have a default button. In our case, we track all default buttons that were not garbage-collected, but animate only the default button in the focused window. This way, user's attention will not be distracted from the top-level frame or dialog.

The code below uses weak references to track all default buttons of the application (via UI delegate). Once a default button is garbage-collected, the data structures are updated automatically (we use WeakHashMap). Each button has an associated Timer. The corresponding ActionListener checks whether the associated button lies inside a focused top level container. If it's inside, the cycle count for this button is incremented, otherwise it's reverted back to 0. Finally, the repaint() function of the associated button is called. This call eventually leads to update function in the UI delegate. This delegate asks the tracker for the cycle count, and paints the button accordingly.

We have three classes, button UI, painting delegate and tracker:
public class SubstanceButtonUI extends MetalButtonUI
public class SubstanceButtonDelegate
public class PulseTracker implements ActionListener
The painting delegate captures common functionality of regular button UI and toggle button UI and provides the graphics functions. In button UI we have the following delegate instantiation:
private SubstanceButtonDelegate delegate;
public SubstanceButtonUI() {
  this.delegate = new SubstanceButtonDelegate(true);
}
and the painting code is
@Override
public void update(Graphics g, JComponent c) {
  AbstractButton button = (AbstractButton) c;
  long cycle = 0;
  boolean isAnimating = false;
  if (button instanceof JButton) {
    JButton jb = (JButton) button;
    if (jb.isDefaultButton()) {
      PulseTracker.update(jb);
    }
    cycle = PulseTracker.getCycles(jb);
    isAnimating = PulseTracker.isAnimating(jb);
  }
  this.delegate.update(g, button, cycle, isAnimating);
}
For a button that is JButton, we check whether it is a default button, and if so, we ask the tracker to update its status. After that, we fetch the cycle count and the animation status from the tracker, and ask the delegate to paint the button based on the cycle count and animation status. The code of the delegate is not relevant for this thread and can be seen in the CVS repository. Let's see the details of the tracker. All the public functions of the tracker are static, and the most important one (update) is synchronized.

Each tracker instance tracks a single not-GC'd default button. The tracker itself is an ActionListener, with associated Timer object. In addition, there are two static hash maps with weakly-referenced keys (each key is a JButton):
   /**
    * Map (with weakly-referenced keys) of all trackers. For each default
    * button which has not been claimed by GC, we have a tracker (with
    * associated Timer).
    */
   private static WeakHashMap<JButton, PulseTracker> trackers = new WeakHashMap<JButton, PulseTracker>();

   /**
    * Map (with weakly-referenced keys) of cycle counts. For each default
    * button which is shown and is in window that owns focus,
    * this map contains the cycle count (for animation
    * purposes). On each event of the associated Timer (see
    * {@link #actionPerformed(ActionEvent)}), the counter is incremented by 1.
    * For buttons that are in windows that lose focus, the counter is reverted
    * back to 0 (animation stops).
    */
   private static WeakHashMap<JButton, Long> cycles = new WeakHashMap<JButton, Long>();

   /**
    * Waek reference to the associated button.
    */
   private WeakReference<JButton> buttonRef;

   /**
    * The associated timer.
    */
   private Timer timer;

The tracker constructor is private and not-synchronized (as it is called from a synchronized function):
   private PulseTracker(JButton jbutton) {
      // Create weak reference.
      buttonRef = new WeakReference<JButton>(jbutton);
      // Create coalesced timer.
      timer = new Timer(50, this);
      timer.setCoalesce(true);
      // Store event handler and initial cycle count.
      PulseTracker.trackers.put(jbutton, this);
      PulseTracker.cycles.put(jbutton, (long) 0);
   }
It adds the button to the tracker map and to the cycle map (with initial value equal to 0). Note that the action listener of the Timer is the tracker itself. Here is the action event function:
   public void actionPerformed(ActionEvent event) {
      // get the button and check if it wasn't GC'd
      JButton jButton = buttonRef.get();
      if (jButton == null)
         return;
        if (!jButton.isDefaultButton()) {
            // has since lost its default status
            PulseTracker tracker = trackers.get(jButton);
            tracker.stopTimer();
            tracker.buttonRef.clear();
            trackers.remove(jButton);
            cycles.remove(jButton);
        }
        else {
            if (!PulseTracker.hasFocus(jButton.getTopLevelAncestor())) {
                // no focus in button window - will restore original (not
                // animated) painting
                PulseTracker.update(jButton);
            } else {
                // check if it's enabled
                if (jButton.isEnabled()) {
                    // increment cycle count for default focused buttons.
                    long oldCycle = cycles.get(jButton);
                    cycles.put(jButton, oldCycle + 1);
                }
                else {
                    // revert to 0 if it's not enabled
                    if (cycles.get(jButton) != 0)
                        cycles.put(jButton, (long) 0);
                }
            }
        }
      jButton.repaint();
   }
Note, that if the button was GC'd, there is no need to explicitly update the hash maps. This function checks whether the associated button is in a window that is a focus owner. If yes, the cycle count is incremented, otherwise the update function is called. Finally, we call repaint function on the button, which will eventually lead to our button UI (that will fetch the cycle count) and the painting delegate (that will use the cycle count to create the matching background). Two special cases: deafult disabled button is not animated, and we check that the tracked button is still default. Tracked button may remain visible in focused window, but due to some action listener lose its "defaultness". In this case the tracker is stopped and hash maps are updated accordingly. Here is the most important function in the tracker:
   public static synchronized void update(JButton jButton) {
      boolean hasFocus = PulseTracker.hasFocus(jButton.getTopLevelAncestor());
      PulseTracker tracker = trackers.get(jButton);
      if (!hasFocus) {
         // remove
         if (tracker == null)
            return;
         if (cycles.get(jButton) == 0)
            return;
         cycles.put(jButton, (long) 0);
         // System.out.println("r::" + trackers.size());
      } else {
         // add
         if (tracker != null) {
            tracker.startTimer();
            return;
         }
         tracker = new PulseTracker(jButton);
         tracker.startTimer();
         trackers.put(jButton, tracker);
         cycles.put(jButton, (long) 0);
         // System.out.println("a::" + trackers.size());
      }
   }
It has two code flows - one for the default buttons that are in focused windows, another for default buttons that are in non-focused windows. If the window does not own focus, the cycle count is reverted to 0 (if wasn't already so). If the window owns focus, we fetch its tracker. If the the tracker hash map already contains the tracker, we start it (startTimer is a helper function that starts timer if it wasn't started already) and return. On the next iteration of the Timer, we will get back to the actionPerformed function, and, if the button is still in focused window, it will be painted according to its new incremented cycle count. If the tracker hash map doesn't contain the tracker, we create a new tracker, start it, put it in the hash map and put the initial cycle count value 0 to the cycle hash map. Note that this function is synchronized to prevent multiple trackers from interfering with one another.

Two additional helper functions that are used in the button UI update functions are:
   public static long getCycles(JButton jButton) {
      Long cycleCount = cycles.get(jButton);
      if (cycleCount == null)
         return 0;
      return cycleCount.longValue();
   }

   public static boolean isAnimating(JButton jButton) {
      PulseTracker tracker = trackers.get(jButton);
      if (tracker == null)
         return false;
      return tracker.isRunning();
   }
These functions check the corresponding hash maps and retrieve the values.

In the Web Start demo application, when you click on "Open new dialog" button, a new JDialog is opened with a default button. You will see that the default button in the main window stops pulsating. Clicking "Close all dialogs" will close all opened dialogs and make sure that the default buttons are garbage-collected (using WeakReference and ReferenceQueue). You can uncomment lines in the tracker's update function to see that the WeakHashMaps are updated automatically.

How to create custom popup menus

Posted by kirillcool on March 12, 2005 at 08:55 AM | Permalink | Comments (10)

The popup menus in Swing application are rather standard. Put a few JMenuItems (most probably with icons), spice them up with an assorted selection of JSeparators, and your plain boring popup is ready for the outside world to see.

The immediate solution to creating custom menu items is, of course, using some lively look'n'feel scheme. However, you don't have to go that far to bring your popup menu alive. There are two things you can do - use HTML text and create custom background image. The following example illustrates both techniques.

First, here is a sample popup menu:
menu.jpg

The zoomed-in version shows some interesting details on the underlying visual artifacts:
menuBig.jpg
  • The text of the header entry is painted twice (black shadow makes the white text stand out).
  • The text of regular entries is HTML and anti-aliased.
  • The gradient on the header entry and the overlaid squares will be discussed below.
The first and the simpler thing to do - make menu items with HTML text. Simply provide valid HTML construct to the constructor (or setText() function) of JMenuItem:
JMenuItem myItem = new JMenuItem("<html><b>Increase</b> font size</html>");
The more interesting part is providing custom rendering for your menu items. The obvious approach is to extend the JMenuItem class and override the paintComponent(Graphics g) function:
@Override
protected final void paintComponent(Graphics g) {
   Graphics2D graphics = (Graphics2D) g;
   graphics.setColor(Color.red);
   graphics.fillRect(0,0,this.getWidth(), this.getHeight());
   super.paintComponent(graphics);
}
Of course, the fillRect function can be replaced by drawImage for painting your background image before the super implementation is called. However, this approach fails when the corresponding menu item is selected - the background is filled with grey color (or the color specified in UIDefaults / L&F specific setting). In this case, you may completely override the painting function, using HTMLEditorKit, HTMLDocument and HTML.Attribute classes to correctly display both the background image and the HTML text. Of course, special attention has to be paid to icons and accelerator strings. The code given below goes halfway in this direction - it draws only the background image and simple (non-HTML) text.

Let's call our class HeaderMenuItem:
public final class HeaderMenuItem extends JMenuItem 
The constructor is straightforward, making this item disabled:
   
public HeaderMenuItem(String text) {
   super(text);
   this.setEnabled(false);
}
The main functionality lies in the paintComponent overriden function. It sets anti-aliasing hint on the graphics context, paints the background image (with the corresponding dimension) and paints the text with shadow:
@Override
protected final void paintComponent(Graphics g) {
   Graphics2D graphics = (Graphics2D) g;
   graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
	RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
   // paint background image
   graphics.drawImage(ImageCreator.getGradientCubesImage(
        this.getWidth(), this.getHeight(), 
        ImageCreator.mainMidColor,
        ImageCreator.mainUltraDarkColor, 
        (int) (0.7 * this.getWidth()),
	(int) (0.9 * this.getWidth())), 0, 0, null);
   graphics.setFont(HEADER_FONT);
   // place the text slightly to the left of the center
   int x = (this.getWidth() - graphics.getFontMetrics().stringWidth(
	this.getText())) / 4;
   int y = (int)(graphics.getFontMetrics().getLineMetrics(
	this.getText(), graphics).getHeight());

   // paint the text with black shadow
   graphics.setColor(Color.black);
   graphics.drawString(this.getText(), x+1, y+1);
   graphics.setColor(Color.white);
   graphics.drawString(this.getText(), x, y);
}
Where the default font is
private static final Font HEADER_FONT = new Font("Arial", Font.BOLD, 14);
And the ImageCreator class defines color constants
public static final Color mainMidColor = new Color(0, 64, 196);
public static final Color mainUltraDarkColor = new Color(0, 0, 64);
and the getGradientCubesImage function. This function is pretty straightforward:
public static BufferedImage getGradientCubesImage(int width, 
   int height, Color leftColor, Color rightColor, 
   int transitionStart, int transitionEnd) {
First it fills the image with background. All pixels to the left of the transitionStart column are painted with leftColor, all pixels to the right of the transitionEnd column are painted with rightColor, and the pixels in the transition area are filled with the gradient:
BufferedImage image = new BufferedImage(width, height,
   BufferedImage.TYPE_INT_ARGB);

Graphics2D graphics = (Graphics2D) image.getGraphics();
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
   RenderingHints.VALUE_ANTIALIAS_ON);

GradientPaint gradient = new GradientPaint(transitionStart, 0,
   leftColor, transitionEnd, 0, rightColor);
graphics.setPaint(gradient);
graphics.fillRect(transitionStart, 0, 
   transitionEnd - transitionStart, height);

graphics.setColor(leftColor);
graphics.fillRect(0, 0, transitionStart, height);

graphics.setColor(rightColor);
graphics.fillRect(transitionEnd, 0, width - transitionEnd, height);
Now, the transition area is covered with squares of the predefined size. We compute how many rows and columns of squares we have to paint in order to completely cover the transition area:
public static final int CUBE_DIMENSION = 5;
The computation itself:
int cubeCountY = height / ImageCreator.CUBE_DIMENSION;
int cubeCountX = 1 + (transitionEnd - transitionStart)
  / ImageCreator.CUBE_DIMENSION;
int cubeStartY = (height % ImageCreator.CUBE_DIMENSION) / 2;
int cubeStartX = transitionStart
   - (ImageCreator.CUBE_DIMENSION - 
   ((transitionEnd - transitionStart) % ImageCreator.CUBE_DIMENSION));
Now, we iterate over cube rows and columns. A random decision is made - if random number is less than 0.5, no cube is drawn at that location:
for (int col = 0; col < cubeCountX; col++) {
   for (int row = 0; row < cubeCountY; row++) {
     // decide if we should put a cube
     if (Math.random() < 0.5)
        continue;
Now, a semi-random color is chosen. The base color is interpolated according to the current X position in the transition area. This color is then perturbed using a random number:
// Make semi-random choice of color. It should lie
// close to the interpolated color, but still appear
// random
double coef = 1.0 - (((double) col / (double) cubeCountX) + 
  0.9 * (Math.random() - 0.5));
coef = Math.max(0.0, coef);
coef = Math.min(1.0, coef);
The interpolated color is computed:
// Compute RGB components
int r = (int) (coef * leftColor.getRed() + (1.0 - coef)
   * rightColor.getRed());
int g = (int) (coef * leftColor.getGreen() + (1.0 - coef)
   * rightColor.getGreen());
int b = (int) (coef * leftColor.getBlue() + (1.0 - coef)
   * rightColor.getBlue());
And the corresponding square is filled:
// fill cube
graphics.setColor(new Color(r, g, b));
graphics.fillRect(cubeStartX + col * ImageCreator.CUBE_DIMENSION, 
   cubeStartY + row * ImageCreator.CUBE_DIMENSION,
   ImageCreator.CUBE_DIMENSION,
   ImageCreator.CUBE_DIMENSION);
The last thing - the border of the square is painted with slightly brighter color (10% closer to the pure white):
// draw cube's border in slightly brighter color
graphics.setColor(new Color(
   255 - (int) (0.95 * (255 - r)),
   255 - (int) (0.9 * (255 - g)),
   255 - (int) (0.9 * (255 - b))));
graphics.drawRect(cubeStartX + col * ImageCreator.CUBE_DIMENSION, 
   cubeStartY + row * ImageCreator.CUBE_DIMENSION,
   ImageCreator.CUBE_DIMENSION,
   ImageCreator.CUBE_DIMENSION);
The last thing you can do with your regular menu item - draw it in anti-aliased text. All you have to do is extend the JMenuItem class and override the paintComponent function:
@Override
protected final void paintComponent(Graphics g) {
   Graphics2D graphics = (Graphics2D) g;
   Object oldHint = graphics.getRenderingHint(
      RenderingHints.KEY_TEXT_ANTIALIASING);
   graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
      RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
   super.paintComponent(graphics);
   graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
      oldHint);
}
Don't forget to restore the state of the graphics context.

How to create your own icons

Posted by kirillcool on February 25, 2005 at 05:50 AM | Permalink | Comments (16)

Almost every application with GUI needs icons. And they better be sexy. And stylish. And consistent. And small. Here are few tips for programatically creating icons using Java 2D features

First, here is a screenshot of a demo that shows icons created using the techniques described in this entry. As you can see, it can paint any letter on any background, creating an optional plus sign and optional arrow that can point either to the right or to the left (note that the actual quality of the icons in GUI is better then saved JPEG version):
.
Here is an enlarged shot of a letter G:

The important visual details in the icons:
  • The letter itself is anti-aliased
  • A whitish highlight spot is in the upper left corner of the icon to make it look 3-D
  • This spot does not influence the black border of the icon, which is also anti-aliased
  • The plus sign has a semi-transparent white halo. Without halo, the red color is too close to the half-black pixels of the border. With completely opaque white color, the sign stands out too much
  • The arrow has semi-transparent white halo. Around horizontal part, the halo is more opaque, around the arrow head the halo is more transparent

And now for the interesting part - the code itself. First we create a completely transparent image. This way, when the icon will be drawn on non-white background, it will blend in nicely:
   BufferedImage image = new BufferedImage(ICON_DIMENSION, ICON_DIMENSION,
      BufferedImage.TYPE_INT_ARGB);
   // set completely transparent
   for (int col = 0; col < ICON_DIMENSION; col++) {
      for (int row = 0; row < ICON_DIMENSION; row++) {
         image.setRGB(col, row, 0x0);
      }
   }
Now, retrieve the graphics context of this image and set rendering hints for antialiasing
   Graphics2D graphics = (Graphics2D) image.getGraphics();
   graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
      RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
   graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
      RenderingHints.VALUE_ANTIALIAS_ON);
Now, we fill a circle with specified background color
     
   graphics.setColor(backgroundColor);
   graphics.fillOval(0, 0, ICON_DIMENSION - 1, ICON_DIMENSION - 1);
Now, before we draw the border, we create a whitish spot. This one is not particularly tricky - based on the distance from a pixel to the spotlight location, make the color of the current pixel closer to white color (or the color of the spotlight). Important - don't forget to preserve the transparency (or opacity) of each pixel:
    
   // create a whitish spot in the left-top corner of the icon
   double id4 = ICON_DIMENSION / 4.0;
   double spotX = id4;
   double spotY = id4;
   for (int col = 0; col < ICON_DIMENSION; col++) {
      for (int row = 0; row < ICON_DIMENSION; row++) {
         // distance to spot
         double dx = col - spotX;
         double dy = row - spotY;
         double dist = Math.sqrt(dx * dx + dy * dy);

         // distance of 0.0 - comes 90% to Color.white
         // distance of ICON_DIMENSION - stays the same

         if (dist > ICON_DIMENSION) {
            dist = ICON_DIMENSION;
         }

         int currColor = image.getRGB(col, row);
         int transp = (currColor >>> 24) & 0xFF;
         int oldR = (currColor >>> 16) & 0xFF;
         int oldG = (currColor >>> 8) & 0xFF;
         int oldB = (currColor >>> 0) & 0xFF;

         double coef = 0.9 - 0.9 * dist / ICON_DIMENSION;
         int dr = 255 - oldR;
         int dg = 255 - oldG;
         int db = 255 - oldB;
   
         int newR = (int) (oldR + coef * dr);
         int newG = (int) (oldG + coef * dg);
         int newB = (int) (oldB + coef * db);

         int newColor = (transp << 24) | (newR << 16) | (newG << 8)
            | newB;
         image.setRGB(col, row, newColor);
      }
   }
Now, draw the outline of the icon in black. Here, based on the background color, we can choose the color of the border as lying somewhere 70-80% on the way from the background color to black. In this way, the icon will have matching border color.
   
   // draw outline of the icon
   graphics.setColor(Color.black);
   graphics.drawOval(0, 0, ICON_DIMENSION - 1, ICON_DIMENSION - 1);
Now, take the input letter and make it capital (this looks much better on icons). Then, set font that is a few pixels smaller than the icon dimension. Compute the bounds of this letter, and set the position for this letter so that it will be centered in the icon's center:
       
   letter = Character.toUpperCase(letter);
   graphics.setFont(new Font("Arial", Font.BOLD, ICON_DIMENSION-5));
   FontRenderContext frc = graphics.getFontRenderContext();
   TextLayout mLayout = new TextLayout("" + letter, graphics.getFont(),
      frc);

   float x = (float) (-.5 + (ICON_DIMENSION - mLayout.getBounds()
      .getWidth()) / 2);
   float y = ICON_DIMENSION
      - (float) ((ICON_DIMENSION - mLayout.getBounds().getHeight()) / 2);
Now we can draw the letter (in black color):
   // draw the letter
   graphics.drawString("" + letter, x, y);
Put optional plus sign in the top-right corner of the icon. First, create a semi-transparent white background for it, and then draw a red plus-sign two pixels thick:
   // if collection - draw '+' sign
   if (
   int height = 6;
   BufferedImage image = new BufferedImage(width, height,
      BufferedImage.TYPE_INT_ARGB);
   // set completely transparent
   for (int col = 0; col < width; col++) {
      for (int row = 0; row < height; row++) {
         image.setRGB(col, row, 0x0);
      }
   }
Get the graphics context, set antialiasing hint and draw an arrow of specified color:
   Graphics2D graphics = (Graphics2D) image.getGraphics();
   graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
      RenderingHints.VALUE_ANTIALIAS_ON);

   // draw arrow
   Polygon pol = new Polygon();
   int ya = 3;
   pol.addPoint(1, ya);
   pol.addPoint(width / 2 + 3, ya);
   pol.addPoint(width / 2 + 3, ya + 2);
   pol.addPoint(width - 1, ya);
   pol.addPoint(width / 2 + 3, ya - 2);
   pol.addPoint(width / 2 + 3, ya);
   graphics.setColor(color);
   graphics.drawPolygon(pol);
And now for the tricky part - we have to compute the halo. Here, if an arrow pixel was completely opaque, it should have less transparent halo than arrow pixel that was only partly opaque (as on arrow's head for example). Here, we create another image with the halo footprint, and then draw the original arrow on top of it. Each arrow pixel contributes to its 8 neighbouring pixels. The final opacity of the halo footprint is the maximal opacity of all neighbouring arrow pixels:
   // create semi-transparent halo around arrow (to make it stand
   // out)
   BufferedImage fimage = new BufferedImage(width, height,
      BufferedImage.TYPE_INT_ARGB);
   // set completely transparent
   for (int col = 0; col < width; col++) {
      for (int row = 0; row < height; row++) {
         fimage.setRGB(col, row, 0x0);
      }
   }
   Graphics2D fgraphics = (Graphics2D) fimage.getGraphics();
   for (int col = 0; col < width; col++) {
      int xs = Math.max(0, col - 1);
      int xe = Math.min(width - 1, col + 1);
      for (int row = 0; row < height; row++) {
         int ys = Math.max(0, row - 1);
         int ye = Math.min(height - 1, row + 1);
         int currColor = image.getRGB(col, row);
         int opacity = (currColor >>> 24) & 0xFF;
         if (opacity > 0) {
            // mark all pixels in 3*3 area
            for (int x = xs; x <= xe; x++) {
               for (int y = ys; y <= ye; y++) {
                  int oldOpacity = (fimage.getRGB(x, y) >>> 24) & 0xFF;
                  int newOpacity = Math.max(oldOpacity, opacity);
                  // set semi-transparent white
                  int newColor = (newOpacity << 24) | (255 << 16) |
                     (255 << 8) | 255;
                  fimage.setRGB(x, y, newColor);
               }
            }
         }
      }
   }
The final step - reduce the opacity of the halo by 30%. This is needed to reduce complete opacity around vertical and horizontal lines:
   // reduce opacity of all pixels by 30%
   for (int col = 0; col < width; col++) {
      for (int row = 0; row < height; row++) {
         int oldOpacity = (fimage.getRGB(col, row) >>> 24) & 0xFF;
         int newOpacity = (int)(0.7*oldOpacity);
         int newColor = (newOpacity << 24) | (255 << 16) |
            (255 << 8) | 255;
         fimage.setRGB(col, row, newColor);
      }
   }
Now, draw the original arrow on top of its halo
   // draw the original arrow image on top of the halo
   fgraphics.drawImage(image, 0, 0, null);
Going back to the original image (the letter and optional plus sign) - draw the arrow image on top of it:
   BufferedImage arrowImage = getArrowImage(arrowColor, ICON_DIMENSION);
   graphics.drawImage(arrowImage, 0, ICON_DIMENSION
     - arrowImage.getHeight(), null);
Arguably, this was a lot of code. On the other hand, you can now create icon with any letter, any background, optional plus sign and optional arrow of any color. See how nice your application can become:




Powered by
Movable Type 3.01D
 Feed java.net RSS Feeds