javajavafxopenfx

TextField that accepts negatives and positives numbers - JavaFx


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

Example: enter image description here


Solution

  • General Idea

    Here are the general ideas needed to meet each of your individual requirements.

    Using a TextFormatter with a Spinner

    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

    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);
    

    Rounding the Value

    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;
    }
    

    Parsing & Formatting

    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).


    Example

    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;
    }
    

    BigDecimal

    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:

    1. 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);
      
    2. 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.

    3. 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).