Tymur Berezhnoi
Tymur Berezhnoi

Reputation: 706

Pass/bind a collection to a custom component (extended from vbox) in FXML via parameters

In my application I have declared a custom component like this:

@DefaultProperty("todoItems")
public class TodoItemsVBox extends VBox {
    private ObservableList<TodoItem> todoItems;

    // Setter/Getter omitted
}

and now somewhere in the fxml I want to use the TodoItemsVBox component like this:

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

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>

<BorderPane prefHeight="600" prefWidth="500.0" xmlns="http://javafx.com/javafx/11.0.1" 
xmlns:fx="http://javafx.com/fxml/1" 
fx:controller="com.todolist.controller.TodoListController"
        stylesheets="@../css/app.css">
<top>
    <HBox spacing="10.0">
        <TextField fx:id="input" layoutX="35.0" layoutY="64.0" prefWidth="431.0" promptText="Enter todo task" HBox.hgrow="ALWAYS" onAction="#addTask"/>
        <Button layoutX="216.0" layoutY="107.0" mnemonicParsing="false" onAction="#addTask" prefHeight="27.0" prefWidth="70.0" text="Add" HBox.hgrow="ALWAYS" />
        <padding>
            <Insets bottom="20.0" left="20.0" right="20.0" top="20.0" />
        </padding>
    </HBox>
</top>
<center>
    <ScrollPane fitToHeight="true" fitToWidth="true" prefHeight="200.0" prefWidth="200.0" BorderPane.alignment="CENTER">
        <TodoItemsVBox fx:id="todoItemsVBox" todoItems="${todoTasks}"/>
    </ScrollPane>
</center>

... so as we can see the fxml has it's controller TodoListController

public class TodoListController implements {
    private final ObservableList<TodoItem> todoTasks = FXCollections.observableArrayList(/*Fill in the collection somehow - for now doesn't matter*/);

    @FXML
    private TodoItemsVBox todoItemsVBox;

    // Setter/Getter omitted
}

So, here what I want to do: pass todoTasks into TodoItemsVBox defined in the FXML via such construction: todoItems="${todoTasks}" ---- unfortunately this doesn't work as I expected, because fxml files load before controllers initialised so todoTasks is always null. I also tried @NamedArg with one arg constructor in TodoItemsVBox - it even fails with exception: "Cannot bind to untyped object.”.

Could some one suggest a solution how to pass a collection of objects, defined in a controller, into a custom component via it's parameters?

Upvotes: 0

Views: 343

Answers (1)

James_D
James_D

Reputation: 209330

There are two issues with the code as you have it:

  1. For FXML expression binding, you need to expose properties from your class, not just the values themselves. This applies to ObservableLists as well as regular values. So your TodoItemsVBox class needs to expose a ListProperty todoItemsProperty()
  2. FXML expression bindings (i.e. ${todoTasks}) reference the FXMLLoader's namespace, not the controller. The controller is automatically injected into the namespace (with key "controller"), so, given that the task list is stored in your controller (which is not necessarily a good idea) you can use ${controller.todoTasks} here.

Here's a minimal, complete version of your app which works.

A basic TodoItem.java:

public class TodoItem {

    private final String name ;
    public TodoItem(String name) {
        this.name = name ;
    }
    public String getName() {
        return name ;
    }
}

A TodoItemsVBox that exposes the list as a property:

import javafx.beans.property.ListProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.collections.ListChangeListener.Change;
import javafx.collections.ObservableList;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;

public class TodoItemsVBox extends VBox {

    private ListProperty<TodoItem> todoItems = new SimpleListProperty<>();

    public TodoItemsVBox() {
        // not efficient but works for demo:
        todoItems.addListener((Change<? extends TodoItem> c) -> rebuildView());
    }

    private void rebuildView() {
        getChildren().clear();
        todoItems.stream()
            .map(TodoItem::getName)
            .map(Label::new)
            .forEach(getChildren()::add);
    }

    public ListProperty<TodoItem> todoItemsProperty() {
        return todoItems ;
    }

    public ObservableList<TodoItem> getTodoItems() {
        return todoItemsProperty().get() ;
    }

}

A simple controller:

import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.control.TextField;

public class TodoListController  {
    private ObservableList<TodoItem> todoTasks = FXCollections.observableArrayList();

    // not actually needed...
    @FXML
    private TodoItemsVBox todoItemsVBox;

    @FXML
    private TextField input ;


    public ObservableList<TodoItem> getTodoTasks() {
        return todoTasks;
    }


    @FXML
    private void addTask() {
        todoTasks.add(new TodoItem(input.getText()));
    }
}

The FXML file (TodoList.fxml):

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

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>

<?import org.jamesd.examples.TodoItemsVBox ?>

<BorderPane prefHeight="600" prefWidth="500.0" xmlns="http://javafx.com/javafx/11.0.1" 
xmlns:fx="http://javafx.com/fxml/1" 
fx:controller="com.todolist.controller.TodoListController"
>

<top>
    <HBox spacing="10.0">
        <TextField fx:id="input" promptText="Enter todo task" HBox.hgrow="ALWAYS" onAction="#addTask"/>
        <Button onAction="#addTask" text="Add" HBox.hgrow="ALWAYS" />
        <padding>
            <Insets bottom="20.0" left="20.0" right="20.0" top="20.0" />
        </padding>
    </HBox>
</top>
<center>
    <ScrollPane fitToWidth="true" prefHeight="200.0" prefWidth="200.0" BorderPane.alignment="CENTER">
        <TodoItemsVBox fx:id="todoItemsVBox" todoItems="${controller.todoTasks}"/>
    </ScrollPane>
</center>
</BorderPane>

And finally the application class:

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

public class TodoApp extends Application {

    @Override
    public void start(Stage primaryStage) throws Exception {
        FXMLLoader loader = new FXMLLoader(getClass().getResource("TodoList.fxml"));
        Scene scene = new Scene(loader.load());
        primaryStage.setScene(scene);
        primaryStage.show();
    }

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

}

Really, a controller is no place to be storing data; you should have a separate model class to do that, which is shared between the controller and view. This is reasonably straightforward to do here; you just need to do a little more work with the FXMLLoader (namely putting the model in the namespace, and manually creating and setting the controller).

For example:

import javafx.collections.FXCollections;
import javafx.collections.ObservableList;

public class TodoModel {

    private ObservableList<TodoItem> todoTasks = FXCollections.observableArrayList();

    public ObservableList<TodoItem> getTodoTasks() {
        return todoTasks;
    }
}

Then your controller becomes:

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

public class TodoListController  {

    // not actually needed...
    @FXML
    private TodoItemsVBox todoItemsVBox;

    @FXML
    private TextField input ;

    private TodoModel model ;

    public TodoModel getModel() {
        return model;
    }

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

    @FXML
    private void addTask() {
        model.getTodoTasks().add(new TodoItem(input.getText()));
    }
}

Modify the FXML to use

<TodoItemsVBox fx:id="todoItemsVBox" todoItems="${model.todoTasks}"/>

And finally assemble the application with

public void start(Stage primaryStage) throws Exception {

    TodoModel model = new TodoModel();

    FXMLLoader loader = new FXMLLoader(getClass().getResource("TodoList.fxml"));
    loader.getNamespace().put("model", model);
    Scene scene = new Scene(loader.load());

    TodoListController controller = loader.getController();
    controller.setModel(model);

    primaryStage.setScene(scene);
    primaryStage.show();
}

The advantage to this approach is that your data are now separated from the UI (both view and controller), which becomes essential if you want to access the same data in another part of the UI (which would use another FXML and another controller).

Upvotes: 1

Related Questions