Search code examples
javaintellij-ideajavafxfxmlfxmlloader

How do you transition between controllers within the original window that was created


I currently have 3 classes.

ScreenController (controller class):

import javafx.fxml.FXMLLoader;
import javafx.fxml.Initializable;
import javafx.scene.Parent;
import javafx.scene.layout.AnchorPane;
import java.net.URL;
import java.util.ResourceBundle;

public class ScreenController implements Initializable
{
    private AnchorPane window;

    public ScreenController()
    {
        super();
    }

    public ScreenController(AnchorPane window)
    {
        setWindow(window);
    }

    public void setWindow(AnchorPane window)
    {
        this.window = window;
    }

    public void setScreen(String screen)
    {
        try
        {
            Parent root = FXMLLoader.load(getClass().getResource("/com/app/client/resources/fxml/" + screen + ".fxml"));
            window.getChildren().setAll(root);
        }
        catch (Exception e)
        {
            e.printStackTrace();
        }
    }

    @Override
    public void initialize(URL location, ResourceBundle resources)
    {
    }
}

LoginScreen (primary screen):

import com.app.client.java.controllers.ScreenController;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.layout.AnchorPane;

import java.io.IOException;

public class LoginScreen extends ScreenController
{
    @FXML
    private AnchorPane loginWindow;

    @FXML
    private Button goButton;

    public LoginScreen()
    {
        super();
        setWindow(loginWindow);
    }

    @FXML
    public void goButtonPressed(ActionEvent event) throws IOException
    {
        setScreen("Home");
        System.out.println("Success.");
    }
}
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.Button?>
<?import javafx.scene.layout.AnchorPane?>

<AnchorPane fx:id="loginWindow" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" opacity="0.5" prefHeight="500.0" prefWidth="850.0" xmlns="http://javafx.com/javafx/8.0.172-ea" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.app.client.java.classes.LoginScreen">
   <children>
      <Button fx:id="goButton" layoutX="205.0" layoutY="60.0" mnemonicParsing="false" onAction="#goButtonPressed" text="Button" />
   </children>
</AnchorPane>

HomeScreen (secondary screen):

import com.app.client.java.controllers.ScreenController;
import javafx.fxml.FXML;
import javafx.scene.layout.AnchorPane;

public class HomeScreen extends ScreenController
{
    @FXML
    private static AnchorPane homeWindow = new AnchorPane();

    public HomeScreen()
    {
        super (homeWindow);
    }
}
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.TextArea?>
<?import javafx.scene.layout.AnchorPane?>


<AnchorPane fx:id="homeWindow" prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/8.0.172-ea" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.app.client.java.classes.HomeScreen">
   <children>
      <TextArea layoutX="200.0" layoutY="100.0" prefHeight="200.0" prefWidth="200.0" text="aksajkasjkasja" />
   </children>
</AnchorPane>

I would like to be able to move from the primary screen to the secondary screen using the setScreen() function. However, I'm finding that the process doesn't complete successfully.

Another approach I've found that works is (Although it resizes the window, rather than filling the initial window with the contents of the new one):

Parent root = FXMLLoader.load(getClass().getResource("/com/app/client/resources/fxml/" + screen + ".fxml"));
Stage stage = (Stage) loginWindow.getScene().getWindow();
Scene scene = new Scene(root);
stage.setScene(scene);

However, I'd prefer to use the initial implementation due to it being more concise, readable and, theoretically, provides the exact behaviour I would like.


Solution

  • There are a couple issues with what you currently have:

    1. In your LoginScreen constructor you call setWindow with the value of a yet-to-be-injected field:

      public LoginScreen()
      {
          super();
          setWindow(loginWindow);
      }
      

      No FXML fields will have been injected while the constructor of the controller is executing—meaning loginWindow is null. The reason for this self-evident: The FXMLLoader has to first construct the controller instance before it can start injecting the appropriate fields.

      The order of events are: (1) Controller instantiated, (2) fields injected, (3) initialize method invoked; I believe linking any event handlers/change listeners is included in step two. What this means is any initialization that needs to happen regarding FXML fields should be done in the initialize method.

      You have the same problem in your HomeScreen constructor with super(homeWindow), though there are other problems there which are addressed in the next point.

    2. In addition to trying to access a yet-to-be-injected field in the constructor, there are two other problems with the following:

      @FXML
      private static AnchorPane homeWindow = new AnchorPane();
      

      The first problem is you initialize a field that is meant to be injected. Never do this. A good rule of thumb is: If the field is annotated with @FXML then don't manually assign a value to it. The FXML field will eventually be injected which means any value you assign to it beforehand will simply be replaced. This can lead to subtle problems since any code with a reference to the previous value won't be using the object that was actually added to the scene graph.

      The other problem is your field is static. Injecting static fields is not supported in JavaFX 8+. It used to be possible in older versions, from what I understand, but that behavior was never officially supported (i.e. was an implementation detail). Besides, it doesn't make sense to have something inherently instance-based (FXML+controllers) set a static field which would affect all instances.

      A bonus problem: When you make homeWindow non-static you can no longer use super(homeWindow) because you can't reference it before the super constructor is invoked.

    Using the two modified classes should allow your code to run:

    LoginScreen.java:

    public class LoginScreen extends ScreenController {
    
        @FXML private AnchorPane loginWindow;
        @FXML private Button goButton;
    
        @Override
        public void initialize(URL location, ResourceBundle resources) {
            super.initialize(location, resources);
            setWindow(loginWindow); // set window in initialize method
        }
    
        @FXML
        public void goButtonPressed(ActionEvent event) throws IOException {
            setScreen("Home");
            System.out.println("Success.");
        }
    
    }
    

    HomeScreen.java:

    public class HomeScreen extends ScreenController {
    
        @FXML private AnchorPane homeWindow;
    
        @Override
        public void initialize(URL location, ResourceBundle resources) {
            super.initialize(location, resources);
            setWindow(homeWindow); // set window in initialize method
        }
    
    }
    

    However don't use:

    window.getChildren().setAll(root);
    

    In your ScreenController#setScreen method—it causes a subtle problem. You're adding a root as a child of the window node. But when this happens, the new instance of ScreenController (associated with the new root) has its window == root. In other words, the window created with LoginScreen is now the parent of the window created with HomeScreen. Depending on how a more complex application is designed, this can lead to progressively deeper nesting of "roots".

    That said, you already have another approach where you actually replace the entire Scene. The issue you're having there, as you stated, is that the Stage resizes to fit the new Scene. This can be fixed by replacing the root of the Scene, rather than the Scene itself:

    window.getScene().setRoot(root);
    

    Some potentially helpful resources: