Выйти из текстового поля NumberField в диалоговом окне JavaFX

У меня есть пользовательский диалог с несколькими элементами пользовательского интерфейса. Некоторые текстовые поля для ввода цифр. Это диалоговое окно не закрывается при нажатии клавиши эвакуации и фокусировка на любом из текстовых полей. Диалоговое окно закрывается, когда фокус находится на других текстовых полях, которые не имеют этого настраиваемого TextFormatter.

Здесь упрощенный код:

package application;

import java.text.DecimalFormat;
import java.text.ParsePosition;

import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.control.TextFormatter;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class Main extends Application {
    @Override
    public void start(Stage primaryStage) {
        try {
            TextField name = new TextField();
            HBox hb1 = new HBox();
            hb1.getChildren().addAll(new Label("Name: "), name);

            TextField id = new TextField();
            id.setTextFormatter(getNumberFormatter()); // numbers only
            HBox hb2 = new HBox();
            hb2.getChildren().addAll(new Label("ID: "), id);

            VBox vbox = new VBox();
            vbox.getChildren().addAll(hb1, hb2);

            Dialog<ButtonType> dialog = new Dialog<>();
            dialog.setTitle("Number Escape");
            dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
            dialog.getDialogPane().setContent(vbox);

            Platform.runLater(() -> name.requestFocus());

            if (dialog.showAndWait().get() == ButtonType.OK) {
                System.out.println("OK: " + name.getText() + id.getText());
            } else {
                System.out.println("Cancel");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    TextFormatter<Number> getNumberFormatter() {
        // from /info/106035/java-8-u40-textformatter-javafx-to-restrict-user-input-only-for-decimal-number/666748#666748
        DecimalFormat format = new DecimalFormat("#");
        TextFormatter<Number> tf = new TextFormatter<>(c -> {
            if (c.getControlNewText().isEmpty()) {
                return c;
            }
            ParsePosition parsePosition = new ParsePosition(0);
            Object object = format.parse(c.getControlNewText(), parsePosition);
            if (object == null || parsePosition.getIndex() < c.getControlNewText().length()) {
                return null;
            } else {
                return c;
            }
        });

        return tf;
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Как закрыть диалоговое окно при нажатии клавиши запуска, когда фокус находится на id?

Ответ 1

Проблема

Прежде чем предлагать решение, я думаю, что важно или, по крайней мере, интересно понять, почему наличие TextFormatter, кажется, меняет поведение Dialog. Если это не имеет значения для вас, смело переходите к концу ответа.

Отмена кнопок

Согласно документации Button, кнопка отмены:

кнопка, которая получает клавиатуру VK_ESC, нажмите, если никакой другой узел в сцене ее не использует.

Конец этого предложения является важной частью. Кнопки отмены, а также кнопки по умолчанию реализуются путем регистрации ускорителя в Scene, к которому принадлежит Button. Эти ускорители вызываются только в том случае, если соответствующий KeyEvent всплывает до Scene. Если событие используется до того, как оно достигнет Scene, ускоритель не вызывается.

Примечание. Чтобы больше узнать об обработке событий в JavaFX, особенно о таких терминах, как "пузыри" и "потребляются", я предлагаю прочитать это руководство.

Диалоги

Dialog имеет определенные правила относительно того, как и когда он может быть закрыт. Эти правила описаны здесь, в разделе "Правила закрытия диалога". Достаточно сказать, что в основном все зависит от того, какой ButtonType был добавлен в DialogPane. В вашем примере вы используете один из предопределенных типов: ButtonType.CANCEL. Если вы посмотрите документацию этого поля, вы увидите:

A pre-defined ButtonType that displays "Cancel" и has a ButtonBar.ButtonData of ButtonBar.ButtonData.CANCEL_CLOSE.

И если вы посмотрите документацию ButtonData.CANCEL_CLOSE, вы увидите:

Тег для кнопки "отмена" или "закрыть".

Is cancel button: True

По крайней мере для реализации по умолчанию это означает, что Button, созданный для указанного ButtonType.CANCEL, будет кнопкой отмены. Другими словами, Button будет иметь свойство cancelButton, установленное на true. Это то, что позволяет закрыть Dialog нажатием клавиши Esc.

Примечание. Это метод DialogPane#createButton(ButtonType), который отвечает за создание соответствующей кнопки (и может быть переопределен для настройки). Несмотря на то, что тип возвращаемого значения этого метода - Node, как документировано, обычно возвращается экземпляр Button.

TextFormatter

Каждый элемент управления в (основном) JavaFX имеет три компонента: класс элемента управления, класс скина и класс поведения. Последний класс отвечает за обработку пользовательского ввода, такого как события мыши и клавиши. В этом случае мы заботимся о TextInputControlBehavior и TextFieldBehavior; первый является суперклассом второго.

Примечание: В отличие от классов скинов, которые стали общедоступными API в JavaFX 9, классы поведения по-прежнему являются частными API с JavaFX 12.0.2. Многое из того, что описано ниже, является подробностями реализации.

Класс TextInputControlBehavior регистрирует EventHandler, который реагирует на нажатие клавиши Esc, вызывая метод cancelEdit(KeyEvent) того же класса. Вся базовая реализация этого метода - пересылка KeyEvent родительскому элементу TextInputControl, если он есть, - что приводит к двум циклам отправки событий по неизвестной (для меня) причине. Однако класс TextFieldBehavior переопределяет этот метод:

@Override
protected void cancelEdit(KeyEvent event) {
    TextField textField = getNode();
    if (textField.getTextFormatter() != null) {
        textField.cancelEdit();
        event.consume();
    } else {
        super.cancelEdit(event);
    }
}

Как видите, присутствие TextFormatter приводит к тому, что KeyEvent безоговорочно потребляется. Это останавливает событие от достижения Scene, кнопка отмены не срабатывает, и, таким образом, Dialog не закрывается, когда клавиша Esc нажата, когда TextField имеет фокус. Когда нет TextFormatter, вызывается супер реализация, которая, как указано выше, просто пересылает событие родителю.

Причина такого поведения намекается на вызов TextInputControl#cancelEdit(). Этот метод имеет "родственный метод" в виде TextInputControl#commitValue(). Если вы посмотрите документацию этих двух методов, вы увидите:

Если поле в данный момент редактируется, этот вызов установит в тексте последнее зафиксированное значение.

И:

Зафиксируйте текущий текст и преобразуйте его в значение.

Соответственно. К сожалению, это мало что объясняет, но если вы посмотрите на реализацию, их цель станет ясна. TextFormatter имеет свойство value, которое не обновляется в режиме реального времени при вводе в TextField. Вместо этого значение обновляется только тогда, когда оно зафиксировано (например, нажатием Enter). Обратное также верно; текущий текст можно вернуть к текущему значению, отменив редактирование (например, нажав Esc).

Примечание. Преобразование между String и объектом произвольного типа обрабатывается StringConverter, связанным с TextFormatter.

Когда есть TextFormatter, акт отмены редактирования считается сценарием с большим количеством событий. Это имеет смысл, я полагаю. Тем не менее, даже когда нет ничего, чтобы отменить событие, все еще потребляется - это не имеет особого смысла для меня.


Решение

Один из способов исправить это - копаться во внутренних органах, используя рефлексию, как показано в ответе Клеопатры. Другой вариант - добавить фильтр событий к TextField или некоторому предку TextField, который закрывает Dialog при нажатии клавиши Esc.

textField.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
    if (event.getCode() == KeyCode.ESCAPE) {
        event.consume();
        dialog.close();
    }
});

Если вы хотите включить режим отмены редактирования (отмена без закрытия), вам следует закрывать Dialog только в том случае, если нет редактирования для отмены. Взгляните на ответ клеопатры, чтобы увидеть, как можно определить, нужна ли отмена. Если есть что отменить, просто не используйте событие и не закрывайте Dialog. Если отменить что-либо не нужно, просто сделайте то же, что и в коде выше (т.е. используйте и закройте).

Является ли использование фильтра событий "рекомендуемым способом"? Это конечно верный способ. JavaFX является управляемым событиями, как и большинство, если не все, основные наборы инструментов пользовательского интерфейса. В частности, для JavaFX это означает реагирование на Event или наблюдение за Observable[Value] на предмет недействительности/изменений. Фреймворк, построенный "поверх" JavaFX, может добавлять свои собственные механизмы. Поскольку проблема - это событие, которое потребляется, когда мы не хотим, чтобы оно было, допустимо добавить свои собственные обработчики для реализации желаемого поведения.

Ответ 2

На вопрос уже есть отличный ответ, добавить нечего. Просто хотел продемонстрировать, как настроить поведение InputMap для добавления/замены наших собственных отображений (как продолжение моего комментария). Осторожно: это грязно в рефлексивном доступе к поведению скина (закрытое конечное поле) и использовании внутреннего API (Behavior/InputMap пока не сделал его публичным).

Как указал Slaw, именно поведение не позволяет ESCAPE пузыриться до кнопки отмены, если в TextField установлен TextFormatter. IMO, в этом случае это не плохое поведение, просто перерегулирование: кнопки отмены/по умолчанию должны срабатывать при ESCAPE/ENTER, если и только если никто другой не использовал его для изменения состояния любых узлов ввода (моя несколько свободная интерпретация потребляется - провел какое-то исследование общих принципов UX, которые я не могу найти прямо сейчас, смущающе...)

Применяется к форме, содержащей как textField с textFormatter, так и кнопку отмены (aka: isCancelButton - true)

  • если textField имеет незафиксированный текст, отмена должна вернуть редактирование к последнему принятому значению и использовать событие
  • если textField зафиксирован, он должен дать ему всплыть, чтобы вызвать кнопку отмены

Реализация cancelEdit в поведении не делает различий между этими двумя состояниями, но всегда использует его. В приведенном ниже примере реализовано ожидаемое (по крайней мере, мной) поведение. Имеет

  • вспомогательный метод, чтобы решить, является ли грязным (иначе: у textField есть незафиксированное редактирование)
  • метод обработки событий, который проверяет наличие загрязнений, вызывает отмену и использует событие, только если оно было грязным
  • метод конфигурации, который настраивает inputMap textFields таким образом, что отображение заменяется нашим собственным.

Обратите внимание, что это PoC: не относится к помощникам, а к пользовательской оболочке (по крайней мере, в идеале это должно быть сделано по поведению). И ему не хватает аналогичной поддержки для ENTER.., который немного более сложен, потому что он должен учитывать actionHandlers (какое поведение пытается, но не удается)

Чтобы проверить пример:

  • скомпилировать (примечание: вам нужно рефлексивно получить доступ к закрытому полю, использовать все, что у вас есть под рукой - мы все делаем, не так ли) и запустить
  • введите что-нибудь в поле
  • нажмите escape: текст поля возвращается к своему начальному значению
  • нажмите escape еще раз: кнопка отмены срабатывает

Пример кода:

public class TextFieldCancelSO extends Application {

    /**
     * Returns a boolean to indicate whether the given field has uncommitted
     * changes.
     * 
     * @param <T> the type of the formatter value
     * @param field the field to analyse
     * @return true if the field has a textFormatter with converter and
     *    uncommitted changes, false otherwise
     */
    public static <T> boolean isDirty(TextField field) {
        TextFormatter<T> textFormatter = (TextFormatter<T>) field.getTextFormatter();
        if (textFormatter == null || textFormatter.getValueConverter() == null) return false;
        String fieldText = field.getText();
        StringConverter<T> valueConverter = textFormatter.getValueConverter();
        String formatterText = valueConverter.toString(textFormatter.getValue());
        // todo: handle empty string vs. null value
        return !Objects.equals(fieldText, formatterText);
    }

    /**
     * Install a custom keyMapping for ESCAPE in the inputMap of the given field. 
     * @param field the textField to configure
     */
    protected void installCancel(TextField field) {
        // Dirty: reflectively access the behavior
        // needs --add-exports at compile- and runtime! 
        // note: FXUtils is a custom helper class not contained in core fx, use your own 
        // helper or write the field access code as needed.
        TextFieldBehavior behavior = (TextFieldBehavior) FXUtils.invokeGetFieldValue(
                TextFieldSkin.class, field.getSkin(), "behavior");
        // Dirty: internal api/classes
        InputMap inputMap = behavior.getInputMap();
        KeyBinding binding = new KeyBinding(KeyCode.ESCAPE);
        // custom mapping that delegates to helper method
        KeyMapping keyMapping = new KeyMapping(binding, e ->  {
            cancelEdit(field, e);
        });
        // by default, mappings consume the event - configure not to
        keyMapping.setAutoConsume(false);
        // remove old
        inputMap.getMappings().remove(keyMapping);
        // add new
        inputMap.getMappings().add(keyMapping);
    }

    /**
     * Custom EventHandler that mapped to ESCAPE.
     * 
     * @param field the field to handle a cancel for
     * @param ev the received keyEvent 
     */
    protected void cancelEdit(TextField field, KeyEvent ev) {
        boolean dirty = isDirty(field);
        field.cancelEdit();
        if (dirty) {
           ev.consume();
        }
    }

    private Parent createContent() {
        TextFormatter<String> fieldFormatter = new TextFormatter<>(
                TextFormatter.IDENTITY_STRING_CONVERTER, "textField ...");
        TextField field = new TextField();
        field.setTextFormatter(fieldFormatter);
        // listen to skin: behavior is available only after it set
        field.skinProperty().addListener((src, ov, nv) -> {
            installCancel(field);
        });
        // just to see the state of the formatter
        Label fieldValue = new Label();
        fieldValue.textProperty().bind(fieldFormatter.valueProperty());

        // add cancel button
        Button cancel = new Button("I'm the cancel");
        cancel.setCancelButton(true);
        cancel.setOnAction(e -> LOG.info("triggered: " + cancel.getText()));

        HBox fields = new HBox(100, field, fieldValue);
        BorderPane content = new BorderPane(fields);
        content.setBottom(cancel);
        return content;
    }

    @Override
    public void start(Stage stage) throws Exception {
        stage.setScene(new Scene(createContent()));
        stage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }

    @SuppressWarnings("unused")
    private static final Logger LOG = Logger
            .getLogger(TextFieldCancelSO.class.getName());

}