Статьи

JavaFX NumberTextField и Spinner Control

Недавно я потратил некоторое время на изучение JavaFX, и создание настраиваемого элемента управления — это хорошая практика, чтобы немного глубже погрузиться в концепции новой библиотеки графического интерфейса. Имея некоторый опыт работы с финансовым программным обеспечением, я, конечно, пропустил эквивалент JFormattedTextField и элемента управления JSpinner в текущей версии 2.0. Поэтому идти по этому пути мне показалось хорошим выбором.

 И вот мои элементы управления:

  • NumberTextField, который может быть настроен с произвольным NumberFormat
  • поле Spinner, которое также может быть сконфигурировано с произвольным NumberFormat и управляется с помощью клавиш со стрелками или кнопок со стрелками, которые являются частью элемента управления

Элементы управления и пример могут быть загружены в виде проекта netbeans . Пример также включает в себя файл CSS, который задает стиль счетчика с прямыми или закругленными углами.

 

 NumberTextField

NumberTextField было довольно простым, и я бы даже не стал рассматривать это как пользовательский элемент управления, поскольку изменил только поведение уже существующего элемента управления. Он расширяет обычный TextField, добавляет NumberProperty, который служит моделью и содержит BigDecimal (для финансовых приложений нам нужны точные типы) и выполняет некоторое форматирование и анализ. В этом нет ничего сложного.

package de.thomasbolz.javafx;

import java.math.BigDecimal;
import java.text.NumberFormat;
import java.text.ParseException;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.control.TextField;

/**
 * Textfield implementation that accepts formatted number and stores them in a
 * BigDecimal property The user input is formatted when the focus is lost or the
 * user hits RETURN.
 *
 * @author Thomas Bolz
 */
public class NumberTextField extends TextField {

    private final NumberFormat nf;
    private ObjectProperty<BigDecimal> number = new SimpleObjectProperty<>();

    public final BigDecimal getNumber() {
        return number.get();
    }

    public final void setNumber(BigDecimal value) {
        number.set(value);
    }

    public ObjectProperty<BigDecimal> numberProperty() {
        return number;
    }

    public NumberTextField() {
        this(BigDecimal.ZERO);
    }

    public NumberTextField(BigDecimal value) {
        this(value, NumberFormat.getInstance());
        initHandlers();
    }

    public NumberTextField(BigDecimal value, NumberFormat nf) {
        super();
        this.nf = nf;
        initHandlers();
        setNumber(value);
    }

    private void initHandlers() {

        // try to parse when focus is lost or RETURN is hit
        setOnAction(new EventHandler<ActionEvent>() {

            @Override
            public void handle(ActionEvent arg0) {
                parseAndFormatInput();
            }
        });

        focusedProperty().addListener(new ChangeListener<Boolean>() {

            @Override
            public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
                if (!newValue.booleanValue()) {
                    parseAndFormatInput();
                }
            }
        });

        // Set text in field if BigDecimal property is changed from outside.
        numberProperty().addListener(new ChangeListener<BigDecimal>() {

            @Override
            public void changed(ObservableValue<? extends BigDecimal> obserable, BigDecimal oldValue, BigDecimal newValue) {
                setText(nf.format(newValue));
            }
        });
    }

    /**
     * Tries to parse the user input to a number according to the provided
     * NumberFormat
     */
    private void parseAndFormatInput() {
        try {
            String input = getText();
            if (input == null || input.length() == 0) {
                return;
            }
            Number parsedNumber = nf.parse(input);
            BigDecimal newValue = new BigDecimal(parsedNumber.toString());
            setNumber(newValue);
            selectAll();
        } catch (ParseException ex) {
            // If parsing fails keep old number
            setText(nf.format(number.get()));
        }
    }
}

 NumberSpinner

NumberSpinner только немного сложнее. Он основан на NumberTextField и добавляет кнопку увеличения и уменьшения, которая увеличивает и уменьшает значение в поле на шаг.

Начальное значение, шаг и базовый NumberFormat задаются в конструкторе. Текстовое поле и размер кнопок масштабируются в соответствии с размером шрифта, который может быть установлен, например, в файле .css.

package de.thomasbolz.javafx;

