4

I have something like the following simplified two classes, let's call them Person and Order. An Order has a single Person:

class Person {
    final SimpleStringProperty name;

    Person(String name) {
        this.name = new SimpleStringProperty(name);
    }

    @Override
    public String toString() {
        return name.get();
    }
}

class Order {
    final SimpleObjectProperty<Person> person;

    Order(Person person) {
        this.person = new SimpleObjectProperty<>(person);
    }
}

I display the Orders in a TableView, and one column is that of the Order's Person (the name of the Person):

TableView<Order> table = new TableView<>();
TableColumn<Order, Person> col = new TableColumn<>("Person");
col.setCellValueFactory(data -> data.getValue().person);

When I change the name of a Person, I would like the person column to reflect this change. I finally got this to work through a Binding in the column's CellFactory (as described in https://stackoverflow.com/a/67979303/1016514). However, the column is also editable using a ComboBoxTableCell, so double-clicking allows you to select a different person (not a different name, mind you):

class UpdatingCell extends ComboBoxTableCell<Order, Person> {
    public UpdatingCell(ObservableList<Person> people) {
        super(people);
    }

    @Override
    public void startEdit() {
        textProperty().unbind();
        super.startEdit();
    }

    @Override
    public void updateItem(Person item, boolean empty) {
        textProperty().unbind();
        super.updateItem(item, empty);

        if (empty || item == null) {
            setText("");
        } else {
            textProperty().bind(item.name);
        }
    }
}

Without the bind(), the selection in the combo box works (but does not update the name shown in the column); but with the bind(), I get an exception on double-clicking, even though I have explicitly unbound the textProperty in startEdit:

Exception in thread "JavaFX Application Thread" java.lang.RuntimeException: UpdatingCell.text : A bound value cannot be set.
    at javafx.beans.property.StringPropertyBase.set(StringPropertyBase.java:141)
    at javafx.beans.property.StringPropertyBase.set(StringPropertyBase.java:50)
    at javafx.beans.property.StringProperty.setValue(StringProperty.java:71)
    at javafx.scene.control.Labeled.setText(Labeled.java:147)
    at javafx.scene.control.cell.ComboBoxTableCell.startEdit(ComboBoxTableCell.java:354)
    at org.people.UpdatingCell.startEdit(OrderTable.java:50)
    at javafx.scene.control.TableCell.updateEditing(TableCell.java:569)
    at javafx.scene.control.TableCell.lambda$new$3(TableCell.java:141)
    at javafx.beans.WeakInvalidationListener.invalidated(WeakInvalidationListener.java:83)

Debugging shows that the textProperty of the cell has its Observable re-set for obscure reasons somewhere deep within the workings of javafx. I would have thought this to be a common use case with a simpler solution, so I am starting to wonder whether I am going in the wrong direction. How can I make the column both reflect changes of a person's name and allow for the person to be changed?

Here is a the complete example application where a button changes the name of the person in the order. Double-clicking on the person to change it throws an exception. When you comment out the bind in the UpdatingCell, you can select a different person, but the name does not update when you click the button.

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

import javafx.application.Application;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.ComboBoxTableCell;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

class Person {
    final SimpleStringProperty name;

    Person(String name) {
        this.name = new SimpleStringProperty(name);
    }

    @Override
    public String toString() {
        return name.get();
    }
}

class Order {
    final SimpleObjectProperty<Person> person;

    Order(Person person) {
        this.person = new SimpleObjectProperty<>(person);
    }
}

class UpdatingCell extends ComboBoxTableCell<Order, Person> {
    public UpdatingCell(ObservableList<Person> people) {
        super(people);
    }

    @Override
    public void startEdit() {
        textProperty().unbind();
        super.startEdit();
    }

    @Override
    public void updateItem(Person item, boolean empty) {
        textProperty().unbind();
        super.updateItem(item, empty);

        if (empty || item == null) {
            setText("");
        } else {
            // comment out to be able to select person:
            textProperty().bind(item.name);
        }
    }
}

