Search code examples
javafxtableviewjavafx-8propertychangelistener

Why does a TableView's change listener give different results for ObjectProperty<T> vs TProperty columns in JavaFX8?


A relative Java newbie question.

I have a TableView with extractors and a ListChangeListener added to the underlying ObservableList.

If I have a StringProperty column in the data model, the change listener doesn't detect changes if I double-click the cell and then hit ENTER without making any changes. That's good.

However, if I define the column as ObjectProperty<String> and double-click and then hit ENTER, the change listener always detects changes even when none have been made.

Why does that happen? What's the difference between ObjectProperty<String> and StringProperty from a change listener's point of view?

I've read Difference between SimpleStringProperty and StringProperty and JavaFX SimpleObjectProperty<T> vs SimpleTProperty and think I understand the differences. But I don't understand why the change listener is giving different results for TProperty/SimpleTProperty and ObjectProperty<T>.

If it helps, here is a MVCE for my somewhat nonsensical case. I'm actually trying to get a change listener working for BigDecimal and LocalDate columns and have been stuck on it for 5 days. If I can understand why the change listener is giving different results, I might be able to get my code working.

I'm using JavaFX8 (JDK1.8.0_181), NetBeans 8.2 and Scene Builder 8.3.

package test17;

import javafx.application.Application;
import static javafx.application.Application.launch;
import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import javafx.util.converter.DefaultStringConverter;

public class Test17 extends Application {

    private Parent createContent() {

        ObservableList<TestModel> olTestModel = FXCollections.observableArrayList(testmodel -> new Observable[] {
                testmodel.strProperty(),
                testmodel.strObjectProperty()
        });

        olTestModel.add(new TestModel("A", "a"));
        olTestModel.add(new TestModel("B", "b"));

        olTestModel.addListener((ListChangeListener.Change<? extends TestModel > c) -> {
            while (c.next()) {
                if (c.wasUpdated()) {
                    System.out.println("===> wasUpdated() triggered");
                }
            }
        });

        TableView<TestModel> table = new TableView<>();

        TableColumn<TestModel, String> strCol = new TableColumn<>("strCol");
        strCol.setCellValueFactory(cellData -> cellData.getValue().strProperty());
        strCol.setCellFactory(TextFieldTableCell.forTableColumn(new DefaultStringConverter()));
        strCol.setEditable(true);
        strCol.setPrefWidth(100);
        strCol.setOnEditCommit((CellEditEvent<TestModel, String> t) -> {
                ((TestModel) t.getTableView().getItems().get(
                        t.getTablePosition().getRow())
                        ).setStr(t.getNewValue());
        });

        TableColumn<TestModel, String> strObjectCol = new TableColumn<>("strObjectCol");
        strObjectCol.setCellValueFactory(cellData -> cellData.getValue().strObjectProperty());
        strObjectCol.setCellFactory(TextFieldTableCell.forTableColumn(new DefaultStringConverter()));
        strObjectCol.setEditable(true);
        strObjectCol.setPrefWidth(100);
        strObjectCol.setOnEditCommit((CellEditEvent<TestModel, String> t) -> {
            ((TestModel) t.getTableView().getItems().get(
                    t.getTablePosition().getRow())
                    ).setStrObject(t.getNewValue());
        });

        table.getColumns().addAll(strCol, strObjectCol);
        table.setItems(olTestModel);
        table.getSelectionModel().setCellSelectionEnabled(true);
        table.setEditable(true);

        BorderPane content = new BorderPane(table);
        return content;
    }

    public class TestModel {

        private StringProperty str;
        private ObjectProperty<String> strObject;

        public TestModel(
            String str,
            String strObject
        ) {
            this.str = new SimpleStringProperty(str);
            this.strObject = new SimpleObjectProperty(strObject);
        }

        public String getStr() {
            return this.str.get();
        }

        public void setStr(String str) {
            this.str.set(str);
        }

        public StringProperty strProperty() {
            return this.str;
        }

        public String getStrObject() {
            return this.strObject.get();
        }

        public void setStrObject(String strObject) {
            this.strObject.set(strObject);
        }

        public ObjectProperty<String> strObjectProperty() {
            return this.strObject;
        }

    }

