Dienstag, 21. Mai 2013

Developing a NumberSpinner Control

Hey all. It's been a long time, when I published my last "real" control (DatePicker). It's about time to present you a new one, right?

I started to develop a NumberSpinner control based on the official UI specification, because a) I needed such a control and b) I really love to develop controls :-).

I also looked into another one, but wasn't quite happy with it because I couldn't really get it to work stable and it was missing some basic requirements like "If users keep one of the buttons pressed, the value in the field increases or decreases automatically until users release the button" or different button layout.

I did not implement all the requested features of the specification (like Date and Time), but it comes already close.

(Sidenote: I am also currently getting to know Mercurial, so that I will push my work into a public repository sooner or later. For now, I will post my code here.)

So here's how it looks:



The Control class

Because the control is very similar to a JavaFX TextField it is quite obvious to derive the NumberSpinner control from TextField. You also want to have all the properties and methods which a TextField already has, especially:

  • selectAll()
  • editableProperty()
  • alignmentProperty()
  • prompTextProperty()

I expanded the control by the following methods and properties:

  • ObjectProperty<Number> value, which holds the current number
  • ObjectProperty<Number> maxValue, which defines the maximum value
  • ObjectProperty<Number> minValue, which defines the minimum value
  • ObjectProperty<Number> stepWidth, which defines the step by which the value is changed
  • ObjectProperty<NumberStringConverter> numberStringConverter, to convert the Number from and to String
  • ObjectProperty<HPos> hAlignment, which defines the horizontal position of the text field in relation to the buttons
  • void increment(), which increments the value
  • void decrement(), which decrements the value
and added some validation.

Here's the complete class:


import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.HPos;
import javafx.scene.Scene;
import javafx.scene.control.TextField;
import javafx.util.StringConverter;
import javafx.util.converter.NumberStringConverter;

import java.math.BigDecimal;

/**
 * @author Christian Schudt
 */
public class NumberSpinner extends TextField {

    /**
     * The numeric value.
     */
    private final ObjectProperty<Number> value = new SimpleObjectProperty<Number>(this, "value") {
        @Override
        protected void invalidated() {
            if (!isBound() && value.get() != null) {
                if (maxValue.get() != null && value.get().doubleValue() > maxValue.get().doubleValue()) {
                    set(maxValue.get());
                }
                if (minValue.get() != null && value.get().doubleValue() < minValue.get().doubleValue()) {
                    set(minValue.get());
                }
            }
        }
    };

    /**
     * The max value.
     */
    private final ObjectProperty<Number> maxValue = new SimpleObjectProperty<Number>(this, "maxValue") {
        @Override
        protected void invalidated() {
            if (maxValue.get() != null) {
                if (minValue.get() != null && maxValue.get().doubleValue() < minValue.get().doubleValue()) {
                    throw new IllegalArgumentException("maxValue must not be greater than minValue");
                }
                if (value.get() != null && value.get().doubleValue() > maxValue.get().doubleValue()) {
                    value.set(maxValue.get());
                }
            }
        }
    };

    /**
     * The min value.
     */
    private final ObjectProperty<Number> minValue = new SimpleObjectProperty<Number>(this, "minValue") {
        @Override
        protected void invalidated() {
            if (minValue.get() != null) {
                if (maxValue.get() != null && maxValue.get().doubleValue() < minValue.get().doubleValue()) {
                    throw new IllegalArgumentException("minValue must not be smaller than maxValue");
                }
                if (value.get() != null && value.get().doubleValue() < minValue.get().doubleValue()) {
                    value.set(minValue.get());
                }
            }
        }
    };

    /**
     * The step width.
     */
    private final ObjectProperty<Number> stepWidth = new SimpleObjectProperty<Number>(this, "stepWidth", 1);

    /**
     * The number format.
     */
    private final ObjectProperty<NumberStringConverter> numberStringConverter = new SimpleObjectProperty<>(this, "numberFormatter", new NumberStringConverter());

    /**
     * The horizontal alignment of the text field.
     */
    private ObjectProperty<HPos> hAlignment = new SimpleObjectProperty<>(this, "hAlignment", HPos.LEFT);


    /**
     * Default constructor. It aligns the text right and set a default {@linkplain StringConverter StringConverter}.
     */
    public NumberSpinner() {
        getStyleClass().add("number-spinner");
        setFocusTraversable(false);

        // Workaround this bug: https://forums.oracle.com/forums/thread.jspa?forumID=1385&threadID=2430102
        sceneProperty().addListener(new ChangeListener<Scene>() {
            @Override
            public void changed(ObservableValue<? extends Scene> observableValue, Scene scene, Scene scene1) {
                if (scene1 != null) {
                    scene1.getStylesheets().add(getClass().getResource("NumberSpinner.css").toExternalForm());
                }
            }
        });
    }