public class OrderTable extends Application {

    private static final List<String> names = Arrays.asList("Anna", "Bob", "Charly");
    private static final AtomicInteger nameIndex = new AtomicInteger();

    private static final ObservableList<Person> people = FXCollections
            .observableArrayList(Arrays.asList(new Person("Zoe"), new Person("Yvonne"), new Person("Xavier")));

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

        Order order = new Order(people.get(0));
        ObservableList<Order> orders = FXCollections.observableArrayList();
        orders.add(order);

        TableView<Order> table = new TableView<>();
        TableColumn<Order, Person> col = new TableColumn<>("Person");
        col.setCellValueFactory(data -> data.getValue().person);
        col.setCellFactory(tc -> new UpdatingCell(people));
        col.setEditable(true);

        table.getColumns().setAll(Collections.singleton(col));
        table.getItems().setAll(orders);
        table.setEditable(true);

        Button nextName = new Button("Next Name");
        nextName.setOnAction(event -> {
            order.person.get().name.set(names.get(nameIndex.getAndIncrement() % names.size()));
            System.out.println("people are %s".formatted(people));
            System.out.println("person is %s".formatted(order.person.get()));
        });

        stage.setScene(new Scene(new VBox(table, nextName), 200, 140));
        stage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}
Hans
  • 2,419
  • 2
  • 30
  • 37

3 Answers3

3

There may be other ways to fix this, but below is my version of fixing this. I hope this may help you to give you some direction.

For the table, to automatically refresh the changes done in Person property, we need to provide the extractor to the ObservableList to listen for changes.

ObservableList<Order> orders = FXCollections.observableArrayList(data -> new Observable[]{data.person});

So any updates to the object property (of person) will update the cells.

But in your case, the object (Person) in ObjectProperty is not updated. Only its internal state is updated. So this will not fire any changes in the TableView.

For the TableView to fire this, we will create a custom ObjectProperty that have access to trigger the value changed method.

class MyObjectProperty<T> extends SimpleObjectProperty<T> {
    public MyObjectProperty(T obj) {
        super(obj);
    }

    @Override
    public void fireValueChangedEvent() {
        super.fireValueChangedEvent();
    }
}

class Order {
    final MyObjectProperty<Person> person;

    Order(Person person) {
        this.person = new MyObjectProperty<>(person);
    }
}

So whenever we modify the Person, we manually call the fireValueChangedEvent() to notify the TableView that the Person object is modified. Then the TableView will reevaluate the updateItems of the cells to reflect the changes.

nextName.setOnAction(event -> {
            order.person.get().name.set(names.get(nameIndex.getAndIncrement() % names.size()));
            order.person.fireValueChangedEvent();
            System.out.println("people are %s".formatted(people));
            System.out.println("person is %s".formatted(order.person.get()));
        });

And one final thing, updating the name of the Person will not immediately reflect in the ComboBox list. So we follow the same extractor pattern for the comboBox list as well.

ObservableList<Person> people = FXCollections.observableArrayList(person -> new Observable[]{person.name});

Combining all the above, below the demo that let the name to update and change the person as well.

enter image description here

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

import javafx.application.Application;
import javafx.beans.Observable;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.ComboBoxTableCell;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

class Person {
    final SimpleStringProperty name;

    Person(String name) {
        this.name = new SimpleStringProperty(name);
    }

    @Override
    public String toString() {
        return name.get();
    }
}

class Order {
    final MyObjectProperty<Person> person;

    Order(Person person) {
        this.person = new MyObjectProperty<>(person);
    }
}

class MyObjectProperty<T> extends SimpleObjectProperty<T> {
    public MyObjectProperty(T obj) {
        super(obj);
    }

    @Override
    public void fireValueChangedEvent() {
        super.fireValueChangedEvent();
    }
}

