Adam
Adam

Reputation: 36703

Integrating Spring with FXML nested controllers

I'm implementing a large application using JavaFX but unsure how to deal with nested controllers and Spring.

My work so far

Problem with using controller factory:

loader.setControllerFactory(new Callback<Class<?>, Object>() {
    @Override
    public Object call(Class<?> param) {
        // OK but doesn't work when multiple instances controller of same type
        return context.getBean(param);
    }
});

Specific questions

Example

Spring context

<context:annotation-config />
<bean id="modelA" class="org.example.Model">
    <property name="value" value="a value" />
</bean>
<bean id="modelB" class="org.example.Model">
    <property name="value" value="b value" />
</bean>

Top level

<HBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.TopLevelController">
   <children>
      <TabPane tabClosingPolicy="UNAVAILABLE">
        <tabs>
          <Tab text="A">
               <content>
                  <fx:include fx:id="a" source="nested.fxml" />
               </content>
          </Tab>
          <Tab text="B">
               <content>
                  <fx:include fx:id="b" source="nested.fxml" />
               </content>
          </Tab>
        </tabs>
      </TabPane>
   </children>
</HBox>

Nested level

<HBox alignment="CENTER" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="200.0" prefWidth="200.0" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.example.NestedController">
   <children>
      <TextField fx:id="value" />
   </children>
</HBox>

Application main

public class NestedControllersSpring extends Application {
    public static void main(String[] args) {
        launch(args);
    }
    @Override
    public void start(Stage stage) throws Exception {

        ClassPathXmlApplicationContext context =
                new ClassPathXmlApplicationContext("/app-context.xml");

        FXMLLoader loader = new FXMLLoader(getClass().getResource("/top-level.fxml"));
        stage.setScene(new Scene(loader.load()));
        TopLevelController top = loader.getController();
        context.getAutowireCapableBeanFactory().autowireBean(top);
        context.getAutowireCapableBeanFactory().autowireBean(top.getAController());
        context.getAutowireCapableBeanFactory().autowireBean(top.getBController());
        top.init(); // needed because autowire doesn't call @PostConstruct
        stage.show();
    }
}

Top level controller

public class TopLevelController {

    @FXML
    private NestedController aController;

    @FXML
    private NestedController bController;

    @Autowired
    @Qualifier("modelA")
    private Model a;

    @Autowired
    @Qualifier("modelB")
    private Model b;

    @PostConstruct
    public void init() {
        aController.setModel(a);
        bController.setModel(b);
    }
    public NestedController getAController() {
        return aController;
    }
    public NestedController getBController() {
        return bController;
    }
}

Upvotes: 2

Views: 4008

Answers (4)

selami tastan
selami tastan

Reputation: 92

Also, can be add "prototype" at the begining of class.

@Component
@Scope("prototype")
public class ExampleController implements Initializable {

Upvotes: 0

Grzegorz Kapica
Grzegorz Kapica

Reputation: 61

It can be done little simpler, without using recursive function:

public class GuiContext {
   private static final ApplicationContext applicationContext = new AnnotationConfigApplicationContext(GuiAppConfiguration.class);

   public Object loadFxml(final String fileName) throws IOException {
    return FXMLLoader.load(App.class.getResource(fileName), null, null, applicationContext::getBean);
   }
}

Upvotes: 1

Adam
Adam

Reputation: 36703

The best I've managed to date...

