Search code examples
javauser-interfacejavafxtextfieldarrow-keys

Navigating out of a TextField in a GridPane using the arrow keys in JavaFX


I am making a sodoku solver program in Java with the JavaFX library. The program incorporates an interactive sodoku board consisting of a series of TextFields in a GridPane. The board looks like this:

The Sodoku Board

Right now, the cursor is in the top left most TextField. If the field had text in it, the user would be able to move the cursor through the text by using the arrow keys. However, I want the user to be able to use the arrow keys to navigate to a different TextField. The issue is, the field is in "typing mode" (I don't know the official terminology) so the arrow keys only move the cursor to a different point in the text, but otherwise it stays in the same field.

This is what I mean:

enter image description here

Pretend that line I drew is the cursor. Right now, if I click the left arrow key, the cursor will move to the left of the 1, but I want it to move the the TextField on the left, instead. If I click the down arrow key, nothing happens because there is no text below the 1 for the cursor to navigate to, but I want it to move to the TextField below, instead.

The code for the GridPane is this:

TextField[][] squares = new TextField[9][9];
GridPane grid = new GridPane();
for (int i = 0; i < 9; i++) {
    for (int j = 0; j < 9; j++) {
        squares[i][j] = new TextField();
        squares[i][j].setPrefHeight(8);
        squares[i][j].setPrefWidth(25);
        grid.add(squares[i][j], j, i);
     }
}
grid.setAlignment(Pos.CENTER);

The squares array is for me to have access to individual TextFields in the GridPane.

Any suggestions for how I can fix this?


Solution

  • To avoid the focused TextField from handling arrow keys at all you need to intercept the KeyEvent before it reaches said TextField. This can be accomplished by adding an event filter to the GridPane and consuming the event as appropriate. If you're not sure why this works you can check out the JavaFX: Handling Events tutorial.

    Then you can use Node#requestFocus() to programmatically change the focused node.

    I also recommend setting the prefColumnCount of each TextField rather than trying to set the preferred dimensions manually. That way the preferred dimensions are computed based on the font size.

    Here's an example:

    import javafx.application.Application;
    import javafx.geometry.Insets;
    import javafx.geometry.Pos;
    import javafx.scene.Node;
    import javafx.scene.Scene;
    import javafx.scene.control.TextField;
    import javafx.scene.control.TextFormatter;
    import javafx.scene.input.KeyEvent;
    import javafx.scene.layout.GridPane;
    import javafx.scene.text.Font;
    import javafx.stage.Stage;
    
    public class App extends Application {
    
      private TextField[][] fields;
    
      @Override
      public void start(Stage primaryStage) {
        GridPane grid = new GridPane();
        grid.setHgap(3);
        grid.setVgap(3);
        grid.setPadding(new Insets(5));
        grid.setAlignment(Pos.CENTER);
        grid.addEventFilter(KeyEvent.KEY_PRESSED, this::handleArrowNavigation);
    
        fields = new TextField[9][9];
    
        for (int i = 0; i < 9; i++) {
          for (int j = 0; j < 9; j++) {
            fields[i][j] = createTextField();
            grid.add(fields[i][j], j, i);
          }
        }
    
        primaryStage.setScene(new Scene(grid));
        primaryStage.show();
      }
    
      private void handleArrowNavigation(KeyEvent event) {
        Node source = (Node) event.getSource(); // the GridPane
        Node focused = source.getScene().getFocusOwner();
        if (event.getCode().isArrowKey() && focused.getParent() == source) {
          int row = GridPane.getRowIndex(focused);
          int col = GridPane.getColumnIndex(focused);
          // Switch expressions were standardized in Java 14
          switch (event.getCode()) {
            case LEFT -> fields[row][Math.max(0, col - 1)].requestFocus();
            case RIGHT -> fields[row][Math.min(8, col + 1)].requestFocus();
            case UP -> fields[Math.max(0, row - 1)][col].requestFocus();
            case DOWN -> fields[Math.min(8, row + 1)][col].requestFocus();
          }
          event.consume();
        }
      }
    
      private TextField createTextField() {
        TextField field = new TextField();
        // Rather than setting the pref sizes manually this will
        // compute the pref sizes based on the font size.
        field.setPrefColumnCount(1);
        field.setFont(Font.font(20));
        field.setTextFormatter(
            new TextFormatter<>(
                change -> {
                  // Only allow the text to be empty or a single digit between 1-9
                  if (change.getControlNewText().matches("[1-9]?")) {
                    // Without this the text goes "off screen" to the left. This also
                    // seems to have the added benefit of selecting the just-entered
                    // text, which makes replacing it a simple matter of typing another
                    // digit.
                    change.setCaretPosition(0);
                    return change;
                  }
                  return null;
                }));
        return field;
      }
    }
    

    The above also adds a TextFormatter to each TextField to show a way to limit the text to digits between 1 and 9. Note the arrow navigation does not "wrap around" when it reaches the end of a row or column. You can of course modify the code to implement this, if desired.

    You may want to consider creating a model for the game. That way the business logic is not tied directly to JavaFX UI objects. When you update the model it would notify the view (possibly via a "view model", depending on the architecture) and the view will update itself accordingly.