|
The Java Specialists' Newsletter
Issue 148 2007-07-31
Category:
GUI
Java version: 1.4+ Snappy JSliders in Swingby Michael KneeboneAbstract:
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 1.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.
Upcoming Java Specialist Master Courses:
- please click here to sign up.
As from May 2010, we are also offering this course on the island of Crete. We
only accept 6 students per class in Crete, due to the size of our conference
room. Please book early to avoid disappointment!
San Jose CA, Mar 16-19 2010, $3500 Ottawa, Canada, Mar 22-25 2010, $3500 Oslo, Norway, Apr 13-16 2010, Kr 24500 Montreal, Canada, Apr 20-23 2010, $3500 Toronto, Canada, May 17-20 2010, $3500 Chania, Crete, May 25-28, Jun 29-Jul 2 or Aug 24-27 2010, €2500
In-house courses if these dates or locations do not suit you - click here for more information. 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
GUI Articles
Related Java Course
|