I've a custom dialog with several UI elements. Some TextFields are for numeric input. This dialog does not close when the escape key is hit and the focus is on any of the numeric text fields. The dialog closes fine when focus is on other TextFields which do not have this custom TextFormatter.
Here's the simplified code:
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);
}
}
How do I close the dialog when escape key is hit while focus is on id
?
The Problem
Before offering a solution I think it's important, or at least interesting, to understand why having a
TextFormatter
seems to change the behavior of theDialog
. If this doesn't matter to you, feel free to jump to the end of the answer.Cancel Buttons
According to the documentation of
Button
, a cancel button is:The end of that sentence is the important part. The way cancel buttons, as well as default buttons, are implemented is by registering an accelerator with the
Scene
that theButton
belongs to. These accelerators are only invoked if the appropriateKeyEvent
bubbles up to theScene
. If the event is consumed before it reaches theScene
, the accelerator is not invoked.Note: To understand more about event processing in JavaFX, especially terms such as "bubbles" and "consumed", I suggest reading this tutorial.
Dialogs
A
Dialog
has certain rules regarding how and when it can be closed. These rules are documented here, in the Dialog Closing Rules section. Suffice to say, basically everything depends on whichButtonType
s have been added to theDialogPane
. In your example you use one of the predefined types:ButtonType.CANCEL
. If you look at the documentation of that field, you'll see:And if you look at the documentation of
ButtonData.CANCEL_CLOSE
, you'll see:What this means, at least for the default implementation, is that the
Button
created for saidButtonType.CANCEL
will be a cancel button. In other words, theButton
will have itscancelButton
property set totrue
. This is what allows one to close aDialog
by pressing the Esc key.Note: It's the
DialogPane#createButton(ButtonType)
method that's responsible for creating the appropriate button (and can be overridden for customization). While the return type of that method isNode
it is typical, as documented, to return an instance ofButton
.The TextFormatter
Every control in (core) JavaFX has three components: the control class, the skin class, and the behavior class. The latter class is responsible for handling user input, such as mouse and key events. In this case, we care about
TextInputControlBehavior
andTextFieldBehavior
; the former is the superclass of the latter.Note: Unlike the skin classes, which became public API in JavaFX 9, the behavior classes are still private API as of JavaFX 12.0.2. Much of what's described below are implementation details.
The
TextInputControlBehavior
class registers anEventHandler
that reacts to the Esc key being pressed, invoking thecancelEdit(KeyEvent)
method of the same class. All the base implementation of this method does is forward theKeyEvent
to theTextInputControl
's parent, if it has one—resulting in two event dispatching cycles for some unknown (to me) reason. However, theTextFieldBehavior
class overrides this method:As you can see, the presence of a
TextFormatter
causes theKeyEvent
to be unconditionally consumed. This stops the event from reaching theScene
, the cancel button is not fired, and thus theDialog
does not close when the Esc key is pressed while theTextField
has the focus. When there is noTextFormatter
the super implementation is invoked which, as stated before, simply forwards the event to the parent.The reason for this behavior is hinted at by the call to
TextInputControl#cancelEdit()
. That method has a "sister method" in the form ofTextInputControl#commitValue()
. If you look at the documentation of those two methods, you'll see:And:
Respectively. That doesn't explain much, unfortunately, but if you look at the implementation their purpose becomes clear. A
TextFormatter
has avalue
property which is not updated in real time while typing into theTextField
. Instead, the value is only updated when it's committed (e.g. by pressing Enter). The reverse is also true; the current text can be reverted to the current value by cancelling the edit (e.g. by pressing Esc).Note: The conversion between
String
and an object of arbitrary type is handled by theStringConverter
associated with theTextFormatter
.When there's a
TextFormatter
, the act of cancelling the edit is deemed an event-consuming scenario. This makes sense, I suppose. However, even when there's nothing to cancel the event is still consumed—this doesn't make as much sense to me.A Solution
One way to fix this is to dig into the internals, using reflection, as is shown in kleopatra's answer. Another option is to add an event filter to the
TextField
or some ancestor of theTextField
that closes theDialog
when the Esc key is pressed.If you'd like to include the cancel-edit behavior (cancel without closing) then you should only close the
Dialog
if there's no edit to cancel. Take a look at kleopatra's answer to see how one might determine whether or not a cancel is needed. If there is something to cancel simply don't consume the event and don't close theDialog
. If there isn't anything to cancel then just do the same as the code above (i.e. consume and close).Is using an event filter the "recommended way"? It's certainly a valid way. JavaFX is event-driven like most, if not all, mainstream UI toolkits. For JavaFX specifically that means reacting to
Event
s or observingObservable[Value]
s for invalidations/changes. A framework built "on top of" JavaFX may add its own mechanisms. Since the problem is an event being consumed when we don't want it to be, it is valid to add your own handlers to implement the desired behavior.The question already has an excellent answer, nothing to add. Just wanted to demonstrate how to tweak the behavior's InputMap to inject/replace our own mappings (as a follow-up to my comment). Beware: it's dirty in reflectively accessing a skin's behavior (private final field) and using internal api (Behavior/InputMap didn't make it into public, yet).
As Slaw pointed out, it's the behavior that prevents the ESCAPE from bubbling up to the cancel button if the TextField has a TextFormatter installed. IMO, it's not misbehaving in that case, just overshooting: the cancel/default buttons should be triggered on ESCAPE/ENTER if and only if no other had used it to change the state of the any input nodes (my somewhat free interpretation of consumed - had done some research on general UX guidelines that I can't find right now, embarassingly ...)
Applied to a form containing both a textField with textFormatter and a cancel button (aka: isCancelButton is true)
The implementation of cancelEdit in behavior doesn't distinguish between those two states, but always consumes it. The example below implements the expected (by me, at least) behavior. It has
Note that this is a PoC: doesn't belong into helpers but into a custom skin (at the very least, ideally should be done by the behavior). And it is missing similar support for the ENTER .. which is slightly more involved because it has to take actionHandlers into account (which behavior tries to but fails to achieve)
To test the example:
The example code: