Search code examples
javajavafxjavafx-bindingseasybind

Binding to a ObservableValue<ObservableList> instead of an ObservableList with EasyBind


I've got a master/detail panel with ModelItem items. Each ModelItem has a ListProperty<ModelItemDetail>, and each ModelItemDetail has a few StringPropertys.

In the detail panel, I want to show a Label that will have its text bounded to and derived from the properties of each ModelItemDetail of the currently selected ModelItem. The final value may depend on other external properties, such as having a CheckBox on the Detail panel selected (i.e. if the checkbox is selected, the values of bProperty aren't included in the result).

This binding accomplishes what I want, using Bindings.createStringBinding():

ObservableValue<ModelItem> selectedItemBinding = EasyBind.monadic(mdModel.selectedItemProperty()).orElse(new ModelItem());

// API Label Binding
apiLabel.textProperty().bind(Bindings.createStringBinding( 
    () -> selectedItemBinding.getValue().getDetails().stream()
        .map(i -> derivedBinding(i.aProperty(), i.bProperty()))
        .map(v->v.getValue())
        .collect(Collectors.joining(", "))
    , mdModel.selectedItemProperty(), checkBox.selectedProperty()));

With for instance:

private ObservableValue<String> derivedBinding(ObservableValue<String> aProp, ObservableValue<String> bProp) {
    return EasyBind.combine(aProp, bProp, checkBox.selectedProperty(), 
            (a, b, s) -> Boolean.TRUE.equals(s) ? new String(a + " <" + b + ">") : a);
}

I recently found out about EasyBind, and I'm trying to replace some API Bindings with it. I can't find a way to express this binding with EasyBind. Apparently, the main problem with my code is that because the selectedItem is a property, I can't use its details as an ObservableList, and I must stick to an ObservableValue<ObservableList>>. This is inconvenient to chain the transformations through EasyBind.map(ObservableList) and EasyBind.combine(ObservableList), which seem ideal candidates to implement this binding. At some point I've thought of creating a local ListProperty and binding it to the selectedItem's details through a listener on selectedItem, but it just looks too verbose and unclean.

I've tried forcing the EasyBind API like this:

ObservableValue<ObservableList<ModelItemDetail>> ebDetailList = EasyBind.select(selectedItemBinding).selectObject(ModelItem::detailsProperty);
MonadicObservableValue<ObservableList<ObservableValue<String>>> ebDerivedList = EasyBind.monadic(ebDetailList).map(x->EasyBind.map(x, i -> derivedBinding(i.aProperty(), i.bProperty())));
MonadicObservableValue<ObservableValue<String>> ebDerivedValueBinding = ebDerivedList.map(x->EasyBind.combine(x, stream -> stream.collect(Collectors.joining(", "))));
easyBindLabel.textProperty().bind(ebDerivedValueBinding.getOrElse(new ReadOnlyStringWrapper("Nothing to see here, move on")));

But I've got the feeling the last getOrElse is only getting invoked at initialization time and doesn't update when selectedItem changes.

I've also tried getting the ObservableList right away, but couldn't expect any other thing that an empty list:

ObservableList<ModelItemDetail> ebDetailList = EasyBind.select(selectedItemBinding).selectObject(ModelItem::detailsProperty).get();
ObservableList<ObservableValue<String>> ebDerivedList = EasyBind.map(ebDetailList, i -> derivedBinding(i.aProperty(), i.bProperty()));
ObservableValue<String> ebDerivedValueBinding = EasyBind.combine(ebDerivedList, stream -> stream.collect(Collectors.joining(", "))).orElse("Nothing to see here, move on");
easyBindLabel2.textProperty().bind(ebDerivedValueBinding);

I've even tried using EasyBind.subscribe to listen to selectedItem changes and re-bind (not too sure about this but I don't think rebinding would be needed, everything is there to perform the calculations):

EasyBind.subscribe(selectedItemBinding, newValue -> {
    if (newValue != null) {
            ObservableList<ObservableValue<String>> l = 
                EasyBind.map(newValue.getDetails(), 
                             i -> derivedBinding(i.aProperty(), i.bProperty()));
            easyBindLabelSub.textProperty().bind(
                    EasyBind.combine(l, 
                            strm -> strm.collect(Collectors.joining(", "))
                    ));}});

This works partially, actually it's listening to checkbox changes but curiously only to the first change. I don't have a clue why (would be great knowing). If I add another EasyBind.Subscribe to subscribe to checkbox.selectedProperty, it works as intended, but this also too verbose and unclean. Same happens if I add an API listener myself to the selectedItemProperty and perform the binding there.

