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.
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
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)
We deliver relevant courses, by top Java developers to produce more resourceful and efficient programmers within their organisations.