class UpdatingCell extends ComboBoxTableCell<Order, Person> {
    public UpdatingCell(ObservableList<Person> people) {
        super(people);
    }

    @Override
    public void updateItem(Person item, boolean empty) {
        super.updateItem(item, empty);

        if (empty || item == null) {
            setText("");
        }
    }
}

public class OrderTable extends Application {

    private static final List<String> names = Arrays.asList("Anna", "Bob", "Charly");
    private static final AtomicInteger nameIndex = new AtomicInteger();

    private static final ObservableList<Person> people = FXCollections.observableArrayList(person -> new Observable[]{person.name});

    @Override
    public void start(Stage stage) throws Exception {
        people.addAll(new Person("Zoe"), new Person("Yvonne"), new Person("Xavier"));
        Order order = new Order(people.get(0));
        ObservableList<Order> orders = FXCollections.observableArrayList(data -> new Observable[]{data.person});
        orders.add(order);

        TableView<Order> table = new TableView<>();
        TableColumn<Order, Person> col = new TableColumn<>("Person");
        col.setPrefWidth(100);
        col.setCellValueFactory(data -> data.getValue().person);
        col.setCellFactory(tc -> new UpdatingCell(people));
        col.setEditable(true);

        table.getColumns().setAll(Collections.singleton(col));
        table.getItems().setAll(orders);
        table.setEditable(true);

        Button nextName = new Button("Next Name");
        nextName.setOnAction(event -> {
            order.person.get().name.set(names.get(nameIndex.getAndIncrement() % names.size()));
            order.person.fireValueChangedEvent(); // Call this method to shout out that something happened in Person Object :)
            System.out.println("people are %s".formatted(people));
            System.out.println("person is %s".formatted(order.person.get()));
        });

        stage.setScene(new Scene(new VBox(table, nextName), 250, 140));
        stage.setTitle("Order Demo");
        stage.show();
    }

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

UPDATE:

Instead of firing the change on button action, if you want the Person and Order to automatically handle this, may be you can start with below code. In the below code, you can update the person externally (without the reference of Order) and the changes will reflect in Order table.

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

import javafx.application.Application;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.ComboBoxTableCell;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

class Person {
    final BooleanProperty modified;
    final StringProperty name;
    final IntegerProperty age;

    Person(String name) {
        this.modified = new SimpleBooleanProperty();
        this.name = new SimpleStringProperty(name);
        this.age = new SimpleIntegerProperty(25);

        InvalidationListener listener = p -> modified.set(!modified.getValue());
        this.name.addListener(listener);
        this.age.addListener(listener);
    }

    @Override
    public String toString() {
        return name.get();
    }
}

class Order {
    final MyObjectProperty<Person> person;

    Order(Person person) {
        this.person = new MyObjectProperty<>(person);

        final InvalidationListener listener = p -> this.person.fireValueChangedEvent();
        person.modified.addListener(listener);
        this.person.addListener((obs, old, val) -> {
            if (old != val) {
                if (old != null) {
                    old.modified.removeListener(listener);
                }
                if (val != null) {
                    val.modified.addListener(listener);
                }
            }
        });
    }
}

class MyObjectProperty<T> extends SimpleObjectProperty<T> {
    public MyObjectProperty(T obj) {
        super(obj);
    }

    @Override
    public void fireValueChangedEvent() {
        super.fireValueChangedEvent();
    }
}

class UpdatingCell extends ComboBoxTableCell<Order, Person> {
    public UpdatingCell(ObservableList<Person> people) {
        super(people);
    }

    @Override
    public void updateItem(Person item, boolean empty) {
        super.updateItem(item, empty);

        if (empty || item == null) {
            setText("");
        }
    }
}

public class OrderTable extends Application {

    private static final List<String> names = Arrays.asList("Anna", "Bob", "Charly");
    private static final AtomicInteger nameIndex = new AtomicInteger();