  • Recursively go through controller wherever an FXML annotation present and not a Node
  • Use autowireBean to hookup @Inject / @Autowire fields
  • Use initializeBean() to call @PostConstruct if present.

Code

public void recursiveWire(ClassPathXmlApplicationContext context, Object root) throws Exception {
    context.getAutowireCapableBeanFactory().autowireBean(root);
    context.getAutowireCapableBeanFactory().initializeBean(root, null);

    for (Field field : root.getClass().getDeclaredFields()) {
        if (field.isAnnotationPresent(FXML.class) && 
            ! Node.class.isAssignableFrom(field.getType())) { 
            // <== assume if not a Node, must be a controller
            recursiveWire(context, field.get(root));
        }
    }
}

Usage

@Override
public void start(Stage stage) throws Exception {

    ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("/app-context.xml");

    FXMLLoader loader = new FXMLLoader(getClass().getResource("/top-level.fxml"));
    stage.setScene(new Scene(loader.load()));
    recursiveWire(context, loader.getController());
    stage.show();
}

Upvotes: 0

James_D
James_D

Reputation: 209330

I don't see a way to do this completely cleanly. As you've noticed, the only information a controller factory gets is the controller class, so there is no way to distinguish between instances. You can use the FXML to provide further values to those controllers, but those values will be set during the FXML initialization phase, which will happen after the Spring initialization phase. (I think this is actually why your @PostConstruct won't work: the @PostConstruct method(s) will be invoked after construction, but before FXML injection.) Spring-injected controllers should always have prototype scope, imho, since you would never want different FXML-loaded elements to share the same controller.

I think the way I would do this would be to inject model instances into the nested controllers using a prototype scope for the model (so each controller gets its own model), and then configure those models from the top-level controller. This still involves some wiring "by hand" (you can't use Spring to inject the values you want directly into the model), but it feels a bit cleaner than the approach you outlined above. Not sure if it will actually work for your real use-case, of course.

So:

application-context.xml:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean id="model" class="application.Model" scope="prototype" />

    <bean id="nestedController" class="application.NestedController" scope="prototype" autowire="byName"/>

    <bean id="topController" class="application.TopLevelController" scope="prototype" />

</beans>

Model.java:

package application;

import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

public class Model {
    private final StringProperty text = new SimpleStringProperty();

    public final StringProperty textProperty() {
        return this.text;
    }

    public final String getText() {
        return this.textProperty().get();
    }

    public final void setText(final String text) {
        this.textProperty().set(text);
    }

}

NestedController.java:

package application;

import javafx.fxml.FXML;
import javafx.scene.control.Label;

public class NestedController {

    @FXML
    private Label label ;

    private Model model ;

    public Model getModel() {
        return model ;
    }

    public void setModel(Model model) {
        this.model = model ;
    }

    public void initialize() {
        label.textProperty().bindBidirectional(model.textProperty());
    }
}

Nested.fxml:

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Tab?>

<BorderPane  xmlns:fx="http://javafx.com/fxml/1" fx:controller="application.NestedController">
    <center>
        <Label fx:id="label"/>
    </center>
</BorderPane>

TopLevel.fxml:

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.control.TabPane?>
<?import javafx.scene.control.Tab?>

<BorderPane xmlns:fx="http://javafx.com/fxml/1"
    fx:controller="application.TopLevelController">
    <center>
        <TabPane>
            <tabs>
                <Tab text="First">
                    <content>
                        <fx:include fx:id="firstTab" source="Nested.fxml" />
                    </content>
                </Tab>
                <Tab>
                    <content>
                        <fx:include fx:id="secondTab" source="Nested.fxml" />
                    </content>
                </Tab>
            </tabs>
        </TabPane>
    </center>
</BorderPane>

The TopLevelController has to wire values into the models, which is a bit of a pain. I'm not sure if this is cleaner than wiring the entire model by hand or not...

package application;

import javafx.fxml.FXML;

public class TopLevelController {

    @FXML
    private NestedController firstTabController ;

    @FXML
    private NestedController secondTabController ;

    public void initialize() {
        firstTabController.getModel().setText("First Tab");
        secondTabController.getModel().setText("Second Tab");
    }
}

The application assembly is fairly straightforward:

package application;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;

import org.springframework.context.support.ClassPathXmlApplicationContext;

public class NestedControllersSpring extends Application {

    @Override
    public void start(Stage primaryStage) throws Exception {
        try (ClassPathXmlApplicationContext context 
                = new ClassPathXmlApplicationContext("application-context.xml")) {

            FXMLLoader loader = new FXMLLoader(getClass().getResource("TopLevel.fxml"));
            loader.setControllerFactory(cls -> context.getBean(cls));

            primaryStage.setScene(new Scene(loader.load(), 600, 400));
            primaryStage.show();
        }
    }

    public static void main(String[] args) {
        launch(args);
    }
}

Upvotes: 4

Related Questions