I'm trying to create a Spinner in JavaFX that accepts only multiples of 0.25 and has positive and negative masks, such as -1,50 and +1,50 and have two decimals places and the max value of -20 to 20. In both cases, I need the mask to show (-) and (+). The TextField field must be editable and follow the same rule.
I managed to create a customizable TextField like this but i dont know how to do in a Spinner:
public class TestPane extends BorderPane {
public TestPane() {
TextField textField = new TextField();
BigDecimalConverter converter = new BigDecimalConverter();
TextFormatter<BigDecimal> textFormatter = new TextFormatter<>(converter, BigDecimal.ZERO, c -> {
if (!c.getControl().isFocused()) return null;
String newText = c.getControlNewText().replace(".", ",");
if (c.getControlNewText().isEmpty()) {
return c;
}
if (c.getControlNewText().equals("-") && c.getAnchor() == 1) {
return c;
}
if (c.getControlNewText().equals("+") && c.getAnchor() == 1) {
return c;
}
if (c.getControlNewText().startsWith("-") && c.getControlCaretPosition() == 0) {
return c;
}
if (c.getControlNewText().startsWith("+") && c.getControlCaretPosition() == 0) {
c.setText(c.getText() + " ");
return c;
}
BigDecimal newValue = converter.fromString(c.getControlNewText());
if (newValue != null) {
return c;
} else {
return null;
}
});
textFormatter.valueProperty().bindBidirectional(valueProperty);
textField.setTextFormatter(textFormatter);
setCenter(new VBox(10, new HBox(6, new Text("TextField 1"), textField)));
}
}
public static class BigDecimalConverter extends BigDecimalStringConverter {
@Override
public String toString(BigDecimal value) {
if (value == null) return "0";
return super.toString(value);
}
@Override
public BigDecimal fromString(String value) {
if (value == null || value.isEmpty()) return BigDecimal.ZERO;
return super.fromString(value);
}
}
edit: i'm using the solution by @swpalmer and implemented this solution to the editor TextFormatter:
TextField editor = spinner.getEditor();
Pattern validDoubleText = Pattern.compile("[+-]?\\d{0,2}(\\,\\d{0,2})?");
UnaryOperator<TextFormatter.Change> filter = c -> {
if (validDoubleText.matcher(c.getControlNewText()).matches()) {
return c;
} else {
return null;
}
};
TextFormatter<Double> textFormatter = new TextFormatter<Double>(filter);
but i dont know how to limit to only values of (-20,00 to 20,00), and to put an + with the number is positive
Here are the general ideas needed to meet each of your individual requirements.
The Spinner
class has an editor
property which holds a TextField
. A TextFormatter
can be set on this field just like you would any other. You should set the formatter's converter to the same one used by the SpinnerValueFactory
.
Clamping the value between minimum and maximum values is relatively straightforward:
double clamp(double value, double min, double max) {
return Math.min(max, Math.max(min, value));
}
On Java 21+ that can be replaced with:
Math.clamp(value, min, max);
To round the value to the nearest multiple of some other value, you can use:
double round(double value, double step) {
// Note: Math#round rounds ties to positive infinity ("half ceiling")
return Math.round(value / step) * step;
}
And you can use a DecimalFormat
to parse strings into numbers and format numbers into strings. Not only can you define the desired pattern to use, but it also adds some localization to your application. For instance, whether .
or ,
(or some other character) is used as the decimal separator will be dynamically determined based on the specified locale (or the system default locale if one is not explicitly given).
Here is an example using the above concepts. Parsing, formatting, clamping, and rounding the value are all encapsulated in a StringConverter
implementation.
Main.java
package com.example.app;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory;
import javafx.scene.control.TextFormatter;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
public class Main extends Application {
private static final double MIN = -20.0;
private static final double MAX = -MIN;
private static final double STEP = 0.25;
@Override
public void start(Stage primaryStage) {
var converter = new ConstrainedDoubleStringConverter(MIN, MAX, STEP);
var factory = new DoubleSpinnerValueFactory(MIN, MAX, 0.0, STEP);
factory.setConverter(converter);
var formatter = new TextFormatter<>(converter, 0.0, change -> {
if (change.isContentChange()) {
var text = change.getControlNewText();
if (!converter.isParsable(text)) {
return null;
}
}
return change;
});
var spinner = new Spinner<>(factory);
spinner.getEditor().setTextFormatter(formatter);
spinner.setEditable(true);
primaryStage.setScene(new Scene(new StackPane(spinner), 500, 300));
primaryStage.show();
}
public static void main(String[] args) {
launch(Main.class, args);
}
}
ConstrainedDoubleStringConverter.java
package com.example.app;
import java.text.DecimalFormat;
import java.text.ParsePosition;
import javafx.util.StringConverter;
// Note: This implementation does not allow parsing +/- infinity or NaN
public class ConstrainedDoubleStringConverter extends StringConverter<Double> {
private final DecimalFormat format = new DecimalFormat("+0.00;-#");
private final double min;
private final double max;
private final double stepAmount;
public ConstrainedDoubleStringConverter(double min, double max, double stepAmount) {
this.min = min;
this.max = max;
this.stepAmount = stepAmount;
}
@Override
public String toString(Double object) {
return format.format(object == null ? 0.0 : object);
}
@Override
public Double fromString(String string) {
double value = parse(string);
if (!Double.isFinite(value))
throw new NumberFormatException("Unable to parse into finite double: " + string);
// round value to nearest multiple of stepAmount
value = Math.round(value / stepAmount) * stepAmount;
// coerce value to be between min and max
return Math.clamp(value, min, max);
}
// A way to test if 'fromString' would succeed without using
// exceptions for control flow.
public boolean isParsable(String string) {
return Double.isFinite(parse(string));
}
private double parse(String s) {
if (s == null || s.isEmpty() || isOnlyPrefix(s)) {
return 0.0;
}
// If there is no prefix then assume positive.
if (!startsWithPrefix(s)) {
s = format.getPositivePrefix() + s;
}
// This approach avoids ParseException on errors and ensures the entire
// string was consumed (NumberFormat is capable of partial parsing).
var position = new ParsePosition(0);
var number = format.parse(s, position);
if (position.getErrorIndex() != -1 || position.getIndex() != s.length()) {
return Double.NaN;
}
return number.doubleValue();
}
private boolean isOnlyPrefix(String s) {
return s.length() == 1 && startsWithPrefix(s);
}
private boolean startsWithPrefix(String s) {
if (s.length() >= 1) {
var positive = format.getPositivePrefix();
var negative = format.getNegativePrefix();
return s.startsWith(positive) || s.startsWith(negative);
}
return false;
}
}
module-info.java (optional)
module com.example.app {
requires javafx.controls;
exports com.example.app to
javafx.graphics;
}
Your question mentions wanting to use BigDecimal
. I agree with swpalmer's answer that BigDecimal
is overkill for your range of values and step amount. Using double
is sufficient and makes the implementation easier. That said, if you need to use BigDecimal
for some reason, then you need to:
Rewrite the code from above to use BigDecimal
, which basically entails replacing all Double
/ double
types with BigDecimal
.
Clamping:
BigDecimal clamp(BigDecimal value, BigDecimal min, BigDecimal max) {
return min.max(value.min(max));
}
Rounding:
BigDecimal round(BigDecimal value, BigDecimal step) {
// Note: This does not 100% match the behavior of the double version above
return value.divide(step, 0, RoundingMode.HALF_UP).multiply(step);
}
Parsing & Formating:
// Configure parsing BigDecimal
format.setParseBigDecimal(true);
// cast the result
var value = (BigDecimal) format.parse(string, position);
Write a SpinnerValueFactory
implementation that works with BigDecimal
. This involves implementing an increment(int)
and a decrement(int)
method (both of which should take into account if the factory is set to "wrap around" the values or not). You can look at the existing implementations for help.
Have the StringConverter<BigDecimal>
implementation copy the return value when it's equal to 0
. The reason being that BigDecimal
caches 0
values at a few different scales, and that interferes with the involved JavaFX properties updating properly (the built-in property implementations only fire invalidation events if the new value and current value are !=
, i.e., different objects).