    private static final ObservableList<Person> people = FXCollections.observableArrayList(person -> new Observable[]{person.name});

    @Override
    public void start(Stage stage) throws Exception {
        people.addAll(new Person("Zoe"), new Person("Yvonne"), new Person("Xavier"));
        Order order = new Order(people.get(0));
        ObservableList<Order> orders = FXCollections.observableArrayList(data -> new Observable[]{data.person});
        orders.add(order);

        TableView<Order> table = new TableView<>();
        TableColumn<Order, Person> col = new TableColumn<>("Person");
        col.setPrefWidth(100);
        col.setCellValueFactory(data -> data.getValue().person);
        col.setCellFactory(tc -> new UpdatingCell(people));
        col.setEditable(true);

        table.getColumns().setAll(Collections.singleton(col));
        table.getItems().setAll(orders);
        table.setEditable(true);

        ComboBox<Person> persons = new ComboBox<>();
        persons.setItems(people);
        persons.getSelectionModel().select(0);

        Button nextName = new Button("Next Name");
        nextName.setOnAction(event -> {
            persons.getValue().name.set(names.get(nameIndex.getAndIncrement() % names.size()));
        });

        stage.setScene(new Scene(new VBox(10, table, new HBox(10, persons, nextName)), 250, 140));
        stage.setTitle("Order Demo");
        stage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}
Sai Dandem
  • 8,229
  • 11
  • 26
3

This is an interesting problem. Here is a solution that seems to work, preserving the original structure of the data. The basic idea is to replace the bindings with a listener. This uses ObservableValue.flatMap(), introduced in JavaFX 19, to create a listener on the name property of the cell's item property. (For more information on flatMap and related methods, see this Q/A.)

Instead of the UpdatingCell in the original, use

class UpdatingCell extends ComboBoxTableCell<Order, Person> {

    ObservableValue<String> name = itemProperty().flatMap(person -> person.name);
    public UpdatingCell(ObservableList<Person> people) {
        super(people);
        name.addListener((obs, oldName, newName) -> updateText());
    }

    private void updateText() {
        if (isEditing()) {
            setText(null);
        } else {
            setText(name.getValue());
        }
    }

    @Override
    public void startEdit() {
        super.startEdit();
        updateText();
    }