    /**
     * Creates the number spinner with a min and max value.
     *
     * @param minValue The min value.
     * @param maxValue The max value.
     */
    public NumberSpinner(final Number minValue, final Number maxValue) {
        this();
        this.minValue.set(minValue);
        this.maxValue.set(maxValue);
    }

    /**
     * The value property. The value can also be null or {@link Double#NaN} or other non-finite values, in order to empty the text field.
     *
     * @return The value property.
     * @see #getValue()
     * @see #setValue(Number)
     */
    public final ObjectProperty<Number> valueProperty() {
        return value;
    }

    /**
     * Gets the value.
     *
     * @return The value.
     * @see #valueProperty()
     */
    public final Number getValue() {
        return value.get();
    }

    /**
     * Sets the value.
     *
     * @param value The value.
     * @see #valueProperty()
     */
    public final void setValue(final Number value) {
        this.value.set(value);
    }

    /**
     * The max value property.
     *
     * @return The property.
     * @see #getMaxValue()
     * @see #setMaxValue(Number)
     */
    public final ObjectProperty<Number> maxValueProperty() {
        return maxValue;
    }

    /**
     * Gets the max value.
     *
     * @return The max value.
     * @see #maxValueProperty()
     */
    public final Number getMaxValue() {
        return maxValue.get();
    }

    /**
     * Sets the max value.
     *
     * @param maxValue The max value.
     * @throws IllegalArgumentException If the max value is smaller than the min value.
     * @see #maxValueProperty()
     */
    public final void setMaxValue(final Number maxValue) {
        this.maxValue.set(maxValue);
    }

    /**
     * The min value property.
     *
     * @return The property.
     * @see #getMinValue()
     * @see #setMinValue(Number)
     */
    public final ObjectProperty<Number> minValueProperty() {
        return minValue;
    }

    /**
     * Gets the min value.
     *
     * @return The min value.
     * @see #minValueProperty()
     */
    public final Number getMinValue() {
        return minValue.get();
    }

    /**
     * Sets the min value.
     *
     * @param minValue The min value.
     * @throws IllegalArgumentException If the min value is greater than the max value.
     * @see #minValueProperty()
     */
    public final void setMinValue(final Number minValue) {
        this.minValue.set(minValue);
    }

    /**
     * The step width property.
     * Specifies the interval by which the value is incremented or decremented.
     *
     * @return The step width property.
     * @see #getStepWidth()
     * @see #setStepWidth(Number)
     */
    public final ObjectProperty<Number> stepWidthProperty() {
        return stepWidth;
    }

    /**
     * Gets the step width.
     *
     * @return The step width.
     * @see #stepWidthProperty()
     */
    public final Number getStepWidth() {
        return this.stepWidth.get();
    }

    /**
     * Sets the step width.
     *
     * @param stepWidth The step width.
     * @see #stepWidthProperty()
     */
    public final void setStepWidth(final Number stepWidth) {
        this.stepWidth.setValue(stepWidth);
    }

    /**
     * The number string converter property.
     *
     * @return The number string converter property.
     * @see #getNumberStringConverter()
     * @see #setNumberStringConverter(javafx.util.converter.NumberStringConverter)
     */
    public final ObjectProperty<NumberStringConverter> numberStringConverterProperty() {
        return numberStringConverter;
    }

    /**
     * Gets the number string converter.
     *
     * @return The number string converter.
     * @see #numberStringConverterProperty()
     */
    public final NumberStringConverter getNumberStringConverter() {
        return numberStringConverter.get();
    }

    /**
     * Sets the number format.
     *
     * @param numberStringConverter The number format.
     * @see #numberStringConverterProperty()
     */
    public final void setNumberStringConverter(final NumberStringConverter numberStringConverter) {
        this.numberStringConverter.set(numberStringConverter);
    }

    /**
     * The horizontal alignment of the text field.
     * It can either be aligned left or right to the buttons or in between them (center).
     *
     * @return The property.
     * @see #getHAlignment()
     * @see #setHAlignment(javafx.geometry.HPos)
     */
    public ObjectProperty<HPos> hAlignmentProperty() {
        return hAlignment;
    }

    /**
     * Gets the horizontal alignment of the text field.
     *
     * @return The alignment.
     * @see #hAlignmentProperty()
     */
    public HPos getHAlignment() {
        return hAlignment.get();
    }