My motivation to use EasyBind to express this binding is precisely get rid of the need to explicitly express dependencies for the binding, and try to simplify it further. All the approaches i've come up with are notably worse than the API one, as much as I'm not fully satisfied with it.

I'm still quite new to JavaFX and I'm trying to wrap my head around this. I want to understand what's happening, and find out if there is a short concise and elegant way to express this Binding with EasyBind. I'm starting to wonder if EasyBind just isn't prepared for this use case (which by the way I don't think is that rare). Probably I am missing something trivial, though.

Here is a MVCE showing a few of the approaches I've tried, and the API Binding working as intended:

package mcve.javafx;

import java.util.*;
import java.util.stream.*;

import javafx.application.*;
import javafx.beans.binding.*;
import javafx.beans.property.*;
import javafx.beans.value.*;
import javafx.collections.*;
import javafx.scene.*;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.*;

import org.fxmisc.easybind.*;
import org.fxmisc.easybind.monadic.*;

public class Main extends Application {
    public static void main(String[] args) {
        launch(args);
    }

    private CheckBox checkShowMore;

    @Override
    public void start(Stage primaryStage) {
        try {
            // Initialize model
            MasterDetailModel mdModel = new MasterDetailModel();
            ObservableList<ModelItem> itemsList = FXCollections.observableArrayList();
            for (int i=0;i<5;i++) { itemsList.add(newModelItem(i)); }

            // Master
            ListView<ModelItem> listView = new ListView<ModelItem>();
            listView.setItems(itemsList);
            listView.setPrefHeight(150);
            mdModel.selectedItemProperty().bind(listView.getSelectionModel().selectedItemProperty());

            //Detail
            checkShowMore = new CheckBox();
            checkShowMore.setText("Show more details");
            VBox detailVBox = new VBox();           
            Label apiLabel = new Label();
            Label easyBindLabel = new Label();
            Label easyBindLabel2 = new Label();
            Label easyBindLabelSub = new Label();
            Label easyBindLabelLis = new Label();
            detailVBox.getChildren().addAll(
                    checkShowMore, 
                    new TitledPane("API Binding", apiLabel), 
                    new TitledPane("EasyBind Binding", easyBindLabel),
                    new TitledPane("EasyBind Binding 2", easyBindLabel2),
                    new TitledPane("EasyBind Subscribe", easyBindLabelSub),
                    new TitledPane("Listener+EasyBind Approach", easyBindLabelLis)
            );

            // Scene
            Scene scene = new Scene(new VBox(listView, detailVBox),400,400);
            primaryStage.setScene(scene);
            primaryStage.setTitle("JavaFX/EasyBind MVCE");

            // --------------------------
            // -------- BINDINGS --------
            // --------------------------
            ObservableValue<ModelItem> selectedItemBinding = EasyBind.monadic(mdModel.selectedItemProperty()).orElse(new ModelItem());

            // API Label Binding
            apiLabel.textProperty().bind(Bindings.createStringBinding( 
                () -> selectedItemBinding.getValue().getDetails().stream()
                    .map(i -> derivedBinding(i.aProperty(), i.bProperty()))
                    .map(v->v.getValue())
                    .collect(Collectors.joining(", "))
                , mdModel.selectedItemProperty(), checkShowMore.selectedProperty()));

            // EasyBind Binding Approach 1
            {
            ObservableValue<ObservableList<ModelItemDetail>> ebDetailList = EasyBind.select(selectedItemBinding).selectObject(ModelItem::detailsProperty);
            MonadicObservableValue<ObservableList<ObservableValue<String>>> ebDerivedList = EasyBind.monadic(ebDetailList).map(x->EasyBind.map(x, i -> derivedBinding(i.aProperty(), i.bProperty())));
            MonadicObservableValue<ObservableValue<String>> ebDerivedValueBinding = ebDerivedList.map(x->EasyBind.combine(x, stream -> stream.collect(Collectors.joining(", "))));
            easyBindLabel.textProperty().bind(ebDerivedValueBinding.getOrElse(new ReadOnlyStringWrapper("Nothing to see here, move on")));
            }

            // EasyBind Binding Approach 2
            {
            ObservableList<ModelItemDetail> ebDetailList = EasyBind.select(selectedItemBinding).selectObject(ModelItem::detailsProperty).get();
            ObservableList<ObservableValue<String>> ebDerivedList = EasyBind.map(ebDetailList, i -> derivedBinding(i.aProperty(), i.bProperty()));
            ObservableValue<String> ebDerivedValueBinding = EasyBind.combine(ebDerivedList, stream -> stream.collect(Collectors.joining(", "))).orElse("Nothing to see here, move on");
            easyBindLabel2.textProperty().bind(ebDerivedValueBinding);
            }

            // Subscribe approach
            EasyBind.subscribe(selectedItemBinding, newValue -> {
                if (newValue != null) {
                        ObservableList<ObservableValue<String>> l = EasyBind.map(newValue.getDetails(), i -> derivedBinding(i.aProperty(), i.bProperty()));
                        easyBindLabelSub.textProperty().bind(
                                EasyBind.combine(l, 
                                        strm -> strm.collect(Collectors.joining(", "))
                                ));
                }
            });
            //With this it works as intended, but something feels very wrong about this
             /*
            EasyBind.subscribe(checkShowMore.selectedProperty(), newValue -> {
                if (selectedItemBinding != null) {
                        ObservableList<ObservableValue<String>> l = EasyBind.map(selectedItemBinding.getValue().getDetails(), i -> derivedBinding(i.aProperty(), i.bProperty()));
                        easyBindLabelSub.textProperty().bind(
                                EasyBind.combine(l, 
                                        strm -> strm.collect(Collectors.joining(", "))
                                ));
                }
                });
            */

            // Listener approach
            selectedItemBinding.addListener( (ob, o, n) -> {
                ObservableList<ModelItemDetail> ebDetailList = n.getDetails();
                ObservableList<ObservableValue<String>> ebDerivedList = EasyBind.map(ebDetailList, i -> derivedBinding(i.aProperty(), i.bProperty()));
                ObservableValue<String> ebDerivedValueBinding = EasyBind.combine(ebDerivedList, stream -> stream.collect(Collectors.joining(", "))).orElse("Nothing to see here, move on");
                easyBindLabelLis.textProperty().bind(ebDerivedValueBinding);                
            });





            primaryStage.show();
        } catch(Exception e) {
            e.printStackTrace();
        }
    }

