在JavaFX对话框中如何退出数字文本字段

15

我有一个自定义对话框,其中包含几个UI元素。一些文本字段用于数字输入(参见此处)。当焦点位于任何数字文本字段上时,按下退出键时,该对话框不会关闭。当焦点在没有此自定义文本格式化程序的其他文本字段上时,对话框可以正常关闭。

以下是简化后的代码:

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://dev59.com/610Z5IYBdhLWcg3wzC9W#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上时,我如何在按下Esc键时关闭对话框?

2个回答

10

问题

在提供解决方案之前,我认为了解为什么拥有似乎会改变

的行为是很重要或者至少很有趣的。如果这对您不重要,请随意跳到答案的结尾。

取消按钮

根据


这适用于 id。而且也适用于父级/祖先,这让我感到惊讶。但是为什么在添加文本格式化程序时默认处理程序不起作用?同时也想知道这是否是JavaFX中推荐的方式... - rubpa
@rubpa 我编辑了我的答案以回应你的评论。 - Slaw
哇!感谢您添加的所有细节,确实澄清了几个问题。 - rubpa
2
真的很好的答案,之前不知道那个特别的调整 :) 另一种方法 - 对于那些有勇气涉足泥泞水域的人 - 是替换由行为安装的字段inputMap中的键绑定。肮脏的事情有两个方面:a)需要反射访问皮肤中的私有行为字段b)更改inputMap,它是一个内部类(不幸的是,在皮肤移出公共范围时没有进入公共范围)。 - kleopatra
@kleopatra 啊,我想我从来没有意识到TextFormatter的全部用途。我会在几个小时内更新我的答案,以解决整个取消编辑的问题(现在我的答案完全绕过了这个问题)。 - Slaw
显示剩余2条评论

4
这个问题已经有了优秀的答案,没有需要补充的。我只是想演示如何调整行为的InputMap来注入/替换我们自己的映射(作为我的评论后续)。请注意:它在反射地访问皮肤的行为(私有最终字段)和使用内部API时有些粗糙(行为/InputMap尚未成为公共API)。
正如Slaw所指出的那样,如果TextField安装了TextFormatter,则是行为阻止ESCAPE冒泡到取消按钮。在我看来,在这种情况下它并没有表现不良,只是过度反应:仅当没有其他人使用它来更改任何输入节点的状态时,才应该触发取消/默认按钮的ESCAPE/ENTER(我的自由解释“consumed”-对我不能找到的一般UX准则进行了一些研究,尴尬……)
应用于同时包含TextField(带TextFormatter)和取消按钮(即:isCancelButton为true)的表单:
  • 如果TextField有未提交的文本,则取消应将编辑恢复为最近提交的值并消耗事件
  • 如果TextField已提交,则应让其冒泡以触发取消按钮

在行为(behavior)中,cancelEdit的实现不区分这两种状态,而总是会消耗掉它。下面的示例实现了预期的(至少是我预期的)行为。

  • 一个辅助方法来决定是否脏(即:textField有未提交的编辑)
  • 一个事件处理方法来检查是否脏,调用cancel并仅在其已变脏时消耗事件
  • 一个配置方法,调整textFields inputMap,以便将映射替换为我们自己的映射。

请注意,这只是一个概念验证(PoC):不属于助手程序,而属于自定义皮肤(至少应由行为完成,最好由行为完成)。而且,它缺少类似的ENTER支持...这稍微复杂一些,因为它必须考虑actionHandlers(行为试图但未能实现的内容,参见链接

要测试该示例:

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

示例代码:

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

}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接