    @Override
    public void updateItem(Person item, boolean empty) {
        super.updateItem(item, empty);
        updateText();
    }
}

If you cannot use JavaFX 19, for some reason, replace

ObservableValue<String> name = itemProperty().flatMap(person -> person.name);

with

ObservableValue<String> name = new StringBinding() {
    {
        itemProperty().addListener((obs, oldItem, newItem) -> {
            if (oldItem != null) {
                unbind(oldItem.name);
            }
            if (newItem != null) {
                bind(newItem.name);
            }
            invalidate();
        });
        if (getItem() != null) {
            bind(getItem().name);
        }
    }
    @Override
    protected String computeValue() {
        return getItem() == null ? null : getItem().name.getValue();
    }
};

Note there is one other bug in your code, which is somewhat independent. With the setup above, changing the name of a person by using the "Next name" button will not update the cells in the combo box in the editor. To allow this to happen, create the people list with an extractor, e.g.

ObservableList<Person> people = 
            FXCollections.observableList(List.of(new Person("Zoe"), new Person("Yvonne"), new Person("Xavier")), 
                    person -> new Observable[] {person.name});
James_D
  • 201,275
  • 16
  • 291
  • 322
  • 1
    +1 Thanks for highlighting the new API. Not aware of this new API. This is quite helpful for me in my other usecases ;). – Sai Dandem Aug 23 '23 at 00:08
  • 2
    @SaiDandem The new API has an interesting history. It was originally developed as a [third party library](https://github.com/TomasMikula/EasyBind) by Tomas Mikula (also see [this library](https://github.com/TomasMikula/ReactFX), which includes some of the same content). As well as `ObservableValue::map` and `ObservableValue::flatMap` there is also `ObservableValue::orElse` which is quite useful. There are other similar functionality which could be developed (e.g. `ObservableList::map`), and may be under development for future releases. – James_D Aug 23 '23 at 14:01
2

Create your own Property class, for use as a table cell’s Observable value, that wraps a Person property and forwards both reads and writes to the name of that Person property’s value:

private static class NameProperty
extends StringPropertyBase {
    private final Property<Person> person;

    NameProperty(Property<Person> personProperty) {
        this.person = Objects.requireNonNull(personProperty,
            "Person property cannot be null.");

        Person person = personProperty.getValue();
        if (person != null) {
            bindBidirectional(person.name);
        }

        this.person.addListener((o, oldPerson, newPerson) -> {
            if (oldPerson != null) {
                unbindBidirectional(oldPerson.name);
            }

            if (newPerson != null) {
                bindBidirectional(newPerson.name);
            } else {
                set(null);
            }
        });
    }

    @Override
    public String getName() {
        return "name";
    }

    @Override
    public Object getBean() {
        return person.getBean();
    }
}

Here is a complete program demonstrating it:

import java.util.Objects;

import javafx.application.Application;

import javafx.beans.property.Property;
import javafx.beans.property.StringPropertyBase;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.SimpleObjectProperty;

import javafx.geometry.Insets;

import javafx.stage.Stage;

import javafx.scene.Scene;

import javafx.scene.control.Button;
import javafx.scene.control.TableView;
import javafx.scene.control.TableColumn;

import javafx.scene.control.cell.TextFieldTableCell;

import javafx.scene.layout.BorderPane;

public class OrderTable
extends Application {
    @Override
    public void start(Stage stage) {
        TableView<Order> table = new TableView<>();
        table.setEditable(true);

        TableColumn<Order, String> col = new TableColumn<>("Person");
        col.setCellFactory(TextFieldTableCell.forTableColumn());
        col.setCellValueFactory(
            data -> new NameProperty(data.getValue().person));

        table.getColumns().add(col);

        table.getItems().addAll(
            new Order(new Person("John")),
            new Order(new Person("Jane"))
        );

        Button printItemsButton = new Button("Print");
        printItemsButton.setOnAction(e -> {
            table.getItems().forEach(System.out::println);
            System.out.println();
        });

        BorderPane pane = new BorderPane(table,
            null, null, printItemsButton, null);
        BorderPane.setMargin(printItemsButton, new Insets(12));

        stage.setTitle("Orders");
        stage.setScene(new Scene(pane));
        stage.show();
    }

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

    private static class NameProperty
    extends StringPropertyBase {
        private final Property<Person> person;

        NameProperty(Property<Person> personProperty) {
            this.person = Objects.requireNonNull(personProperty,
                "Person property cannot be null.");

            Person person = personProperty.getValue();
            if (person != null) {
                bindBidirectional(person.name);
            }

            this.person.addListener((o, oldPerson, newPerson) -> {
                if (oldPerson != null) {
                    unbindBidirectional(oldPerson.name);
                }

                if (newPerson != null) {
                    bindBidirectional(newPerson.name);
                } else {
                    set(null);
                }
            });
        }

        @Override
        public String getName() {
            return "name";
        }

        @Override
        public Object getBean() {
            return person.getBean();
        }
    }

    static class Person {
        final SimpleStringProperty name;

        Person(String name) {
            this.name = new SimpleStringProperty(name);
        }

        @Override
        public String toString() {
            return name.get();
        }
    }

    static class Order {
        final SimpleObjectProperty<Person> person;

        Order(Person person) {
            this.person = new SimpleObjectProperty<>(person);
        }

        @Override
        public String toString() {
            return "Order[person=" + person.get() + "]";
        }
    }
}
VGR
  • 40,506
  • 4
  • 48
  • 63