    private ObservableValue<String> derivedBinding(ObservableValue<String> aProp, ObservableValue<String> bProp) {
        return EasyBind.combine(aProp, bProp, checkShowMore.selectedProperty(), 
                (a, b, s) -> Boolean.TRUE.equals(s) ? new String(a + " <" + b + ">") : a);
    }   

    private ModelItem newModelItem(int number) { 
        ModelItem item = new ModelItem();
        item.itemNumber = number+1;
        for (int i=0;i<2;i++) { 
            ModelItemDetail detail = new ModelItemDetail();
            detail.setA("A" + (i+item.itemNumber));
            detail.setB("B" + (i+item.itemNumber));
            item.getDetails().add(detail);
        }
        return item;
    }

    /** GUI Model class */ 
    private static class MasterDetailModel {
        private ObjectProperty<ModelItem> selectedItemProperty = new SimpleObjectProperty<>();
        public ObjectProperty<ModelItem> selectedItemProperty() { return selectedItemProperty; }
        public ModelItem getSelectedItem() { return selectedItemProperty.getValue(); }
        public void setSelectedItem(ModelItem item) { selectedItemProperty.setValue(item); }
    }

    /** Domain Model class */
    private static class ModelItem { 
        int itemNumber;
        private ListProperty<ModelItemDetail> detailsProperty = new SimpleListProperty<>(FXCollections.observableArrayList());
        public ListProperty<ModelItemDetail> detailsProperty() { return detailsProperty; }
        public ObservableList<ModelItemDetail> getDetails() { return detailsProperty.getValue(); }
        public void setDetails(List<ModelItemDetail> details) { detailsProperty.setValue(FXCollections.observableList(details)); }
        public String toString() { return "Item " + itemNumber; }
    }

    /** Domain Model class */
    private static class ModelItemDetail {
        private StringProperty aProperty = new SimpleStringProperty();
        public StringProperty aProperty() { return aProperty; }
        public String getA() { return aProperty.get(); }
        public void setA(String a) { aProperty.set(a); }

        private StringProperty bProperty = new SimpleStringProperty();
        public StringProperty bProperty() { return bProperty; }
        public String getB() { return bProperty.get(); }
        public void setB(String b) { bProperty.set(b); }
    }
}

UPDATE: I've made some progress.

The following code works fine, but mysteriouysly still keeps listening only to the first change on the CheckBox:

