Mittwoch, 18. Dezember 2013

Tetris with JavaFX

I've had some spare time recently and I finally came to finish a little project, which I've started a few months ago: Tetris developed with JavaFX.

I don't want to go into detail here, but instead only point to the sources, which I published here.

If you want to try it out, you have to either compile it yourself and start the App.java class, which contains the main class or you can just execute the Tetris.jar.

For the very impatient among you, I've prepared a video. Have fun.

Freitag, 13. September 2013

JavaFX Charts: Display Date values on a DateAxis

As Pedro Duque Vieira recently mentioned in his blogpost about his DateAxis, Diego Cirujano-Cuesta and me developed a DateAxis, which uses actual Date objects.
I don't want to hesitate to introduce it to you and spend some words on how it works.

You can find the source code here.

How it looks like


How to use it

  ObservableList<XYChart.Series<Date, Number>> series = FXCollections.observableArrayList();
 
  ObservableList<XYChart.Data<Date, Number>> series1Data = FXCollections.observableArrayList();
  series1Data.add(new XYChart.Data<Date, Number>(new GregorianCalendar(2012, 11, 15).getTime(), 2));
  series1Data.add(new XYChart.Data<Date, Number>(new GregorianCalendar(2014, 5, 3).getTime(), 4));
 
  ObservableList<XYChart.Data<Date, Number>> series2Data = FXCollections.observableArrayList();
  series2Data.add(new XYChart.Data<Date, Number>(new GregorianCalendar(2014, 0, 13).getTime(), 8));
  series2Data.add(new XYChart.Data<Date, Number>(new GregorianCalendar(2014, 7, 27).getTime(), 4));
 
  series.add(new XYChart.Series<>("Series1", series1Data));
  series.add(new XYChart.Series<>("Series2", series2Data));
 
  NumberAxis numberAxis = new NumberAxis();
  DateAxis dateAxis = new DateAxis();
  LineChart<Date, Number> lineChart = new LineChart<>(dateAxis, numberAxis, series);

As you can see, it allows you to define XYChart.Data with Date objects.

Basically that's all you need to do.
As with other axes, you can either define a lower and upper bound or you can chose to let the axis find out its bounds by itself. Just set autorange to true.

Furthermore you can also use your own tick label formatter or enable or disable animation.

Some words on the implementation

The first important part to implement was the getDisplayPosition(Date) method in order to get the display position for a given date. To do this, we get the percentage value, where the date is "located" between the lower and upper bound. Imagine the lower bound as 0 and the upper bound as 1 and we get a value between 0 and 1. Then we have to multiply this value with the length of the axis and we are basically done.

Here's my implementation:

@Override
public double getDisplayPosition(Date date) {
    final double length = getSide().isHorizontal() ? getWidth() : getHeight();

    // Get the difference between the max and min date.
    double diff = currentUpperBound.get() - currentLowerBound.get();

    // Get the actual range of the visible area.
    // The minimal date should start at the zero position, that's why we subtract it.
    double range = length - getZeroPosition();

    // Then get the difference from the actual date to the min date and divide it by the total difference.
    // We get a value between 0 and 1, if the date is within the min and max date.
    double d = (date.getTime() - currentLowerBound.get()) / diff;

    // Multiply this percent value with the range and add the zero offset.
    if (getSide().isVertical()) {
        return getHeight() - d * range + getZeroPosition();
    } else {
        return d * range + getZeroPosition();
    }
}

The next important method to implement was calculateTickValues(double v, Object range).

Uhh, this was a tricky one ;-).

Depending on the range, we want to display different values. E.g. If we display a large range, ranging over several years, we want each tick label to represent one year, where as if the range ranges only over a few days, we want to display the days, and if it ranges over half a day only, we want to display hours.

So, I defined intervals, which define in which intervals tick labels will be shown, e.g. years, months, 3 months, weeks, days, ... and so on. Then we find out, which interval suites best for the current range. We do this by keep adding one interval to the lower bound until we have reached the upper bound or until there are too many tick labels. Then we try it with the next smaller interval until we found good one.

As last step, we want to make sure, that years always start on January 1st and months always start on the first date of the month and so on.

As a end result we have a nice list of Dates, which represent the tick marks. In fact, it is even a little bit trickier, but if you're really interested in the details, have a look at the code ;-).


Last important method is the getTickMarkLabel(Date date), which converts a date to a String representation.

It just guesses what might be the best representation depending on the range / interval, which was used. E.g. if only years are displayed the date values are formatted only to display the year, where as smaller ranges are displayed with days or hours.


If you are in need of a DateAxis, give it a try and I hope you like it!

Mittwoch, 21. August 2013

Beef up your animations with easing functions

If you have ever worked with JavaScript frameworks like jQuery or MooTools you probably also stumbled over the pletora of easing functions which are used for animation.

