Skip to main content

How to create pulsating buttons in your Look and Feel

Posted by kirillcool on May 6, 2005 at 4:44 AM PDT

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 [prettify]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; [/prettify]

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.

Related Topics >>