ListProperty<ModelItemDetail> obsList = new SimpleListProperty<>(FXCollections.observableArrayList(i->new Observable[] { i.aProperty(), i.bProperty(), checkShowMore.selectedProperty()}));
obsList.bind(selectedItemBinding.flatMap(ModelItem::detailsProperty));
ObservableList<ModelItemDetail> ebDetailList = obsList; // WHY ??
ObservableList<ObservableValue<String>> ebDerivedList = EasyBind.map(ebDetailList, i -> derivedBinding(i.aProperty(), i.bProperty()));
ObservableValue<String> ebDerivedValueBinding = EasyBind.combine(ebDerivedList, stream -> stream.collect(Collectors.joining(", "))).orElse("Nothing to see here, move on");
labelPlayground.textProperty().bind(ebDerivedValueBinding);

Apparently the main reason I was having trouble is because I didn't see how to get an ObservableList from the bound current selectedItem with the EasyBind fluent API. Declaring a local ListProperty and binding it to the selected item's I could take advantage of ListProperty being an ObservableList. I think EasyBind somewhere doesn't follow. Feels like type information is getting lost somewhere. I can't put together all these variables in this last code, and I don't understand why EasyBind.map() will accept ebDetailList in this last code, but won't accept obsList.

So, the question now is, why is this binding listening to CheckBox events only the first time? The extractor in the backing list for the ListProperty doesn't do anything. I guess the obsList.bind() is replacing the backing list with the one in the Model, which has no extractors.


