Running on Java 24-ea+21-2447 (Preview)
Home of The JavaSpecialists' Newsletter

148Snappy JSliders in Swing

Author: Michael KneeboneDate: 2007-07-31Java Version: 1.4Category: GUI
 

Abstract: Recent versions of Swing do a good job of mimicking the underlying platform, with a few caveats. For example, the JSlider only snaps onto the correct tick once you let go of the mouse. Here I present a fix for this problem with a non-intrusive one-liner that we can add to the application code.

 

Welcome to the 148th issue of The Java(tm) Specialists' Newsletter. In this issue, we are honoured to have a guest author, Michael Kneebone, from the University of Birmingham in the UK. Michael discovered an interesting approach to overcoming GUI anomalies in the Swing framework using some dynamic reflection code and did me the favour of writing up his approach. Enjoy this break from our Secrets of Concurrency series, which we will continue again in our next issue. Over to Michael Kneebone ... enjoy! Heinz

Java's GUI toolkit, Swing, has grown to become a powerful framework which, when used with the platform look and feel (or UI for short) tries to copy the way native applications behave. Java 6 improves over previous versions in several ways, but still falls short in some places. The snapping behaviour on JSliders is one such place. The code in this newsletter fixes the problem while requiring minimal code changes to applications.

javaspecialists.teachable.com: Please visit our new self-study course catalog to see how you can upskill your Java knowledge.

Snappy JSliders in Swing

First, try this warm-up exercise: Load any Java application which includes a JSlider where the thumb "snaps" to the ticks/labels (if you don't have one to hand, run the test code at the end of the newsletter with the comment intact). Now open a native program with a snapping slider and experiment (e.g. the display resolution slider in Windows' "Display Properties"). Notice anything? The native slider thumb jumps between ticks as it's dragged, but in Java the thumb follows the mouse exactly and only snaps to a valid point when the thumb is released. I created the code presented below to adapt Swing's behaviour to imitate native applications exactly.

The easiest solution would be to subclass JSlider and hack it to pieces, but this is very inflexible (every JSlider instance would need to be changed, and what if existing subclasses were already in use?) and we can do better. I wanted a solution that would mean minimal effort for the developer.

Swing has the ability to plug different themes (Look and Feels - LAFs - in Swing lingo) into it which control a component's appearance and operation. Upon construction every standard Swing component queries a central registry managed by the UIManager class to obtain an object instance that implements its user interface (UI).

Since the current half-baked snapping algorithm is built into the LAF code, then this makes the perfect entry point to add any new behaviour. Now to the code:

import javax.swing.*;
import javax.swing.event.MouseInputAdapter;
import javax.swing.plaf.ComponentUI;
import javax.swing.plaf.basic.BasicSliderUI;
import java.awt.*;
import java.awt.event.*;
import java.beans.*;
import java.lang.reflect.*;

public class SliderSnap extends BasicSliderUI {
  /**
   * The UI class implements the current slider Look and Feel.
   */
  private static Class sliderClass;
  private static Method xForVal, yForVal;
  private static ReinitListener reinitListener =
      new ReinitListener();

  public SliderSnap() {
    super(null);
  }

