JavaFX valueAt() Binding only compute once

339 views Asked by At

We know that ListExpression has a method ObjectBinding<E> valueAt(ObservableIntegerValue). We can use this method to listen to an element of a ListProperty exactly.

I expect it will both bind to the ListProperty and the ObservableNumberValue. So either the list changes or the observable number value changes will make the binding invalid and recompute. But in the following code, the binding is computed only once! (Actually twice, if we don't ignore the initial computation)

The label will display a random string at the beginning. And the property will have 100 Bean as the initial value. If we click button1, the indexProperty will increase by 1. If we click button2, the Bean located at the current index of the ListProperty will change. Both effects will make the binding invalid and recompute the label text.

But in practice, the text will change the first time when one button is clicked. And will not change anymore.

I'm using Liberica JDK17 which contains jmods of JavaFX by default.

class FixmeApp : Application() {

    companion object {
        fun genRandomDouble(): Double = Math.random() * 10000
        fun genRandomString(): String = genRandomDouble().roundToInt().toString(36)
    }

    class Bean {
        val stringProperty = SimpleStringProperty(genRandomString())
        val doubleProperty = SimpleDoubleProperty(genRandomDouble())
    }

    val property: ListProperty<Bean> = SimpleListProperty(FXCollections.observableArrayList(Bean()))

    override fun start(primaryStage: Stage) {
        property.addAll((0..100).map { Bean() })

        val indexProperty = SimpleIntegerProperty(0)

        val label = Label().apply {
            textProperty().bind(Bindings.createStringBinding(
                { genRandomString() },
                property.valueAt(indexProperty)
            ))
        }
        val button1 = Button("Change Index").apply {
            setOnAction {
                indexProperty.set(indexProperty.get() + 1)
            }
        }
        val button2 = Button("Change Bean").apply {
            setOnAction {
                property[indexProperty.get()] = Bean()
            }
        }
        val scene = Scene(BorderPane().apply {
            center = label
            bottom = HBox(button1, button2)
        })

        primaryStage.scene = scene
        primaryStage.show()

    }

}

fun main() {
    Application.launch(FixmeApp::class.java)
}

By the way, if we change the binding dependencies from property.valueAt(indexProperty) to property, indexProperty, the code will run as we expected.

In my program, the binding will return the property of Bean at the location indexProperty.get() of property


Edit (by James_D):

To increase the audience for this question, here is (to the best of my knowledge) a translation of this example to Java.

import javafx.application.Application;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.stage.Stage;

import java.util.Random;

public class FixMeApp extends Application {

    private final Random rng = new Random();

    private final ListProperty<Bean> property = new SimpleListProperty<>(FXCollections.observableArrayList());

    @Override
    public void start(Stage stage) throws Exception {
        for (int i = 0 ; i < 100 ; i++) property.add(new Bean());
        var index = new SimpleIntegerProperty(0);
        var label =  new Label();
        label.textProperty().bind(Bindings.createStringBinding(
                this::generateRandomString,
                property.valueAt(index)
        ));
        var button1 = new Button("Change Index");
        button1.setOnAction(e -> index.set(index.get() + 1));
        var button2 = new Button("Change bean");
        button2.setOnAction(e -> property.set(index.get(), new Bean()));
        var root = new BorderPane(label);
        root.setBottom(new HBox(button1, button2));
        var scene = new Scene(root);
        stage.setScene(scene);
        stage.show();
    }

    private double generateRandomDouble() {
        return rng.nextDouble() * 10000 ;
    }

    private String generateRandomString() {
        return Integer.toString((int) generateRandomDouble());
    }

    class Bean {
        private StringProperty stringProperty = new SimpleStringProperty(generateRandomString());
        private DoubleProperty doubleProperty = new SimpleDoubleProperty(generateRandomDouble());

        StringProperty stringProperty() { return stringProperty; }
        DoubleProperty doubleProperty() { return doubleProperty; }
    }

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

There are 2 answers

6
James_D On BEST ANSWER

I'm going to reply in Java, as I'm more familiar with it. The reasoning applies to kotlin too.

Bindings.createStringBinding(function, dependencies) creates a binding that is invalidated any time any of the dependencies are invalidated. Here "invalidated" means "changes from a valid state to an invalid state". The problem with your code is that you define the dependency as property.valueAt(index), which is a binding to which you have no other reference.

When you change either the index or the bean at that index in the list, then the binding becomes invalid. Since you never compute the value of that binding again, it never becomes valid again (i.e. it never holds, or has returned, a valid value). So subsequent changes will not change its validation state (it is simply still invalid; it cannot transition from valid to invalid).

Changing the code to

label.textProperty().bind(Bindings.createStringBinding(
        () -> property.valueAt(index).get().stringBinding().get(),
        property.valueAt(index)
));

doesn't help: you have one binding used for the dependency (whose value is never computed, so it is never returned to a valid state) and a different binding each time the value is computed. The value of the binding used as the dependency is never computed, so it is never returned to a valid state, and consequently can never become invalidated again.

However

ObjectBinding<Bean> bean = property.valueAt(index);
label.textProperty().bind(Bindings.createStringBinding(
        () -> bean.get().stringProperty().get(),
        bean
));

will work. Here the computation of the text forces bean to compute its current value, returning it to a valid state. Then subsequent changes to the index or the list will invalidate bean again, triggering recomputation of the binding to which text is bound.

I think the kotlin translation of this is


val bean = property.valueAt(indexProperty)
val label = Label().apply {
    textProperty().bind(Bindings.createStringBinding(
        { bean.value.stringProperty.value },
        bean
    ))
}

You can experiment with other variations. This one doesn't update more than once, because bean is never validated:

ObjectBinding<Bean> bean = property.valueAt(index);
label.textProperty().bind(Bindings.createStringBinding(
        this::generateRandomString,
        bean
));

Whereas this one forces validation, so it always updates:

ObjectBinding<Bean> bean = property.valueAt(index);
label.textProperty().bind(Bindings.createStringBinding(
        () -> {
            bean.get();
            return generateRandomString();
        },
        bean
));
2
Meodinger Wang On

Thanks to @James_D, I not only know why this code did not run as expected but also solved another issue related to Bindings.

TL;DR - JavaFX won't automatically validate bindings when the dependencies become invalid. You should validate them by getting the value or somehow.

In the following code, bean will become invalid either property or index changes, and the StringBinding will compute. After the computation, bean is still invalid because we didn't compute to validate it. So next time the dependencies of bean change, the StringBinding will not recompute.

ObjectBinding<Bean> bean = property.valueAt(index);
label.textProperty().bind(Bindings.createStringBinding(
        this::generateRandomString,
        bean
));

But for this one, it is different. Though we never use the binding bean's value, we validate it by getting. So the code runs as expected.

ObjectBinding<Bean> bean = property.valueAt(index);
label.textProperty().bind(Bindings.createStringBinding(
        () -> {
            bean.get();
            return generateRandomString();
        },
        bean
));

And for another situation, be careful! May bean became invalid and during the computation, the flag was false so bean didn't validate. If we don't validate bean somehow somewhere before next time bean's dependencies become invalid again, the binding will not compute.

ObjectBinding<Bean> bean = property.valueAt(index);
label.textProperty().bind(Bindings.createStringBinding(
        () -> {
            if (flag) return bean.value 
            else return generateRandomString();
        },
        bean
));