    /**
     * The horizontal alignment of the text field.
     *
     * @param hAlignment The alignment.
     * @see #hAlignmentProperty()
     */
    public void setHAlignment(final HPos hAlignment) {
        this.hAlignment.set(hAlignment);
    }

    /**
     * Increments the value by the value specified by {@link #stepWidthProperty()}.
     */
    public void increment() {
        if (getStepWidth() != null && isFinite(getStepWidth().doubleValue())) {
            if (getValue() != null && isFinite(getValue().doubleValue())) {
                setValue(BigDecimal.valueOf(getValue().doubleValue()).add(BigDecimal.valueOf(getStepWidth().doubleValue())));
            } else {
                if (getMinValue() != null && isFinite(getMinValue().doubleValue())) {
                    setValue(BigDecimal.valueOf(getMinValue().doubleValue()).add(BigDecimal.valueOf(getStepWidth().doubleValue())));
                } else {
                    setValue(BigDecimal.valueOf(getStepWidth().doubleValue()));
                }
            }
        }
    }

    /**
     * Decrements the value by the value specified by {@link #stepWidthProperty()}.
     */
    public void decrement() {
        if (getStepWidth() != null && isFinite(getStepWidth().doubleValue())) {
            if (getValue() != null && isFinite(getValue().doubleValue())) {
                setValue(BigDecimal.valueOf(getValue().doubleValue()).subtract(BigDecimal.valueOf(getStepWidth().doubleValue())));
            } else {
                if (getMaxValue() != null && isFinite(getMaxValue().doubleValue())) {
                    setValue(BigDecimal.valueOf(getMaxValue().doubleValue()).subtract(BigDecimal.valueOf(getStepWidth().doubleValue())));
                } else {
                    setValue(BigDecimal.valueOf(getStepWidth().doubleValue()).multiply(new BigDecimal(-1)));
                }
            }
        }
    }

    /**
     * Utility method for Java 7. (Double.isFinite(double) is only available for Java 8)
     *
     * @param value The value.
     * @return True, if the double value is finite.
     */
    private boolean isFinite(double value) {
        return !Double.isInfinite(value) && !Double.isNaN(value);
    }

    @Override
    protected String getUserAgentStylesheet() {
        return getClass().getResource("NumberSpinner.css").toExternalForm();
    }
}


The Skin class

