javajavafx

Keep an editable TableColumn value updated from property


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 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);
    }
}

Solution

  • 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});