    @Override
    public void start(Stage stage) throws Exception {
        stage.setScene(new Scene(createContent()));
        stage.setTitle("Test");
        stage.setWidth(350);
        stage.show();
    }

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

}

Solution

  • The difference can be seen by looking at the source code of StringPropertyBase and ObjectPropertyBase—specfically, their set methods.

    StringPropertyBase

    @Override
    public void set(String newValue) {
        if (isBound()) {
            throw new java.lang.RuntimeException((getBean() != null && getName() != null ?
                    getBean().getClass().getSimpleName() + "." + getName() + " : ": "") + "A bound value cannot be set.");
        }
        if ((value == null)? newValue != null : !value.equals(newValue)) {
            value = newValue;
            markInvalid();
        }
    }
    

    ObjectPropertyBase

    @Override
    public void set(T newValue) {
        if (isBound()) {
            throw new java.lang.RuntimeException((getBean() != null && getName() != null ?
                    getBean().getClass().getSimpleName() + "." + getName() + " : ": "") + "A bound value cannot be set.");
        }
        if (value != newValue) {
            value = newValue;
            markInvalid();
        }
    }
    

    Notice the difference in how they check if the new value is equal to the old value? The StringPropertyBase class checks by using Object.equals whereas the ObjectPropertyBase class uses reference equality (==/!=).

    I can't answer for certain why this difference exists, but I can hazard a guess: An ObjectProperty can hold anything and therefore there's the potential for Object.equals to be expensive; such as when using a List or Set. When coding StringPropertyBase I guess they decided that potential wasn't there, that the semantics of String equality was more important, or both. There may be more/better reasons for why they did what they did, but as I was not involved in development I'm not aware of them.


    Interestingly, if you look at how they handle listeners (com.sun.javafx.binding.ExpressionHelper) you'll see that they check for equality using Object.equals. This equality check only occurs if there are currently ChangeListeners registered—probably to support lazy evaluation when there are no ChangeListeners.

    If the new and old values are equals the ChangeListeners are not notified. This doesn't stop the InvalidationListeners from being notified, however. Thus, your ObservableList will fire an update change because that mechanism is based on InvalidationListeners and not ChangeListeners.

    Here's the relevant source code:

    ExpressionHelper$Generic.fireValueChangedEvent

    @Override
    protected void fireValueChangedEvent() {
        final InvalidationListener[] curInvalidationList = invalidationListeners;
        final int curInvalidationSize = invalidationSize;
        final ChangeListener<? super T>[] curChangeList = changeListeners;
        final int curChangeSize = changeSize;
    
        try {
            locked = true;
            for (int i = 0; i < curInvalidationSize; i++) {
                try {
                    curInvalidationList[i].invalidated(observable);
                } catch (Exception e) {
                    Thread.currentThread().getUncaughtExceptionHandler().uncaughtException(Thread.currentThread(), e);
                }
            }
            if (curChangeSize > 0) {
                final T oldValue = currentValue;
                currentValue = observable.getValue();
                final boolean changed = (currentValue == null)? (oldValue != null) : !currentValue.equals(oldValue);
                if (changed) {
                    for (int i = 0; i < curChangeSize; i++) {
                        try {
                            curChangeList[i].changed(observable, oldValue, currentValue);
                        } catch (Exception e) {
                            Thread.currentThread().getUncaughtExceptionHandler().uncaughtException(Thread.currentThread(), e);
                        }
                    }
                }
            }
        } finally {
            locked = false;
        }
    }
    

    And you can see this behavior in the following code:

    import javafx.beans.property.ObjectProperty;
    import javafx.beans.property.SimpleObjectProperty;
    
    public class Main {
    
      public static void main(String[] args) {
        ObjectProperty<String> property = new SimpleObjectProperty<>("Hello, World!");
        property.addListener(obs -> System.out.printf("Property invalidated: %s%n", property.get()));
        property.addListener((obs, ov, nv) -> System.out.printf("Property changed: %s -> %s%n", ov, nv));
        property.get(); // ensure valid
    
        property.set(new String("Hello, World!")); // must not use interned String
        property.set("Goodbye, World!");
      }
    
    }
    

    Output:

    Property invalidated: Hello, World!
    Property invalidated: Goodbye, World!
    Property changed: Hello, World! -> Goodbye, World!