(Note that I didn't make a behavior class nor did I derive from SkinBase, since both are not public API).

I don't want to go into great detail here, but just will post the code, which is hopefully self-explanatory.

One important thing to note, is that you should really unbind everything and remove all listeners from the control in the dispose() method. Otherwise there could be memory leaks, if for some reason the skin changes.

import javafx.application.Platform;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.HPos;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.IndexRange;
import javafx.scene.control.Skin;
import javafx.scene.control.TextField;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;

import java.math.BigDecimal;

/**
 * The default skin for the NumberSpinner control.
 *
 * @author Christian Schudt
 */
public class NumberSpinnerSkin extends StackPane implements Skin<NumberSpinner> {

    private static final String TOP_LEFT = "top-left";

    private static final String BOTTOM_LEFT = "bottom-left";

    private static final String LEFT = "left";

    private static final String RIGHT = "right";

    private static final String BOTTOM_RIGHT = "bottom-right";

    private static final String TOP_RIGHT = "top-right";

    private static final String CENTER = "center";

    private final String[] cssClasses = {TOP_LEFT, TOP_RIGHT, LEFT, CENTER, RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT};

    private final TextField textField;

    private final NumberSpinner numberSpinner;

    private final ChangeListener<IndexRange> changeListenerSelection;

    private final ChangeListener<Number> changeListenerCaretPosition;

    private final ChangeListener<Number> changeListenerValue;

    private final ChangeListener<HPos> changeListenerHAlignment;

    private final Button btnIncrement;

    private final Button btnDecrement;

    private final Region arrowIncrement;

    private final Region arrowDecrement;

    /**
     * @param numberSpinner The control.
     */
    public NumberSpinnerSkin(final NumberSpinner numberSpinner) {

        this.numberSpinner = numberSpinner;

        minHeightProperty().bind(numberSpinner.minHeightProperty());

        // The TextField
        textField = new TextField();
        textField.focusedProperty().addListener(new ChangeListener<Boolean>() {
            @Override
            public void changed(ObservableValue<? extends Boolean> observableValue, Boolean aBoolean, Boolean aBoolean1) {
                if (textField.isEditable() && aBoolean1) {
                    Platform.runLater(new Runnable() {
                        @Override
                        public void run() {
                            textField.selectAll();
                        }
                    });
                }

                // setStyle explicitly is a workaround for this JavaFX 2.2 bug:
                // https://javafx-jira.kenai.com/browse/RT-23085
                String javafxVersion = System.getProperty("javafx.runtime.version");
                if (textField.isFocused()) {
                    getStyleClass().add("number-spinner-focused");
                    if (javafxVersion.startsWith("2.2")) {
                        setStyle("-fx-background-color: -fx-focus-color, -fx-text-box-border, -fx-control-inner-background;\n" +
                                "    -fx-background-insets: -0.4, 1, 2;\n" +
                                "    -fx-background-radius: 3.4, 2, 2");
                    }
                } else {
                    getStyleClass().remove("number-spinner-focused");
                    if (javafxVersion.startsWith("2.2")) {
                        setStyle("-fx-background-color: null;\n" +
                                "    -fx-background-insets: null;\n" +
                                "    -fx-background-radius: null");
                    }
                    parseText();
                    setText();
                }
            }
        });

        // Mimic bidirectional binding: Whenever the selection changes of either the control or the text field, propagate it to the other.
        // This ensures that the selectionProperty of both are in sync.
        changeListenerSelection = new ChangeListener<IndexRange>() {
            @Override
            public void changed(ObservableValue<? extends IndexRange> observableValue, IndexRange indexRange, IndexRange indexRange2) {
                textField.selectRange(indexRange2.getStart(), indexRange2.getEnd());
            }
        };
        numberSpinner.selectionProperty().addListener(changeListenerSelection);

        textField.selectionProperty().addListener(new ChangeListener<IndexRange>() {
            @Override
            public void changed(ObservableValue<? extends IndexRange> observableValue, IndexRange indexRange, IndexRange indexRange1) {
                numberSpinner.selectRange(indexRange1.getStart(), indexRange1.getEnd());
            }
        });

        // Mimic bidirectional binding: Whenever the caret position changes in either the control or the text field, propagate it to the other.
        // This ensures that both caretPositions are in sync.
        changeListenerCaretPosition = new ChangeListener<Number>() {
            @Override
            public void changed(ObservableValue<? extends Number> observableValue, Number number, Number number1) {
                textField.positionCaret(number1.intValue());
            }
        };
        numberSpinner.caretPositionProperty().addListener(changeListenerCaretPosition);

        textField.caretPositionProperty().addListener(new ChangeListener<Number>() {
            @Override
            public void changed(ObservableValue<? extends Number> observableValue, Number number, Number number1) {
                numberSpinner.positionCaret(number1.intValue());
            }
        });

        // Bind the control's properties to the text field.
        textField.minHeightProperty().bind(numberSpinner.minHeightProperty());
        textField.maxHeightProperty().bind(numberSpinner.maxHeightProperty());
        textField.textProperty().bindBidirectional(numberSpinner.textProperty());
        textField.alignmentProperty().bind(numberSpinner.alignmentProperty());
        textField.editableProperty().bind(numberSpinner.editableProperty());
        textField.prefColumnCountProperty().bind(numberSpinner.prefColumnCountProperty());
        textField.promptTextProperty().bind(numberSpinner.promptTextProperty());
        textField.onActionProperty().bind(numberSpinner.onActionProperty());
        textField.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() {
            @Override
            public void handle(KeyEvent keyEvent) {
                if (!keyEvent.isConsumed()) {
                    if (keyEvent.getCode().equals(KeyCode.UP)) {
                        btnIncrement.fire();
                        keyEvent.consume();
                    }
                    if (keyEvent.getCode().equals(KeyCode.DOWN)) {
                        btnDecrement.fire();
                        keyEvent.consume();
                    }
                }
            }
        });
        setText();

        changeListenerValue = new ChangeListener<Number>() {
            @Override
            public void changed(ObservableValue<? extends Number> observableValue, Number number, Number number2) {
                setText();
            }
        };
        numberSpinner.valueProperty().addListener(changeListenerValue);
        changeListenerHAlignment = new ChangeListener<HPos>() {
            @Override
            public void changed(ObservableValue<? extends HPos> observableValue, HPos hPos, HPos hPos1) {
                align(numberSpinner.getHAlignment());
            }
        };
        numberSpinner.hAlignmentProperty().addListener(changeListenerHAlignment);


        // The increment button.
        btnIncrement = new Button();
        btnIncrement.setFocusTraversable(false);
        btnIncrement.disableProperty().bind(new BooleanBinding() {
            {
                super.bind(numberSpinner.valueProperty(), numberSpinner.maxValueProperty());
            }

            @Override
            protected boolean computeValue() {

                return numberSpinner.valueProperty().get() != null && numberSpinner.maxValueProperty().get() != null && numberSpinner.valueProperty().get().doubleValue() >= numberSpinner.maxValueProperty().get().doubleValue();
            }
        });
        btnIncrement.setOnAction(new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent actionEvent) {
                parseText();
                numberSpinner.increment();
            }
        });
        arrowIncrement = createArrow();
        btnIncrement.setGraphic(arrowIncrement);

        btnIncrement.setMinHeight(0);
        ClickRepeater.install(btnIncrement);


        // The decrement button
        btnDecrement = new Button();
        btnDecrement.setFocusTraversable(false);
        btnDecrement.disableProperty().bind(new BooleanBinding() {
            {
                super.bind(numberSpinner.valueProperty(), numberSpinner.minValueProperty());
            }

            @Override
            protected boolean computeValue() {
                return numberSpinner.valueProperty().get() != null && numberSpinner.minValueProperty().get() != null && numberSpinner.valueProperty().get().doubleValue() <= numberSpinner.minValueProperty().get().doubleValue();
            }
        });
        btnDecrement.setOnAction(new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent actionEvent) {
                parseText();
                numberSpinner.decrement();
            }
        });
        arrowDecrement = createArrow();
        btnDecrement.setGraphic(arrowDecrement);
        btnDecrement.setMinHeight(0);
        ClickRepeater.install(btnDecrement);

        // Allow the buttons to grow vertically.
        VBox.setVgrow(btnIncrement, Priority.ALWAYS);
        VBox.setVgrow(btnDecrement, Priority.ALWAYS);

        // Allow the text field to allow horizontally.
        HBox.setHgrow(textField, Priority.ALWAYS);
        align(numberSpinner.getHAlignment());
    }

    /**
     * Creates an arrow for the buttons.
     *
     * @return The arrow.
     */
    private Region createArrow() {
        Region arrow = new Region();
        arrow.setMaxSize(8, 8);
        arrow.getStyleClass().add("arrow");
        return arrow;
    }

    /**
     * Aligns the text field relative to the buttons.
     *
     * @param hPos The horizontal position of the text field.
     */
    private void align(HPos hPos) {
        getChildren().clear();
        clearStyles();
        btnIncrement.maxHeightProperty().unbind();
        btnDecrement.maxHeightProperty().unbind();
        switch (hPos) {
            case LEFT:
            case RIGHT:
                alignLeftOrRight(hPos);
                break;
            case CENTER:
                alignCenter();
                break;
        }
    }

    /**
     * Aligns the text field in between both buttons.
     */
    private void alignCenter() {
        btnIncrement.getStyleClass().add(RIGHT);
        btnDecrement.getStyleClass().add(LEFT);
        textField.getStyleClass().add(CENTER);

        btnIncrement.maxHeightProperty().setValue(Double.MAX_VALUE);
        btnDecrement.maxHeightProperty().setValue(Double.MAX_VALUE);

        arrowIncrement.setRotate(-90);
        arrowDecrement.setRotate(90);

        getChildren().add(HBoxBuilder.create().children(btnDecrement, textField, btnIncrement).build());
    }

    /**
     * Aligns the buttons either left or right.
     *
     * @param hPos The HPos, either {@link HPos#LEFT} or {@link HPos#RIGHT}.
     */
    private void alignLeftOrRight(HPos hPos) {
        // The box which aligns the two buttons vertically.
        final VBox buttonBox = new VBox();
        HBox hBox = new HBox();
        switch (hPos) {
            case RIGHT:
                btnIncrement.getStyleClass().add(TOP_LEFT);
                btnDecrement.getStyleClass().add(BOTTOM_LEFT);
                textField.getStyleClass().add(RIGHT);
                hBox.getChildren().addAll(buttonBox, textField);
                break;
            case LEFT:
                btnIncrement.getStyleClass().add(TOP_RIGHT);
                btnDecrement.getStyleClass().add(BOTTOM_RIGHT);
                textField.getStyleClass().add(LEFT);
                hBox.getChildren().addAll(textField, buttonBox);
                break;
            case CENTER:
                break;
        }

        btnIncrement.maxHeightProperty().bind(textField.heightProperty().divide(2.0));
        // Subtract 0.5 to ensure it looks fine if height is odd.
        btnDecrement.maxHeightProperty().bind(textField.heightProperty().divide(2.0).subtract(0.5));
        arrowIncrement.setRotate(180);
        arrowDecrement.setRotate(0);

        buttonBox.getChildren().addAll(btnIncrement, btnDecrement);
        getChildren().add(hBox);
    }

    /**
     * Clears all styles on all controls.
     */
    private void clearStyles() {
        btnIncrement.getStyleClass().removeAll(cssClasses);
        btnDecrement.getStyleClass().removeAll(cssClasses);
        textField.getStyleClass().removeAll(cssClasses);
    }

    /**
     * Parses the text and sets the {@linkplain NumberSpinner#valueProperty() value} accordingly.
     * If parsing fails, the value is set to null.
     */
    private void parseText() {
        if (textField.getText() != null) {
            try {
                numberSpinner.setValue(BigDecimal.valueOf(numberSpinner.getNumberStringConverter().fromString(textField.getText()).doubleValue()));
            } catch (Exception e) {
                numberSpinner.setValue(null);
            }

        } else {
            numberSpinner.setValue(null);
        }
    }

    /**
     * Sets the formatted value to the text field.
     */
    private void setText() {
        if (numberSpinner.getValue() != null && !Double.isInfinite((numberSpinner.getValue().doubleValue())) && !Double.isNaN(numberSpinner.getValue().doubleValue())) {
            textField.setText(numberSpinner.getNumberStringConverter().toString(numberSpinner.getValue()));
        } else {
            textField.setText(null);
        }
    }

    @Override
    public NumberSpinner getSkinnable() {
        return numberSpinner;
    }

    @Override
    public Node getNode() {
        return this;
    }

    @Override
    public void dispose() {

        // Unbind everything and remove listeners, in order to avoid memory leaks.
        minHeightProperty().unbind();

        textField.minHeightProperty().unbind();
        textField.maxHeightProperty().unbind();
        textField.textProperty().unbindBidirectional(numberSpinner.textProperty());
        textField.alignmentProperty().unbind();
        textField.editableProperty().unbind();
        textField.prefColumnCountProperty().unbind();
        textField.promptTextProperty().unbind();
        textField.onActionProperty().unbind();

        numberSpinner.selectionProperty().removeListener(changeListenerSelection);
        numberSpinner.caretPositionProperty().removeListener(changeListenerCaretPosition);
        numberSpinner.valueProperty().removeListener(changeListenerValue);
        numberSpinner.hAlignmentProperty().removeListener(changeListenerHAlignment);
        btnIncrement.disableProperty().unbind();
        btnDecrement.disableProperty().unbind();

    }
}


