JavaFX NumberTextField and Spinner Control
Join the DZone community and get the full member experience.
Join For FreeI recently spent some time learning JavaFX and doing a custom control is a good practice to dive a little bit deeper into the concepts of a new gui library. Having some background in financial software I did of course miss the equivalent of a JFormattedTextField and a JSpinner control in the current 2.0 release. So going down that road seemed like a good choice to me.
And here are my controls:
- a NumberTextField, that can be configured with an arbitrary NumberFormat
- a Spinner field that also can also be configured with an arbitrary NumberFormat and controlled with the arrow keys or arrow buttons, that are part of the control
The controls and an example can be downloaded as a netbeans project.The example also includes a css file that styles the spinner with either straight or rounded corners.
NumberTextField
The NumberTextField was pretty easy and I wouldn't even consider this a custom control as I only changed the behaviour of an already existing control. It extends a normal TextField, adds a NumberProperty that serves as the model and holds a BigDecimal (for financial applications we need exact types) and does some formatting and parsing.That's it, no big deal.
package de.thomasbolz.javafx; import java.math.BigDecimal; import java.text.NumberFormat; import java.text.ParseException; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.scene.control.TextField; /** * Textfield implementation that accepts formatted number and stores them in a * BigDecimal property The user input is formatted when the focus is lost or the * user hits RETURN. * * @author Thomas Bolz */ public class NumberTextField extends TextField { private final NumberFormat nf; private ObjectProperty<BigDecimal> number = new SimpleObjectProperty<>(); public final BigDecimal getNumber() { return number.get(); } public final void setNumber(BigDecimal value) { number.set(value); } public ObjectProperty<BigDecimal> numberProperty() { return number; } public NumberTextField() { this(BigDecimal.ZERO); } public NumberTextField(BigDecimal value) { this(value, NumberFormat.getInstance()); initHandlers(); } public NumberTextField(BigDecimal value, NumberFormat nf) { super(); this.nf = nf; initHandlers(); setNumber(value); } private void initHandlers() { // try to parse when focus is lost or RETURN is hit setOnAction(new EventHandler<ActionEvent>() { @Override public void handle(ActionEvent arg0) { parseAndFormatInput(); } }); focusedProperty().addListener(new ChangeListener<Boolean>() { @Override public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) { if (!newValue.booleanValue()) { parseAndFormatInput(); } } }); // Set text in field if BigDecimal property is changed from outside. numberProperty().addListener(new ChangeListener<BigDecimal>() { @Override public void changed(ObservableValue<? extends BigDecimal> obserable, BigDecimal oldValue, BigDecimal newValue) { setText(nf.format(newValue)); } }); } /** * Tries to parse the user input to a number according to the provided * NumberFormat */ private void parseAndFormatInput() { try { String input = getText(); if (input == null || input.length() == 0) { return; } Number parsedNumber = nf.parse(input); BigDecimal newValue = new BigDecimal(parsedNumber.toString()); setNumber(newValue); selectAll(); } catch (ParseException ex) { // If parsing fails keep old number setText(nf.format(number.get())); } } }
NumberSpinner
The NumberSpinner is only slightly more complicated. It builds upon the NumberTextField and adds an increment and decrement button, that increments and decrements the value in the field by a stepwidth.
Initial value, stepwidth and underlying NumberFormat are set in the constructor. The textfield and the size of the buttons are scaled according to the font size that can be - for example - set in the .css file.
package de.thomasbolz.javafx; import java.math.BigDecimal; import java.text.NumberFormat; import javafx.beans.binding.NumberBinding; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.geometry.Pos; import javafx.scene.control.Button; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.shape.LineTo; import javafx.scene.shape.MoveTo; import javafx.scene.shape.Path; import javax.swing.JSpinner; /** * JavaFX Control that behaves like a {@link JSpinner} known in Swing. The * number in the textfield can be incremented or decremented by a configurable * stepWidth using the arrow buttons in the control or the up and down arrow * keys. * * @author Thomas Bolz */ public class NumberSpinner extends HBox { public static final String ARROW = "NumberSpinnerArrow"; public static final String NUMBER_FIELD = "NumberField"; public static final String NUMBER_SPINNER = "NumberSpinner"; public static final String SPINNER_BUTTON_UP = "SpinnerButtonUp"; public static final String SPINNER_BUTTON_DOWN = "SpinnerButtonDown"; private final String BUTTONS_BOX = "ButtonsBox"; private NumberTextField numberField; private ObjectProperty<BigDecimal> stepWitdhProperty = new SimpleObjectProperty<>(); private final double ARROW_SIZE = 4; private final Button incrementButton; private final Button decrementButton; private final NumberBinding buttonHeight; private final NumberBinding spacing; public NumberSpinner() { this(BigDecimal.ZERO, BigDecimal.ONE); } public NumberSpinner(BigDecimal value, BigDecimal stepWidth) { this(value, stepWidth, NumberFormat.getInstance()); } public NumberSpinner(BigDecimal value, BigDecimal stepWidth, NumberFormat nf) { super(); this.setId(NUMBER_SPINNER); this.stepWitdhProperty.set(stepWidth); // TextField numberField = new NumberTextField(value, nf); numberField.setId(NUMBER_FIELD); // Enable arrow keys for dec/inc numberField.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() { @Override public void handle(KeyEvent keyEvent) { if (keyEvent.getCode() == KeyCode.DOWN) { decrement(); keyEvent.consume(); } if (keyEvent.getCode() == KeyCode.UP) { increment(); keyEvent.consume(); } } }); // Painting the up and down arrows Path arrowUp = new Path(); arrowUp.setId(ARROW); arrowUp.getElements().addAll(new MoveTo(-ARROW_SIZE, 0), new LineTo(ARROW_SIZE, 0), new LineTo(0, -ARROW_SIZE), new LineTo(-ARROW_SIZE, 0)); // mouse clicks should be forwarded to the underlying button arrowUp.setMouseTransparent(true); Path arrowDown = new Path(); arrowDown.setId(ARROW); arrowDown.getElements().addAll(new MoveTo(-ARROW_SIZE, 0), new LineTo(ARROW_SIZE, 0), new LineTo(0, ARROW_SIZE), new LineTo(-ARROW_SIZE, 0)); arrowDown.setMouseTransparent(true); // the spinner buttons scale with the textfield size // TODO: the following approach leads to the desired result, but it is // not fully understood why and obviously it is not quite elegant buttonHeight = numberField.heightProperty().subtract(3).divide(2); // give unused space in the buttons VBox to the incrementBUtton spacing = numberField.heightProperty().subtract(2).subtract(buttonHeight.multiply(2)); // inc/dec buttons VBox buttons = new VBox(); buttons.setId(BUTTONS_BOX); incrementButton = new Button(); incrementButton.setId(SPINNER_BUTTON_UP); incrementButton.prefWidthProperty().bind(numberField.heightProperty()); incrementButton.minWidthProperty().bind(numberField.heightProperty()); incrementButton.maxHeightProperty().bind(buttonHeight.add(spacing)); incrementButton.prefHeightProperty().bind(buttonHeight.add(spacing)); incrementButton.minHeightProperty().bind(buttonHeight.add(spacing)); incrementButton.setFocusTraversable(false); incrementButton.setOnAction(new EventHandler<ActionEvent>() { @Override public void handle(ActionEvent ae) { increment(); ae.consume(); } }); // Paint arrow path on button using a StackPane StackPane incPane = new StackPane(); incPane.getChildren().addAll(incrementButton, arrowUp); incPane.setAlignment(Pos.CENTER); decrementButton = new Button(); decrementButton.setId(SPINNER_BUTTON_DOWN); decrementButton.prefWidthProperty().bind(numberField.heightProperty()); decrementButton.minWidthProperty().bind(numberField.heightProperty()); decrementButton.maxHeightProperty().bind(buttonHeight); decrementButton.prefHeightProperty().bind(buttonHeight); decrementButton.minHeightProperty().bind(buttonHeight); decrementButton.setFocusTraversable(false); decrementButton.setOnAction(new EventHandler<ActionEvent>() { @Override public void handle(ActionEvent ae) { decrement(); ae.consume(); } }); StackPane decPane = new StackPane(); decPane.getChildren().addAll(decrementButton, arrowDown); decPane.setAlignment(Pos.CENTER); buttons.getChildren().addAll(incPane, decPane); this.getChildren().addAll(numberField, buttons); } /** * increment number value by stepWidth */ private void increment() { BigDecimal value = numberField.getNumber(); value = value.add(stepWitdhProperty.get()); numberField.setNumber(value); } /** * decrement number value by stepWidth */ private void decrement() { BigDecimal value = numberField.getNumber(); value = value.subtract(stepWitdhProperty.get()); numberField.setNumber(value); } public final void setNumber(BigDecimal value) { numberField.setNumber(value); } public ObjectProperty<BigDecimal> numberProperty() { return numberField.numberProperty(); } public final BigDecimal getNumber() { return numberField.getNumber(); } // debugging layout bounds public void dumpSizes() { System.out.println("numberField (layout)=" + numberField.getLayoutBounds()); System.out.println("buttonInc (layout)=" + incrementButton.getLayoutBounds()); System.out.println("buttonDec (layout)=" + decrementButton.getLayoutBounds()); System.out.println("binding=" + buttonHeight.toString()); System.out.println("spacing=" + spacing.toString()); } }
number_spinner.css
Last but not least the control can be styled in the css file. I played around with two looks, rounded corners and straight corners (see attached screenshots). You can switch between them by changing the border/background-radiuses in #NumberField, #ButtonBox, #SpinnerButtonUp and #SpinnerButtonDown.
.root{ -fx-font-size: 24pt; /* -fx-base: rgb(255,0,0);*/ /* -fx-background: rgb(50,50,50);*/ } #NumberField { -fx-border-width: 1; -fx-border-color: lightgray; -fx-background-insets:1; -fx-border-radius:3 0 0 3; /* -fx-border-radius:0 0 0 0;*/ } #NumberSpinnerArrow { -fx-fill: gray; -fx-stroke: gray; /* -fx-effect: innershadow( gaussian , black , 2 , 0.6 , 1 , 1 )*/ } #ButtonsBox { -fx-border-color:lightgray; -fx-border-width: 1 1 1 0; -fx-border-radius: 0 3 3 0; /* -fx-border-radius: 0 0 0 0;*/ } #SpinnerButtonUp { -fx-background-insets: 0; -fx-background-radius:0 3 0 0; /* -fx-background-radius:0;*/ } #SpinnerButtonDown { -fx-background-insets: 0; -fx-background-radius:0 0 3 0; /* -fx-background-radius:0;*/ }
Conclusion
Doing custom controls in JavaFX is really no big deal although the examples above are really easy ones. JavaFX being a pure Java API since 2.0 now integrates even better than before with languages like groovy where BigDecimal is a first class citizen. This makes it an almost perfect couple for financial desktops applications.
Opinions expressed by DZone contributors are their own.
Comments