import java.math.BigDecimal;
import java.text.NumberFormat;
import javafx.beans.binding.NumberBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.shape.LineTo;
import javafx.scene.shape.MoveTo;
import javafx.scene.shape.Path;
import javax.swing.JSpinner;

/**
 * JavaFX Control that behaves like a {@link JSpinner} known in Swing. The
 * number in the textfield can be incremented or decremented by a configurable
 * stepWidth using the arrow buttons in the control or the up and down arrow
 * keys.
 *
 * @author Thomas Bolz
 */
public class NumberSpinner extends HBox {

    public static final String ARROW = "NumberSpinnerArrow";
    public static final String NUMBER_FIELD = "NumberField";
    public static final String NUMBER_SPINNER = "NumberSpinner";
    public static final String SPINNER_BUTTON_UP = "SpinnerButtonUp";
    public static final String SPINNER_BUTTON_DOWN = "SpinnerButtonDown";
    private final String BUTTONS_BOX = "ButtonsBox";
    private NumberTextField numberField;
    private ObjectProperty<BigDecimal> stepWitdhProperty = new SimpleObjectProperty<>();
    private final double ARROW_SIZE = 4;
    private final Button incrementButton;
    private final Button decrementButton;
    private final NumberBinding buttonHeight;
    private final NumberBinding spacing;

    public NumberSpinner() {
        this(BigDecimal.ZERO, BigDecimal.ONE);
    }

    public NumberSpinner(BigDecimal value, BigDecimal stepWidth) {
        this(value, stepWidth, NumberFormat.getInstance());
    }

    public NumberSpinner(BigDecimal value, BigDecimal stepWidth, NumberFormat nf) {
        super();
        this.setId(NUMBER_SPINNER);
        this.stepWitdhProperty.set(stepWidth);

        // TextField
        numberField = new NumberTextField(value, nf);
        numberField.setId(NUMBER_FIELD);

        // Enable arrow keys for dec/inc
        numberField.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() {

            @Override
            public void handle(KeyEvent keyEvent) {
                if (keyEvent.getCode() == KeyCode.DOWN) {
                    decrement();
                    keyEvent.consume();
                }
                if (keyEvent.getCode() == KeyCode.UP) {
                    increment();
                    keyEvent.consume();
                }
            }
        });

        // Painting the up and down arrows
        Path arrowUp = new Path();
        arrowUp.setId(ARROW);
        arrowUp.getElements().addAll(new MoveTo(-ARROW_SIZE, 0), new LineTo(ARROW_SIZE, 0),
                new LineTo(0, -ARROW_SIZE), new LineTo(-ARROW_SIZE, 0));
        // mouse clicks should be forwarded to the underlying button
        arrowUp.setMouseTransparent(true);

        Path arrowDown = new Path();
        arrowDown.setId(ARROW);
        arrowDown.getElements().addAll(new MoveTo(-ARROW_SIZE, 0), new LineTo(ARROW_SIZE, 0),
                new LineTo(0, ARROW_SIZE), new LineTo(-ARROW_SIZE, 0));
        arrowDown.setMouseTransparent(true);

        // the spinner buttons scale with the textfield size
        // TODO: the following approach leads to the desired result, but it is 
        // not fully understood why and obviously it is not quite elegant
        buttonHeight = numberField.heightProperty().subtract(3).divide(2);
        // give unused space in the buttons VBox to the incrementBUtton
        spacing = numberField.heightProperty().subtract(2).subtract(buttonHeight.multiply(2));