Solution

  • After some time and practice and getting more familiar with Bindings, Properties and Observables, I came up with what I was looking for. A simple, powerful, concise and type-safe EasyBind expression that doesn't need listeners, duplicating or explicitly stating binding dependencies, or extractors. Definitely looks much better than the Bindings API version.

     labelWorking.textProperty().bind(
        selectedItemBinding
        .flatMap(ModelItem::detailsProperty)
        .map(l -> derivedBinding(l))
        .flatMap(l -> EasyBind.combine(
                 l, stream -> stream.collect(Collectors.joining(", "))))
        );
    

    With

    private ObservableList<ObservableValue<String>> derivedBinding(ObservableList<ModelItemDetail> l) { 
            return l.stream()
                    .map(c -> derivedBinding(c.aProperty(), c.bProperty()))
                    .collect(Collectors.toCollection(FXCollections::observableArrayList));
        }
    

    There are apparently some bugs with type inference in Eclipse/javac. That didn't help getting things clear when I was trying to find the right expression letting the IDE guide me.

    The MVCE with the working binding for the sake of completeness:

    package mcve.javafx;
    
    import java.util.List;
    import java.util.stream.Collectors;
    
    import org.fxmisc.easybind.EasyBind;
    import org.fxmisc.easybind.monadic.MonadicBinding;
    
    import javafx.application.Application;
    import javafx.beans.Observable;
    import javafx.beans.binding.Binding;
    import javafx.beans.binding.Bindings;
    import javafx.beans.property.ListProperty;
    import javafx.beans.property.ObjectProperty;
    import javafx.beans.property.SimpleListProperty;
    import javafx.beans.property.SimpleObjectProperty;
    import javafx.beans.property.SimpleStringProperty;
    import javafx.beans.property.StringProperty;
    import javafx.beans.value.ObservableValue;
    import javafx.collections.FXCollections;
    import javafx.collections.ObservableList;
    import javafx.scene.Scene;
    import javafx.scene.control.CheckBox;
    import javafx.scene.control.Label;
    import javafx.scene.control.ListView;
    import javafx.scene.control.TitledPane;
    import javafx.scene.layout.VBox;
    import javafx.stage.Stage;
    
    public class Main extends Application {
        public static void main(String[] args) {
            launch(args);
        }
    
        private CheckBox checkShowMore;
    
        @Override
        public void start(Stage primaryStage) {
            try {
    
    
                // Initialize model
                MasterDetailModel mdModel = new MasterDetailModel();
                ObservableList<ModelItem> itemsList = FXCollections.observableArrayList();
                for (int i=0;i<5;i++) { itemsList.add(newModelItem(i)); }
    
                MonadicBinding<ModelItem> selectedItemBinding = EasyBind.monadic(mdModel.selectedItemProperty()).orElse(new ModelItem());
    
                // Master
                ListView<ModelItem> listView = new ListView<ModelItem>();
                listView.setItems(itemsList);
                listView.setPrefHeight(150);
                mdModel.selectedItemProperty().bind(listView.getSelectionModel().selectedItemProperty());
    
                //Detail
                checkShowMore = new CheckBox();
                checkShowMore.setText("Show more details");
                VBox detailVBox = new VBox();           
                Label apiLabel = new Label();
                Label labelPlayground = new Label();
                detailVBox.getChildren().addAll(
                        checkShowMore, 
                        new TitledPane("API Binding", apiLabel), 
                        new TitledPane("EasyBind", labelPlayground)
                );
    
    
                // Scene
                Scene scene = new Scene(new VBox(listView, detailVBox),400,400);
                primaryStage.setScene(scene);
                primaryStage.setTitle("JavaFX/EasyBind MVCE");
    
                // --------------------------
                // -------- BINDINGS --------
                // --------------------------
    
                // API Label Binding
    
                apiLabel.textProperty().bind(Bindings.createStringBinding( 
                    () -> selectedItemBinding.getValue().getDetails().stream()
                        .map(i -> derivedBinding(i.aProperty(), i.bProperty()))
                        .map(v->v.getValue())
                        .collect(Collectors.joining(", "))
                    , mdModel.selectedItemProperty(), checkShowMore.selectedProperty()));
    
                // EasyBind non-working attempt
                /*
                ListProperty<ModelItemDetail> obsList = new SimpleListProperty<>(FXCollections.observableArrayList(i->new Observable[] { i.aProperty(), i.bProperty(), checkShowMore.selectedProperty()}));
                obsList.bind(selectedItemBinding.flatMap(ModelItem::detailsProperty));
                ObservableList<ModelItemDetail> ebDetailList = obsList; // WHY ??
                ObservableList<ObservableValue<String>> ebDerivedList = EasyBind.map(ebDetailList, i -> derivedBinding(i.aProperty(), i.bProperty()));
                ObservableValue<String> ebDerivedValueBinding = EasyBind.combine(ebDerivedList, stream -> stream.collect(Collectors.joining(", "))).orElse("Nothing to see here, move on");
                labelPlayground.textProperty().bind(ebDerivedValueBinding);
                */
    
                // Working EasyBind Binding
                labelPlayground.textProperty().bind(
                        selectedItemBinding
                        .flatMap(ModelItem::detailsProperty)
                        .map(l -> derivedBinding(l))
                        .flatMap(l -> EasyBind.combine(l, stream -> stream.collect(Collectors.joining(", "))))
                        );
    
                primaryStage.show();
            } catch(Exception e) {
                e.printStackTrace();
            }
        }
    
        private ObservableList<ObservableValue<String>> derivedBinding(ObservableList<ModelItemDetail> l) { 
            return l.stream()
                    .map(c -> derivedBinding(c.aProperty(), c.bProperty()))
                    .collect(Collectors.toCollection(FXCollections::observableArrayList));
        }
    
        private Binding<String> derivedBinding(ObservableValue<String> someA, ObservableValue<String> someB ) { 
            return EasyBind.combine(someA, someB, checkShowMore.selectedProperty(), 
                            (a, e, s) -> a + (Boolean.TRUE.equals(s) ? " <" + e + ">" : ""));
        }
    
        private ModelItem newModelItem(int number) { 
            ModelItem item = new ModelItem();
            item.itemNumber = number+1;
            for (int i=0;i<2;i++) { 
                ModelItemDetail detail = new ModelItemDetail("A" + (i+item.itemNumber), "B" + (i+item.itemNumber));
                item.getDetails().add(detail);
            }
            return item;
        }
    
        /** GUI Model class */ 
        private static class MasterDetailModel {
            private ObjectProperty<ModelItem> selectedItemProperty = new SimpleObjectProperty<>();
            public ObjectProperty<ModelItem> selectedItemProperty() { return selectedItemProperty; }
            public ModelItem getSelectedItem() { return selectedItemProperty.getValue(); }
            public void setSelectedItem(ModelItem item) { selectedItemProperty.setValue(item); }
        }
    
        /** Domain Model class */
        private static class ModelItem { 
            int itemNumber;
            private ListProperty<ModelItemDetail> detailsProperty = new SimpleListProperty<>(FXCollections.observableArrayList());
            public ListProperty<ModelItemDetail> detailsProperty() { return detailsProperty; }
            public ObservableList<ModelItemDetail> getDetails() { return detailsProperty.getValue(); }
            public void setDetails(List<ModelItemDetail> details) { detailsProperty.setValue(FXCollections.observableList(details)); }
            public String toString() { return "Item " + itemNumber; }
        }
    
        /** Domain Model class */
        private static class ModelItemDetail {
    
            public ModelItemDetail(String a, String b) { 
                setA(a);
                setB(b);
            }
    
            private StringProperty aProperty = new SimpleStringProperty();
            public StringProperty aProperty() { return aProperty; }
            public String getA() { return aProperty.get(); }
            public void setA(String a) { aProperty.set(a); }
    
            private StringProperty bProperty = new SimpleStringProperty();
            public StringProperty bProperty() { return bProperty; }
            public String getB() { return bProperty.get(); }
            public void setB(String b) { bProperty.set(b); }
        }
    }