从JavaFX对话框中的数字文本字段中转义

时间:2018-06-21 13:59:03

标签: javafx

我有一个带有几个UI元素的自定义对话框。一些文本字段用于numeric input。当按下逃逸键并且焦点位于任何数字文本字段上时,此对话框不会关闭。当焦点位于没有此自定义TextFormatter的其他TextField上时,该对话框可以正常关闭。

这是简化的代码:

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 https://stackoverflow.com/a/31043122
        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上时,如果按了退出键,如何关闭对话框?

2 个答案:

答案 0 :(得分:4)

问题已经有一个excellent answer,没有要添加的内容。只是想演示如何调整行为的InputMap来注入/替换我们自己的映射(作为我的评论的后续内容)。注意:以反射方式访问皮肤的行为(私有最终字段)并使用内部api是肮脏的(Behavior / InputMap尚未将其公开)。

正如Slaw所指出的,如果TextField安装了TextFormatter,则是防止ESCAPE冒泡到取消按钮的行为。 IMO,在这种情况下,它的行为不是多余的,只是超调:当且仅当没有其他人使用它来更改任何输入节点的状态时,才应在ESCAPE / ENTER上触发cancel / default按钮(我对已消耗-令人尴尬地对我目前找不到的一般UX准则进行了一些研究...)

适用于同时包含带有textFormatter的textField和取消按钮的表单(又称:isCancelButton为true)

  • 如果textField包含未提交的文本,则取消操作应将编辑还原为最新的提交值并使用事件
  • 如果提交了textField,则应使其冒泡以触发“取消”按钮

行为中的cancelEdit的实现不能区分这两个状态,但是总是消耗它。下面的示例实现了预期的行为(至少对我而言)。它有

  • 确定是否脏的辅助方法(又称textField的编辑内容未提交)
  • 一种事件处理方法,用于检查是否肮脏,仅在它被弄脏时才调用取消并消耗事件
  • 一种调整textFields inputMap的配置方法,以使映射由我们自己的替换。

请注意,这是一个PoC:不属于帮助程序,而是属于自定义皮肤(至少,理想情况下应通过行为来完成)。而且它缺少对ENTER ..的类似支持,因为它需要考虑actionHandlers(这种行为试图fails to achieve,但它要考虑到),所以会涉及更多的问题。

要测试示例:

  • 编译(注意:您需要反射性地访问一个私有字段,使用您手头上的任何东西-我们都这样做,不是吗)并运行
  • 在字段中输入内容
  • 按转义:该字段的文本恢复为其初始值
  • 再次按逃脱键:取消按钮被触发

示例代码:

public class TextFieldCancelSO extends Application {

    /**
     * Returns a boolean to indicate whether the given field has uncommitted
     * changes.
     * 
     * @param <T> the type of the formatter's 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's 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's 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());

}

答案 1 :(得分:1)

您可以将EventHandler添加到TextField

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

您也可以将此EventHandler添加到您的DialogPane的{​​{1}}(或者实际上是Dialog的任何父母/祖先)中。


添加到祖先

id添加到祖先的原因是JavaFX调度事件的方式。您可以在JavaFX教程here中阅读有关事件处理的更多信息。

当您通过EventHandlerEventHandler的祖先添加addEventFilter时,您是在id实际到达Event之前对其进行拦截。在上方的id中,您仅在EventHandler是由用户按下KeyEvent键引起的情况下才做出反应。如果它是ESCAPE键,则关闭ESCAPE并使用Dialog(这将停止对KeyEvent的更多处理)。如果不是KeyEvent键,则您什么也不做,而ESCAPE会照常继续到KeyEvent


为什么id会改变事物

这涉及JavaFX的内部类/代码。 JavaFX中的每个TextFormatter(对于标准库)都有一个Control和一个SkinBehavior确定外观(在JavaFX 9中成为公共API),而Skin确定Behavior对用户操作的反应(从JavaFX 10开始仍是私有API)。此问题的相关类是ControlBehaviorBaseTextInputControlBehavior(每个类都在其之前扩展了该类)。

TextFieldBehavior中有一个称为TextInputControlBehavior的方法。每当由cancelEdit(KeyEvent)引起的KeyEvent到达ESCAPE时,都会调用此方法。基本实现只是在TextInputControl的父节点上重新触发事件。此“重新触发”会导致消耗TextInputControl之前发生单独的事件分配周期(针对父对象)。这很重要,因为KeyEvent会覆盖此基本实现(如下所述)。

Java 8

在Java 8中,似乎到达TextFieldBehavior并具有关联操作的所有KeyEvent都被消耗了。当没有TextInputControl时,这不是问题,因为事件在消耗之前已“转发”给父对象。但是,TextFormatter 中的覆盖方法仅在没有 TextFieldBehavior 时才“转发”事件。

TextFormatter

这意味着当 @Override protected void cancelEdit(KeyEvent event) { TextField textField = getControl(); if (textField.getTextFormatter() != null) { textField.cancelEdit(); } else { forwardToParent(event); } } 时,TextFormatter永远不会冒泡回到KeyEvent(在“为什么如此重要? “)。

Java 10(可能还有9个)

在Java 10中,似乎他们改变了一些东西。现在,到达Scene且具有关联操作的所有KeyEvent并没有被异常消耗。这些例外之一是TextInputControl键。但是,ESCAPE中的重写方法再次起作用。在这种情况下,方法消耗TextFieldBehavior ,并且永远不会在{em> 是{{1 }}

KeyEvent

同样,这意味着当 KeyEvent时,TextFormatter永远不会冒泡回到@Override protected void cancelEdit(KeyEvent event) { TextField textField = getNode(); if (textField.getTextFormatter() != null) { textField.cancelEdit(); event.consume(); } else { super.cancelEdit(event); // would "forward" the event to the parent } } (下文所述的重要性)。

注意:关于为什么,我不知道这些类是通过这种方式设计的,但我认为是有原因的。

为什么如此重要?

创建TextFormatter时,必须将所需的KeyEvent添加到Scene中(或者,如果使用Dialog,则会为您添加它们) 。在您的代码中,您添加的ButtonType之一是DialogPane。此预定义的Alert具有以下ButtonTypeButtonBar.ButtonData.CANCEL_CLOSEButtonType.CANCEL的Javadoc是:

  

“取消”或“关闭”按钮的标签。

     

是“取消”按钮:

     

按钮订购代码: C

重要的部分是“ 是取消按钮:是”。这意味着ButtonType创建与ButtonBar.ButtonData关联的ButtonData时,其cancelButton property设置为Dialog。当您按下Button时,正是ButtonType关闭了true。但是,Button属性的Javadoc声明(强调我的意思):

  

“取消按钮”是接收键盘VK_ESC键的按钮,如果场景中没有其他节点消耗它,则

在JavaFX中处理默认和关闭Dialog的方式是向ESCAPE所属的closeButton添加事件挂钩。仅当Button发生{strong>冒泡到Scene时才调用这些挂钩。如上所示,在ButtonKeyEvent的情况下,Scene决不会冒泡到KeyEvent


上述解决方案是“推荐方式”吗?

答案很简单,是

JavaFX基本上可以通过SceneTextField做出反应,或者通过侦听器对TextFormatter中的变化做出反应。这是内部完成的方式,也是应用程序开发人员(您)如何将行为编码到UI中的方法。

由于javafx.event.Event在存在EventHandler时从未到达(由于被消耗)javafx.beans.Observable,因此实际上只有一个解决方案。您必须先拦截KeyEvent,然后再使用自己的行为。关闭Scene与“取消”相同,如class Javadoc标题为“ 对话框关闭规则”一节中所述。