        // inc/dec buttons
        VBox buttons = new VBox();
        buttons.setId(BUTTONS_BOX);
        incrementButton = new Button();
        incrementButton.setId(SPINNER_BUTTON_UP);
        incrementButton.prefWidthProperty().bind(numberField.heightProperty());
        incrementButton.minWidthProperty().bind(numberField.heightProperty());
        incrementButton.maxHeightProperty().bind(buttonHeight.add(spacing));
        incrementButton.prefHeightProperty().bind(buttonHeight.add(spacing));
        incrementButton.minHeightProperty().bind(buttonHeight.add(spacing));
        incrementButton.setFocusTraversable(false);
        incrementButton.setOnAction(new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent ae) {
                increment();
                ae.consume();
            }
        });

        // Paint arrow path on button using a StackPane
        StackPane incPane = new StackPane();
        incPane.getChildren().addAll(incrementButton, arrowUp);
        incPane.setAlignment(Pos.CENTER);

        decrementButton = new Button();
        decrementButton.setId(SPINNER_BUTTON_DOWN);
        decrementButton.prefWidthProperty().bind(numberField.heightProperty());
        decrementButton.minWidthProperty().bind(numberField.heightProperty());
        decrementButton.maxHeightProperty().bind(buttonHeight);
        decrementButton.prefHeightProperty().bind(buttonHeight);
        decrementButton.minHeightProperty().bind(buttonHeight);

        decrementButton.setFocusTraversable(false);
        decrementButton.setOnAction(new EventHandler<ActionEvent>() {

            @Override
            public void handle(ActionEvent ae) {
                decrement();
                ae.consume();
            }
        });

        StackPane decPane = new StackPane();
        decPane.getChildren().addAll(decrementButton, arrowDown);
        decPane.setAlignment(Pos.CENTER);

        buttons.getChildren().addAll(incPane, decPane);
        this.getChildren().addAll(numberField, buttons);
    }

    /**
     * increment number value by stepWidth
     */
    private void increment() {
        BigDecimal value = numberField.getNumber();
        value = value.add(stepWitdhProperty.get());
        numberField.setNumber(value);
    }

    /**
     * decrement number value by stepWidth
     */
    private void decrement() {
        BigDecimal value = numberField.getNumber();
        value = value.subtract(stepWitdhProperty.get());
        numberField.setNumber(value);
    }

    public final void setNumber(BigDecimal value) {
        numberField.setNumber(value);
    }

    public ObjectProperty<BigDecimal> numberProperty() {
        return numberField.numberProperty();
    }

    public final BigDecimal getNumber() {
        return numberField.getNumber();
    }

    // debugging layout bounds
    public void dumpSizes() {
        System.out.println("numberField (layout)=" + numberField.getLayoutBounds());
        System.out.println("buttonInc (layout)=" + incrementButton.getLayoutBounds());
        System.out.println("buttonDec (layout)=" + decrementButton.getLayoutBounds());
        System.out.println("binding=" + buttonHeight.toString());
        System.out.println("spacing=" + spacing.toString());
    }
}

  number_spinner.css

 Наконец, что не менее важно, элемент управления может быть стилизован в файле CSS. Я играл с двумя взглядами, закругленными углами и прямыми углами (см. Скриншоты). Вы можете переключаться между ними, изменяя границы / фоновые радиусы в #NumberField, #ButtonBox, #SpinnerButtonUp и #SpinnerButtonDown.

.root{
    -fx-font-size: 24pt;
    /*    -fx-base: rgb(255,0,0);*/
    /*    -fx-background: rgb(50,50,50);*/
}
#NumberField {
    -fx-border-width: 1;
    -fx-border-color: lightgray;
    -fx-background-insets:1;
    -fx-border-radius:3 0 0 3;
    /*    -fx-border-radius:0 0 0 0;*/
}
#NumberSpinnerArrow {
    -fx-fill: gray;
    -fx-stroke: gray;
    /*        -fx-effect: innershadow( gaussian , black , 2 , 0.6 , 1 , 1 )*/
}
#ButtonsBox {
    -fx-border-color:lightgray;
    -fx-border-width: 1 1 1 0;
    -fx-border-radius: 0 3 3 0;
    /*    -fx-border-radius: 0 0 0 0;*/
}
#SpinnerButtonUp {
    -fx-background-insets: 0;
    -fx-background-radius:0 3 0 0;
    /*    -fx-background-radius:0;*/
}
#SpinnerButtonDown {
    -fx-background-insets: 0;
    -fx-background-radius:0 0 3 0;
    /*    -fx-background-radius:0;*/
}

 Вывод

Создание пользовательских элементов управления в JavaFX на самом деле не составляет особого труда, хотя приведенные выше примеры действительно просты. JavaFX, являющийся чистым Java API с 2.0, теперь интегрируется даже лучше, чем раньше, с такими языками, как groovy, где BigDecimal является гражданином первого класса. Это делает его практически идеальной парой для приложений для настольных компьютеров.