The ClickRepeater class

Now that's an interesting part.
If you want to implement the requirement "If users keep one of the buttons pressed, the value in the field increases or decreases automatically until users release the button" from the specification, you need a way to make the buttons fire periodically while they are pressed.

Buttons have a state called "armed", which is the state before the button is actually fired, that is while it is pressed.

So, while a button is armed, I set up an endless PauseTransition, which periodically fires the button's ActionEvent after an initial pause of 500ms. This mimics the behavior of the KeyEvent, which is also constantly fired, while a key is pressed.

Any button can get this click repeating behavior, by using

ClickRepeater.install(button);
Here's the class:
public class ClickRepeater {

    /**
     * This is the initial pause until the button is fired for the first time. This is 500 ms as it the same value used by key events.
     */
    private final PauseTransition initialPause = new PauseTransition(Duration.millis(500));

    /**
     * This is for all the following intervals, after the first one. 80 ms is also used by key events.
     */
    private final PauseTransition pauseTransition = new PauseTransition();

    /**
     * This transition combines the first two.
     */
    private final SequentialTransition sequentialTransition = new SequentialTransition(initialPause, pauseTransition);

    /**
     * Store the change listener, so that it can be removed in the {@link #uninstall(javafx.scene.control.ButtonBase)} method.
     */
    private final ChangeListener<Boolean> changeListener;