If you still don't know what I am talking about..., I am talking about the popular easing functions developed by Robert Penner, which have since been implemented in many languages and frameworks.

Time to have these fantastic functions around in JavaFX!

One thing, all easing functions have in common is the "mode". They either ease in, ease out or do both.

If you just take the result of a function f(x) directly, it is easing in.

x is the normalized time between 0 and 1 and the function should return 0 for x = 0 and 1 for x = 1, so that your animation starts and ends at the desired value.

If you want to reverse this function into the "easeOut" mode, the formular for this is simply:

1 - f(1-x)

And the third option is to have the function at the beginning and the end of the animation, which is usually referred to as "easeInOut".

The formular for this is slightly more complex:
if (x <= 0.5) {
   return f(2 * x) / 2;
} else {
   return (2 - f(2 * (1 - x))) / 2;
}

JavaFX already gives you Interpolator.EASE_IN, Interpolator.EASE_OUT and Interpolator.EASE_BOTH, which give you an easing behavior similar to a quadratic function.

But if you want to write your own easing functions, you basically have to extend javafx.animation.Interpolator.

What we should do first, is to write a base interpolator which does all the math for the three different easing modes and later let all other functions derive from that interpolator.

Here's the abstract base class I wrote, which does the basic easing stuff:

public abstract class EasingInterpolator extends Interpolator {

    /**
     * The easing mode.
     */
    private ObjectProperty<EasingMode> easingMode = new SimpleObjectProperty<>(EasingMode.EASE_OUT);

    /**
     * Constructs the interpolator with a specific easing mode.
     *
     * @param easingMode The easing mode.
     */
    public EasingInterpolator(EasingMode easingMode) {
        this.easingMode.set(easingMode);
    }

    /**
     * The easing mode property.
     *
     * @return The property.
     * @see #getEasingMode()
     * @see #setEasingMode(EasingMode)
     */
    public ObjectProperty<EasingMode> easingModeProperty() {
        return easingMode;
    }

    /**
     * Gets the easing mode.
     *
     * @return The easing mode.
     * @see #easingModeProperty()
     */
    public EasingMode getEasingMode() {
        return easingMode.get();
    }

    /**
     * Sets the easing mode.
     *
     * @param easingMode The easing mode.
     * @see #easingModeProperty()
     */
    public void setEasingMode(EasingMode easingMode) {
        this.easingMode.set(easingMode);
    }

    /**
     * Defines the base curve for the interpolator.
     * The base curve is then transformed into an easing-in, easing-out easing-both curve.
     *
     * @param v The normalized value/time/progress of the interpolation (between 0 and 1).
     * @return The resulting value of the function, should return a value between 0 and 1.
     * @see Interpolator#curve(double)
     */
    protected abstract double baseCurve(final double v);

    /**
     * Curves the function depending on the easing mode.
     *
     * @param v The normalized value (between 0 and 1).
     * @return The resulting value of the function.
     */
    @Override
    protected final double curve(final double v) {
        switch (easingMode.get()) {
            case EASE_IN:
                return baseCurve(v);
            case EASE_OUT:
                return 1 - baseCurve(1 - v);
            case EASE_BOTH:
                if (v <= 0.5) {
                    return baseCurve(2 * v) / 2;
                } else {
                    return (2 - baseCurve(2 * (1 - v))) / 2;
                }

        }
        return baseCurve(v);
    }
}

Then let the actual implementation for each easing function derive from that base class.

For simplicity, here's only the quadratic interpolator. All other implementations, including the more complex ones, like elastic, bounce and back interpolators can be found in my repository.

public class QuadraticInterpolator extends EasingInterpolator {

    /**
     * Default constructor. Initializes the interpolator with ease out mode.
     */
    public QuadraticInterpolator() {
        this(EasingMode.EASE_OUT);
    }

    /**
     * Constructs the interpolator with a specific easing mode.
     *
     * @param easingMode The easing mode.
     */
    public QuadraticInterpolator(EasingMode easingMode) {
        super(easingMode);
    }

    @Override
    protected double baseCurve(double v) {
        return Math.pow(v, 2);
    }
}

Have fun using them ;-).

There's also a sample jar file for download, which demonstrates each interpolator.

Sonntag, 23. Juni 2013

Pushing my sources into a public repository

Hi all,

I just wanted you to know that I pushed my sources from this blog into a public respository on BitBucket.

I called my project "ExtFX", in the style of the JavaScript Framework "ExtJS".

I also added a permissive license (MIT license) to the code.

The repository also contains some cool interpolators, which are also included in many JavaScript web frameworks. I just translated them to JavaFX.

Besides the actual JAR file with some few controls from this blog, I also uploaded a little samples application and JavaDocs in the downloads section.

The date picker control underwent some changes since the last publication, including a cell factory for the date cells, (which allows for more customization), some bug fixes, cleaner and better documented code, min and max date, and some other minor improvements.

I'd be glad to get some feedback!
Have fun!

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