You said:
The Spinner's text field allows arbitrary inputs but only accepts numerical input on commitEdit.
If you just want your commitEdit()
call to accept something like "125 inch", then try this approach:
- Supply the
JSpinner
with a DefaultEditor
.
- Obtain the
DefaultEditor
's JFormattedTextField
.
- Make it editable.
- Supply it with a custom (simple)
AbstractFormatterFactory
.
- Your custom
AbstractFormatterFactory
makes custom (simple) AbstractFormatter
s.
- These
AbstractFormatter
s handle the parsing of the user's input string to a value, and back, so you can then check for any parsing errors and throw ParseException
if needed.
Follows sample code:
import java.awt.GridLayout;
import java.text.ParseException;
import java.util.HashMap;
import java.util.Objects;
import javax.swing.JButton;
import javax.swing.JFormattedTextField;
import javax.swing.JFormattedTextField.AbstractFormatter;
import javax.swing.JFormattedTextField.AbstractFormatterFactory;
import javax.swing.JFrame;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JSpinner.DefaultEditor;
import javax.swing.SpinnerNumberModel;
public final class CommitSpinner extends JPanel {
private String currentUnit;
/**
* Your formatter converts the text to integer by ignoring any measuring units,
* and then converts the integer back to string by appending the last measuring unit:
*/
private final class MyFormatter extends AbstractFormatter {
@Override
public Object stringToValue(final String text) throws ParseException {
currentUnit = parseUnitPart(text);
return parseIntegerPart(text);
}
@Override
public String valueToString(final Object value) throws ParseException {
return Objects.toString(value) + ' ' + currentUnit;
}
}
private final class MyFormatterFactory extends AbstractFormatterFactory {
private final HashMap<JFormattedTextField, AbstractFormatter> formatters;
private MyFormatterFactory() {
formatters = new HashMap<>();
}
/**
* Because this method is a 'getter', I implemented the factory with a HashMap.
* If it was a 'createFormatter' method for example, you could simply return a new
* instance of MyFormatter.
* @param tf the formatted text field to obtain its formatter.
* @return the formatter for the given formatted text field.
*/
@Override
public AbstractFormatter getFormatter(final JFormattedTextField tf) {
if (!formatters.containsKey(tf))
formatters.put(tf, new MyFormatter());
return formatters.get(tf);
}
}
private CommitSpinner() {
super(new GridLayout(0, 1));
currentUnit = "inch"; //Let's say the first value of the spinner is measured in inches.
final JSpinner spin = new JSpinner(new SpinnerNumberModel(0, 0, 10000, 1)); //Your values here. Let's say for now this is a spinner for integers.
final DefaultEditor editor = new DefaultEditor(spin); //We need a DefaultEditor (to be able to obtain the JFormattedTextField and customize it).
final JFormattedTextField field = editor.getTextField();
field.setFocusLostBehavior(JFormattedTextField.COMMIT); //Could be "PERSIST" also, but it shall not be "COMMIT_OR_REVERT" (which is the default).
field.setFormatterFactory(new MyFormatterFactory());
field.setEditable(true); //Allow user input (obvious reasons).
spin.setEditor(editor);
//The commitButton button will "commitEdit()", and the ParseException exception part works as expected!
final JButton commitButton = new JButton("Check input");
commitButton.addActionListener(e -> {
try {
spin.commitEdit();
JOptionPane.showMessageDialog(null, currentUnit + " = " + spin.getValue());
}
catch (final ParseException pe) {
JOptionPane.showMessageDialog(null, pe.toString(), "Illegal input!", JOptionPane.ERROR_MESSAGE);
}
});
add(spin);
add(commitButton);
}
public static void main(final String[] args) {
final JFrame frame = new JFrame("CommitSpinner demo");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.getContentPane().add(new CommitSpinner());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}
//Let's parse user input... What could go wrong?... xD
private static int parseIntegerPart(final String text) throws ParseException {
if (text == null)
throw new ParseException("Text is null.", 0);
if (text.trim().isEmpty())
throw new ParseException("Text is empty.", 0);
final String[] args = text.split(" ");
if (args.length != 2)
throw new ParseException("Text has invalid number of arguments (required 2, found " + args.length + ").", 0);
final String unit = args[1].trim();
if (!unit.equalsIgnoreCase("inch")
&& !unit.equalsIgnoreCase("cm"))
throw new ParseException("Nor 'inch', nor 'cm' detected.", 0);
try {
return Integer.valueOf(args[0].trim());
}
catch (final NumberFormatException nfe) {
throw new ParseException(args[0] + " is not a valid integer value.", 0);
}
}
//Let's parse user input... What could go wrong?... xD
private static String parseUnitPart(final String text) throws ParseException {
if (text == null)
throw new ParseException("Text is null.", 0);
if (text.trim().isEmpty())
throw new ParseException("Text is empty.", 0);
final String[] args = text.split(" ");
if (args.length != 2)
throw new ParseException("Text has invalid number of arguments (required 2, found " + args.length + ").", 0);
final String unit = args[1].trim().toLowerCase();
if (!unit.equals("inch") && !unit.equals("cm"))
throw new ParseException("Nor 'inch', nor 'cm' detected.", 0);
return unit;
}
}
You also said:
I'd like to be notified if there was garbage input before it tries to parse the entire input.
A custom DocumentFilter
in the JFormattedTextField
will do the trick.
In AbstractFormatter
there is a method called getDocumentFilter()
. If you see the code, this method is called to add a DocumentFilter
to the JFormattedTextField
.
DocumentFilter
s' methods are called while the user is typing, i.e. you can parse the user's input before he "commits" it.
The default implementation returns null
, which means no DocumentFilter
will be added.
So I just only overrided this method to implement my custom (simple) DocumentFilter
which in turn checks the user input while the user is typing it...
If the user's input is parsed OK then I just set the foreground color of a label to green. If not, then red. But this is just to demonstrate where you may handle such events.
Follows sample code:
import java.awt.Color;
import java.awt.GridLayout;
import java.text.ParseException;
import java.util.HashMap;
import java.util.Objects;
import javax.swing.JButton;
import javax.swing.JFormattedTextField;
import javax.swing.JFormattedTextField.AbstractFormatter;
import javax.swing.JFormattedTextField.AbstractFormatterFactory;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JSpinner.DefaultEditor;
import javax.swing.SpinnerNumberModel;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.DocumentFilter;
public final class RealTimeSpinner extends JPanel {
private String currentUnit;
private final JLabel stateLabel;
private final class MyFormatter extends AbstractFormatter {
@Override
public Object stringToValue(final String text) throws ParseException {
currentUnit = parseUnitPart(text);
return parseIntegerPart(text);
}
@Override
public String valueToString(final Object value) throws ParseException {
return Objects.toString(value) + ' ' + currentUnit;
}
@Override
protected DocumentFilter getDocumentFilter() {
return new DocumentFilter() {
private void after(final FilterBypass fb) throws BadLocationException {
try {
//Obtain the user's input text so far:
final String theWholeNewText = fb.getDocument().getText(0, fb.getDocument().getLength());
//Try parse the user's input so far:
parseUnitPart(theWholeNewText);
parseIntegerPart(theWholeNewText);
//If the parsing succeeds, then set the foreground color to GREEN:
stateLabel.setForeground(Color.GREEN.darker());
}
catch (final ParseException pe) {
//If the parsing fails, then set the foreground color to RED:
stateLabel.setForeground(Color.RED.darker());
}
}
@Override
public void remove(final FilterBypass fb, final int offset, final int length) throws BadLocationException {
super.remove(fb, offset, length);
after(fb);
}
@Override
public void insertString(final FilterBypass fb, final int offset, final String string, final AttributeSet attr) throws BadLocationException {
super.insertString(fb, offset, string, attr);
after(fb);
}
@Override
public void replace(final FilterBypass fb, final int offset, final int length, final String text, final AttributeSet attrs) throws BadLocationException {
super.replace(fb, offset, length, text, attrs);
after(fb);
}
};
}
}
private final class MyFormatterFactory extends AbstractFormatterFactory {
private final HashMap<JFormattedTextField, AbstractFormatter> formatters;
private MyFormatterFactory() {
formatters = new HashMap<>();
}
/**
* Because this method is a 'getter', I implemented the factory with a HashMap.
* If it was a 'createFormatter' method for example, you could simply return a new
* instance of MyFormatter.
* @param tf the formatted text field to obtain its formatter.
* @return the formatter for the given formatted text field.
*/
@Override
public AbstractFormatter getFormatter(final JFormattedTextField tf) {
if (!formatters.containsKey(tf))
formatters.put(tf, new MyFormatter());
return formatters.get(tf);
}
}
private RealTimeSpinner() {
super(new GridLayout(0, 1));
currentUnit = "inch"; //Let's say the first value of the spinner is measured in inches.
final JSpinner spin = new JSpinner(new SpinnerNumberModel(0, 0, 10000, 1)); //Your values here. Let's say for now this is a spinner for integers.
final DefaultEditor editor = new DefaultEditor(spin); //We need a DefaultEditor (to be able to obtain the JFormattedTextField and customize it).
final JFormattedTextField field = editor.getTextField();
field.setFocusLostBehavior(JFormattedTextField.COMMIT); //Could be "PERSIST" also, but it shall not be "COMMIT_OR_REVERT" (which is the default).
field.setFormatterFactory(new MyFormatterFactory());
field.setEditable(true); //Allow user input (obvious reasons).
spin.setEditor(editor);
final JButton commitButton = new JButton("Check input");
commitButton.addActionListener(e -> {
try {
spin.commitEdit();
JOptionPane.showMessageDialog(null, currentUnit + " = " + spin.getValue());
}
catch (final ParseException pe) {
JOptionPane.showMessageDialog(null, pe.toString(), "Illegal input!", JOptionPane.ERROR_MESSAGE);
}
});
stateLabel = new JLabel("This is the color of the state of the input.", JLabel.CENTER);
stateLabel.setForeground(Color.GREEN.darker());
add(spin);
add(commitButton);
add(stateLabel);
}
public static void main(final String[] args) {
final JFrame frame = new JFrame("CommitSpinner demo");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.getContentPane().add(new RealTimeSpinner());
frame.pack();
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}
//Let's parse user input... What could go wrong?... xD
private static int parseIntegerPart(final String text) throws ParseException {
if (text == null)
throw new ParseException("Text is null.", 0);
if (text.trim().isEmpty())
throw new ParseException("Text is empty.", 0);
final String[] args = text.split(" ");
if (args.length != 2)
throw new ParseException("Text has invalid number of arguments (required 2, found " + args.length + ").", 0);
final String unit = args[1].trim();
if (!unit.equalsIgnoreCase("inch")
&& !unit.equalsIgnoreCase("cm"))
throw new ParseException("Nor 'inch', nor 'cm' detected.", 0);
try {
return Integer.valueOf(args[0].trim());
}
catch (final NumberFormatException nfe) {
throw new ParseException(args[0] + " is not a valid integer value.", 0);
}
}
//Let's parse user input... What could go wrong?... xD
private static String parseUnitPart(final String text) throws ParseException {
if (text == null)
throw new ParseException("Text is null.", 0);
if (text.trim().isEmpty())
throw new ParseException("Text is empty.", 0);
final String[] args = text.split(" ");
if (args.length != 2)
throw new ParseException("Text has invalid number of arguments (required 2, found " + args.length + ").", 0);
final String unit = args[1].trim().toLowerCase();
if (!unit.equals("inch") && !unit.equals("cm"))
throw new ParseException("Nor 'inch', nor 'cm' detected.", 0);
return unit;
}
}