    /**
     * Private constructor.
     *
     * @param buttonBase The button.
     */
    private ClickRepeater(final ButtonBase buttonBase, final Duration interval) {
        initialPause.setOnFinished(new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent actionEvent) {
                // Fire the button the first time after the initial pause.
                buttonBase.fire();
            }
        });

        pauseTransition.setDuration(interval);
        pauseTransition.setCycleCount(Animation.INDEFINITE);
        pauseTransition.currentTimeProperty().addListener(new ChangeListener<Duration>() {
            @Override
            public void changed(ObservableValue<? extends Duration> observableValue, Duration duration, Duration duration2) {
                // Every time a new cycle starts, fire the button.
                if (duration.greaterThan(duration2)) {
                    buttonBase.fire();
                }
            }
        });
        changeListener = new ChangeListener<Boolean>() {
            @Override
            public void changed(ObservableValue<? extends Boolean> observableValue, Boolean aBoolean, Boolean aBoolean2) {
                if (aBoolean2) {
                    // If the button gets armed, start the animation.
                    sequentialTransition.playFromStart();
                } else {
                    // Stop the animation, if the button is no longer armed.
                    sequentialTransition.stop();
                }
            }
        };
        buttonBase.armedProperty().addListener(changeListener);
    }

    /**
     * I
     * nstalls the click repeating behavior for a {@link ButtonBase}.
     * The default click interval is 80ms.
     *
     * @param buttonBase The button.
     */
    public static void install(ButtonBase buttonBase) {
        install(buttonBase, Duration.millis(80));
    }

    /**
     * I
     * nstalls the click repeating behavior for a {@link ButtonBase} and also allows to set a click interval.
     *
     * @param buttonBase The button.
     * @param interval   The click interval.
     */
    public static void install(ButtonBase buttonBase, Duration interval) {
        // Uninstall any previous behavior.
        uninstall(buttonBase);

        // Initializes a new ClickRepeater
        if (!buttonBase.getProperties().containsKey(ClickRepeater.class)) {
            // Store the ClickRepeater in the button's properties.
            // If the button will get GCed, so will its ClickRepeater.
            buttonBase.getProperties().put(ClickRepeater.class, new ClickRepeater(buttonBase, interval));
        }
    }

    /**
     * U
     * ninstalls the click repeater behavior from a button.
     *
     * @param buttonBase The button.
     */
    public static void uninstall(ButtonBase buttonBase) {
        if (buttonBase.getProperties().containsKey(ClickRepeater.class) && buttonBase.getProperties().get(ClickRepeater.class) instanceof ClickRepeater) {
            ClickRepeater clickRepeater = (ClickRepeater) buttonBase.getProperties().remove(ClickRepeater.class);
            buttonBase.armedProperty().removeListener(clickRepeater.changeListener);
        }
    }
}