  /**
   * Returns the UI as normal, but intercepts the call, so a
   * listener can be attached.
   */
  public static ComponentUI createUI(JComponent c) {
    if (c == null || sliderClass == null)
      return null;
    UIDefaults defaults = UIManager.getLookAndFeelDefaults();
    try {
      Method m = (Method) defaults.get(sliderClass);
      if (m == null) {
        m = sliderClass.getMethod("createUI",
          new Class[] {JComponent.class});
        defaults.put(sliderClass, m);
      }
      ComponentUI uiObject = (ComponentUI) m.invoke(null,
        new Object[] {c});
      if (uiObject instanceof BasicSliderUI)
        c.addHierarchyListener(new MouseAttacher());
      return uiObject;
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

  public static void init() {
    //check we don't initialise twice
    if (sliderClass != null)
      return;
    Init init = new Init();
    if (EventQueue.isDispatchThread()) {
      init.run();
    } else {
      // This code must run on the EDT for data visibility
      try {
        EventQueue.invokeAndWait(init);
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    }
  }

  /**
   * Listeners for when the JSlider becomes visible then
   * attaches the mouse listeners, then removes itself.
   */
  private static class MouseAttacher implements HierarchyListener {
    public void hierarchyChanged(HierarchyEvent evt) {
      long flags = evt.getChangeFlags();
      if ((flags & HierarchyEvent.DISPLAYABILITY_CHANGED) > 0
          && evt.getComponent() instanceof JSlider) {
        JSlider c = (JSlider) evt.getComponent();
        c.removeHierarchyListener(this);
        attachTo(c);
      }
    }
  }

  /**
   * Listens for Look and Feel changes and re-initialises the
   * class.
   */
  private static class ReinitListener implements
      PropertyChangeListener {
    public void propertyChange(PropertyChangeEvent evt) {
      if ("lookAndFeel".equals(evt.getPropertyName())) {
        // The look and feel was changed so we need to re-insert
        // Our hook into the new UIDefaults map
        sliderClass = null;
        xForVal = yForVal = null;
        UIManager.removePropertyChangeListener(reinitListener);
        init();
      }
    }
  }

  /**
   * Initialises the reflective methods and adjusts the current
   * Look and Feel.
   */
  private static class Init implements Runnable {
    public void run() {
      try {
        UIDefaults defaults = UIManager.getLookAndFeelDefaults();
        sliderClass = defaults.getUIClass("SliderUI");
        // Set up two reflective method calls
        xForVal = BasicSliderUI.class.getDeclaredMethod(
            "xPositionForValue",
            new Class[] {int.class});
        yForVal = BasicSliderUI.class.getDeclaredMethod(
            "yPositionForValue",
            new Class[] {int.class});
        // Allow us access to the methods
        xForVal.setAccessible(true);
        yForVal.setAccessible(true);
        // Replace UI class with ourselves
        defaults.put("SliderUI", SliderSnap.class.getName());
        UIManager.addPropertyChangeListener(reinitListener);
      } catch (Exception e) {
        sliderClass = null;
        xForVal = yForVal = null;
      }
    }
  }

  /**
   * Called to attach mouse listeners to the JSlider.
   */
  private static void attachTo(JSlider c) {
    MouseMotionListener[] listeners = c.getMouseMotionListeners();
    for (int i = 0; i < listeners.length; i++) {
      MouseMotionListener m = listeners[i];
      if (m instanceof TrackListener) {
        c.removeMouseMotionListener(m); //remove original
        SnapListener listen = new SnapListener(m,
            (BasicSliderUI) c.getUI(), c);
        c.addMouseMotionListener(listen);
        c.addMouseListener(listen);
        c.addPropertyChangeListener("UI", listen);
      }
    }
  }

  private static class SnapListener extends MouseInputAdapter
      implements PropertyChangeListener {
    private MouseMotionListener delegate;
    /**
     * Original Look and Feel implementation
     */
    private BasicSliderUI ui;
    /**
     * Our slider
     */
    private JSlider slider;
    /**
     * Offset of mouse click from centre of slider thumb
     */
    private int offset;

    public SnapListener(MouseMotionListener delegate,
                        BasicSliderUI ui, JSlider slider) {
      this.delegate = delegate;
      this.ui = ui;
      this.slider = slider;
    }

    /**
     * UI can change at any point, so we need to listen for these
     * events.
     */
    public void propertyChange(PropertyChangeEvent evt) {
      if ("UI".equals(evt.getPropertyName())) {
        // Remove old listeners and create new ones
        slider.removeMouseMotionListener(this);
        slider.removeMouseListener(this);
        slider.removePropertyChangeListener("UI", this);
        attachTo(slider);
      }
    }

    /**
     * Implements the actual "snap while dragging" behaviour.  If
     * snap to ticks is enabled on this slider, then the location
     * for the nearest tick/label is calculated and the click
     * location is translated before being passed to the
     * delegate.
     */
    public void mouseDragged(MouseEvent evt) {
      if (slider.getSnapToTicks()) { // if we are set to snap
        int pos = getLocationForValue(getSnappedValue(evt));
        // if above call fails and returns -1, take no action
        if (pos > -1) {
          if (slider.getOrientation() == JSlider.HORIZONTAL)
            evt.translatePoint(pos - evt.getX() + offset, 0);
          else
            evt.translatePoint(0, pos - evt.getY() + offset);
        }
      }
      delegate.mouseDragged(evt);
    }

    /**
     * When the slider is clicked we need to record the offset
     * from thumb center.
     */
    public void mousePressed(MouseEvent evt) {
      int pos = (slider.getOrientation() == JSlider.HORIZONTAL) ?
          evt.getX() : evt.getY();
      int loc = getLocationForValue(getSnappedValue(evt));
      this.offset = (loc < 0) ? 0 : pos - loc;
    }

    /* Pass straight to delegate. */
    public void mouseMoved(MouseEvent evt) {
      delegate.mouseMoved(evt);
    }

    /**
     * Calculates the nearest snapable value given a MouseEvent.
     * Code adapted from BasicSliderUI.
     */
    public int getSnappedValue(MouseEvent evt) {
      int value = slider.getOrientation() == JSlider.HORIZONTAL
          ? ui.valueForXPosition(evt.getX())
          : ui.valueForYPosition(evt.getY());
      // Now calculate if we should adjust the value
      int snappedValue = value;
      int tickSpacing = 0;
      int majorTickSpacing = slider.getMajorTickSpacing();
      int minorTickSpacing = slider.getMinorTickSpacing();
      if (minorTickSpacing > 0)
        tickSpacing = minorTickSpacing;
      else if (majorTickSpacing > 0)
        tickSpacing = majorTickSpacing;
      // If it's not on a tick, change the value
      if (tickSpacing != 0) {
        if ((value - slider.getMinimum()) % tickSpacing != 0) {
          float temp = (float) (value - slider.getMinimum())
              / (float) tickSpacing;
          snappedValue = slider.getMinimum() +
              (Math.round(temp) * tickSpacing);
        }
      }
      return snappedValue;
    }

    /**
     * Provides the x or y co-ordinate for a slider value,
     * depending on orientation.
     */
    public int getLocationForValue(int value) {
      try {
        // Reflectively call slider ui code
        Method m = slider.getOrientation() == JSlider.HORIZONTAL
            ? xForVal : yForVal;
        Integer result = (Integer) m.invoke(
            ui, new Object[]{new Integer(value)});
        return result.intValue();
      } catch (InvocationTargetException e) {
        return -1;
      } catch (IllegalAccessException e) {
        return -1;
      }
    }
  }
}

The following test class shows the new snapping behaviour in action. The first JSlider was created before the SliderSnap.init() was called, it thus has the old behaviour. We then create one JSlider for each of your installed Look and Feels.

import javax.swing.*;
import java.awt.*;

public class SliderTest {
  public static void main(String[] args) {
    EventQueue.invokeLater(new Runnable() {
      public void run() {
        JFrame frame = new JFrame();
        frame.getContentPane().setLayout(new GridLayout(0, 1));
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.getContentPane().add(makeSlider("Without Snapping"));
        SliderSnap.init();
        UIManager.LookAndFeelInfo[] infos =
            UIManager.getInstalledLookAndFeels();
        for (int i = 0; i < infos.length; i++) {
          UIManager.LookAndFeelInfo info = infos[i];
          try {
            UIManager.setLookAndFeel(info.getClassName());
            JComponent slider = makeSlider(info.getClassName());
            frame.getContentPane().add(slider);
          } catch (Exception e) {
            e.printStackTrace();
          }
        }
        frame.pack();
        frame.setVisible(true);
      }
    });
  }

  private static JComponent makeSlider(String title) {
    JPanel panel = new JPanel();
    JSlider slider = new JSlider(-50, 50, 0);
    slider.setPaintLabels(true);
    slider.setMajorTickSpacing(20);
    slider.setSnapToTicks(true);
    panel.add(slider);
    panel.setBorder(BorderFactory.createTitledBorder(title));
    return panel;
  }
}

At the start of an application the line SliderSnap.init(); is all that is required. The init() method invokes the run() method in the SliderSnap.Init class to replace the slider UI class of the LAF. When a JSlider requests its UI object, createUI() is called which just returns the original UI object but attaches a HierarchyListener. The code must wait until the slider is displayed before switching the mouse handlers due to a data race that can occur if the component isn't constructed on the event thread (all too common) and we try to switch listeners immediately.

SnapListener implements the snapping logic by calculating the nearest "tick" to the mouse and re-targeting the MouseEvent so it appears to the delegate that the mouse itself is hopping between ticks. The two propertyChange() methods are there to handle LAF changes that occur mid-application and ensure the code hooks into the new LAF and to de-register listeners when the UI changes on a slider.

The interesting part is how the LAF is used to adapt a component's behaviour, not the actual change (the snap calculation borrows heavily from BasicSliderUI). The same approach could be used for several changes in Swing. The code works unaltered with just about any application by including the initialisation line as shown and has been tested successfully with many third-party Look and Feels. It is backwards compatible to at least Java 1.4, though should work with Java 1.3 as well (untested). What's more is that it should remain compatible with future versions, including the upcoming Nimbus Look and Feel that is slated to become Swing's default UI.

Regards

Michael Kneebone E-mail: M.L.Kneebone AT cs DOT bham DOT ac DOT uk

 

Comments

We are always happy to receive comments from our readers. Feel free to send me a comment via email or discuss the newsletter in our JavaSpecialists Slack Channel (Get an invite here)

When you load these comments, you'll be connected to Disqus. Privacy Statement.

Related Articles

Browse the Newsletter Archive

About the Author

Heinz Kabutz Java Conference Speaker

Java Champion, author of the Javaspecialists Newsletter, conference speaking regular... About Heinz

Superpack '23

Superpack '23 Our entire Java Specialists Training in one huge bundle more...

Free Java Book

Dynamic Proxies in Java Book
Java Training

We deliver relevant courses, by top Java developers to produce more resourceful and efficient programmers within their organisations.

Java Consulting

We can help make your Java application run faster and trouble-shoot concurrency and performance bugs...