The CSS file

Finally, to round things up, here's the CSS file for the control. It does the correct button radius for each possible button location, removes the focus style from the skin's text field and sets the focus style on the whole control instead.

.number-spinner {
    -fx-skin: "NumberSpinnerSkin";
    -fx-padding: 0 0 0 0;
    -fx-cursor: default; /* Override the cursor since the "text" cursor is inherited from text field. But we want the default cursor over the buttons. */
}

/* A left directed arrow */
.number-spinner .arrow {
    -fx-padding: 3 3.5 3 3.5;
    -fx-shape: "M 100 100 L 300 100 L 200 300 z";
    -fx-background-color: #000000;
}


/* Make one-side rounded buttons */
.number-spinner .button.top-right {
    -fx-background-insets: 1 1 0 0, 1 1 0 0, 2 2 1 1, 3 3 2 2;
    -fx-background-radius: 0 3 0 0, 0 3 0 0, 0 2 0 0, 0 1 0 0;
}
.number-spinner .button.bottom-right {
    -fx-background-insets: 0 1 1 0, 0 1 1 0, 1 2 2 1, 2 3 3 2;
    -fx-background-radius: 0 0 3 0, 0 0 3 0, 0 0 1 0, 0 0 1 0;
}
.number-spinner .button.top-left {
    -fx-background-insets: 1 0 0 1, 1 0 0 1, 2 1 1 2, 3 2 2 3;
    -fx-background-radius: 3 0 0 0, 3 0 0 0, 2 0 0 0, 1 0 0 0;
}
.number-spinner .button.bottom-left {
    -fx-background-insets: 0 0 1 1, 0 0 1 1, 1 1 2 2, 2 2 3 3;
    -fx-background-radius: 0 0 0 3, 0 0 0 3, 0 0 0 2, 0 0 0 1;
}
.number-spinner .button.left {
    -fx-background-insets: 1 0 1 1, 1 0 1 1, 2 1 2 2, 3 2 3 3;
    -fx-background-radius: 3 0 0 3, 3 0 0 3, 2 0 0 2, 1 0 0 1;
}
.number-spinner .button.right {
    -fx-background-insets: 1 1 1 0, 1 1 1 0, 2 2 2 1, 3 3 3 2;
    -fx-background-radius: 0 3 3 0, 0 3 3 0, 0 2 2 0, 0 1 1 0;
}

/* Only make one side of the text field with round borders. */
.number-spinner .text-field.left {
    -fx-background-radius: 3 0 0 3, 2 0 0 2, 2 0 0 2;
}
.number-spinner .text-field.right {
    -fx-background-radius: 0 3 3 0, 0 2 2 0, 0 2 2 0;
}
/* Don't use round borders if the text field is in the center */
.number-spinner .text-field.center {
    -fx-background-radius: 0 0 0 0, 0 0 0 0, 0 0 0 0;
}

/* Make the focused text field look like an unfocused text field, since the whole spinner is already focused. */
.number-spinner .text-field:focused {
    -fx-background-color: -fx-shadow-highlight-color, -fx-text-box-border, -fx-control-inner-background;
}

/* Move the insets accordingly, so that there is still a little border visible between buttons and text field. */
.number-spinner .text-field.left:focused {
    -fx-background-insets: 2 0 2 2, 2 1 2 2, 2;
}
.number-spinner .text-field.right:focused {
    -fx-background-insets: 2 2 2 0, 2 2 2 1, 2;
}
.number-spinner .text-field.center:focused {
    -fx-background-insets: 2 0 2 0, 2 1 2 1, 2;
}

/* Apply the focus style to the whole control */
.number-spinner-focused {
    -fx-background-color: -fx-focus-color, -fx-text-box-border, -fx-control-inner-background;
    -fx-background-insets: -0.4, 1, 2;
    -fx-background-radius: 3.4, 2, 2;
}
Have fun and see you soon!

Montag, 13. Mai 2013

Restricting user input on a TextField

Have you ever came to the problem to limit the input in a text field to a maximal length?

Or to restrict the input to specific characters, like allowing only numbers?

Of course there are already some solutions around, but most of them suffer from the problem, that they still allow invalid user input by pasting invalid characters with Ctrl + V or via the context menu, because they only check key events or use some other technique like overriding replaceText.

Here's a simple class that I want to share with you, which let you restrict the input to a regular expression class and also let you set a maximal length, just like in HTML, no matter, if the user pasted or typed it into the text field.

Basically it just listens to text changes and if it is too long or does not match it is either truncated or the old text is set instead.

By the way: the property names (maxLength, restrict) are burrowed from HTML and Adobe Flex respectively.
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.control.TextField;

/**
 * A text field, which restricts the user's input.
 * <p>
 * The restriction can either be a maximal number of characters which the user is allowed to input
 * or a regular expression class, which contains allowed characters.
 * </p>
 * <p/>
 * <b>Sample, which restricts the input to maximal 10 numeric characters</b>:
 * <pre>
 * {@code
 * RestrictiveTextField textField = new RestrictiveTextField();
 * textField.setMaxLength(10);
 * textField.setRestrict("[0-9]");
 * }
 * </pre>
 *
 * @author Christian Schudt
 */
public class RestrictiveTextField extends TextField {

    private IntegerProperty maxLength = new SimpleIntegerProperty(this, "maxLength", -1);
    private StringProperty restrict = new SimpleStringProperty(this, "restrict");

    public RestrictiveTextField() {

        textProperty().addListener(new ChangeListener<String>() {

            private boolean ignore;

            @Override
            public void changed(ObservableValue<? extends String> observableValue, String s, String s1) {
                if (ignore || s1 == null)
                    return;
                if (maxLength.get() > -1 && s1.length() > maxLength.get()) {
                    ignore = true;
                    setText(s1.substring(0, maxLength.get()));
                    ignore = false;
                }

                if (restrict.get() != null && !restrict.get().equals("") && !s1.matches(restrict.get() + "*")) {
                    ignore = true;
                    setText(s);
                    ignore = false;
                }
            }
        });
    }

    /**
     * The max length property.
     *
     * @return The max length property.
     */
    public IntegerProperty maxLengthProperty() {
        return maxLength;
    }

    /**
     * Gets the max length of the text field.
     *
     * @return The max length.
     */
    public int getMaxLength() {
        return maxLength.get();
    }

    /**
     * Sets the max length of the text field.
     *
     * @param maxLength The max length.
     */
    public void setMaxLength(int maxLength) {
        this.maxLength.set(maxLength);
    }

    /**
     * The restrict property.
     *
     * @return The restrict property.
     */
    public StringProperty restrictProperty() {
        return restrict;
    }

    /**
     * Gets a regular expression character class which restricts the user input.<br/>
     *
     * @return The regular expression.
     * @see #getRestrict()
     */
    public String getRestrict() {
        return restrict.get();
    }

    /**
     * Sets a regular expression character class which restricts the user input.<br/>
     * E.g. [0-9] only allows numeric values.
     *
     * @param restrict The regular expression.
     */
    public void setRestrict(String restrict) {
        this.restrict.set(restrict);
    }
}