Search code examples
javafxjavafx-bindings

Bind CheckBoxTableCell to BooleanBinding


I want to bind a CheckBox in a TableViewCell to a BooleanBinding. The following sample consists of a TableView with a column name and isEffectiveRequired. The checkbox in the column is bound to the Expression: isRequired.or(name.isEqualTo("X"))

So an item is "effectivly required" when the item in the row is required OR the name is an X, then the expression should be true. Unfortunately the CheckBox does not reflect the change. For debugging I added a textfield, showing the nameProperty, requiredProperty and the computed effectiveRequiredProperty.

Interestingly when returning just the isRequiredProperty instead of the binding the checkbox works.

public ObservableBooleanValue effectiveRequiredProperty() {
     // Bindings with this work:
     // return isRequired;
     // with this not
     return isRequired.or(name.isEqualTo(SPECIAL_STRING));
}

So what is the difference between a Property and a ObservableValue in regard to a CheckBox?

public class TableCellCBBinding extends Application {

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

    @Override
    public void start(Stage primaryStage) throws Exception {
        init(primaryStage);
        primaryStage.show();
    }

    private void init(Stage primaryStage) {
        primaryStage.setScene(new Scene(buildContent()));
    }

    private Parent buildContent() {
        TableView<ViewModel> tableView = new TableView<>();
        tableView.setItems(sampleEntries());
        tableView.setEditable(true);
        tableView.getColumns().add(buildRequiredColumn());
        tableView.getColumns().add(buildNameColumn());

        // Add a Textfield to show the values for the first item
        // As soon as the name is set to "X", the effectiveRequiredProperty should evaluate to true and the CheckBox should reflect this but it does not
        TextField text = new TextField();
        ViewModel firstItem = tableView.getItems().get(0);
        text.textProperty()
            .bind(Bindings.format("%s | %s | %s", firstItem.nameProperty(), firstItem.isRequiredProperty(), firstItem.effectiveRequiredProperty()));

        return new HBox(text, tableView);
    }

    private TableColumn<ViewModel, String> buildNameColumn() {
        TableColumn<ViewModel, String> nameColumn = new TableColumn<>("Name");
        nameColumn.setCellValueFactory(new PropertyValueFactory<>("name"));
        nameColumn.setCellFactory(TextFieldTableCell.forTableColumn());
        nameColumn.setEditable(true);
        return nameColumn;
    }

    private TableColumn<ViewModel, Boolean> buildRequiredColumn() {
        TableColumn<ViewModel, Boolean> requiredColumn = new TableColumn<>("isEffectiveRequired");
        requiredColumn.setMinWidth(50);
        // This is should bind my BindingExpression from to ViewModel to the CheckBox
        requiredColumn.setCellValueFactory( p -> p.getValue().effectiveRequiredProperty());
        requiredColumn.setCellFactory( CheckBoxTableCell.forTableColumn(requiredColumn));
        return requiredColumn;
    }

    private ObservableList<ViewModel> sampleEntries() {
        return FXCollections.observableArrayList(
                new ViewModel(false, "A"),
                new ViewModel(true,  "B"),
                new ViewModel(false, "C"),
                new ViewModel(true,  "D"),
                new ViewModel(false, "E"));
    }

    public static class ViewModel {
        public static final String SPECIAL_STRING = "X";

        private final StringProperty name;
        private final BooleanProperty isRequired;

        public ViewModel(boolean isRequired, String name) {
            this.name = new SimpleStringProperty(this, "name", name);
            this.isRequired = new SimpleBooleanProperty(this, "isRequired", isRequired);
            this.name.addListener((observable, oldValue, newValue) -> System.out.println(newValue));
        }

        public StringProperty nameProperty() {return name;}
        public final String getName(){return name.get();}
        public final void setName(String value){
            name.set(value);}

        public boolean isRequired() {
            return isRequired.get();
        }
        public BooleanProperty isRequiredProperty() {
            return isRequired;
        }
        public void setRequired(final boolean required) {
            this.isRequired.set(required);
        }

        public ObservableBooleanValue effectiveRequiredProperty() {
            // Bindings with this work:
            // return isRequired;
            // with this not
            return isRequired.or(name.isEqualTo(SPECIAL_STRING));
        }
    }
}

When typing an X into the name the checkbox in the row should be checked.

When typing an X into the name the checkbox in the row is not checked. It's never checked like it is not bound at all.


Solution

  • CheckBoxXXCells don't live up to their doc when it comes to binding their selected state, f.i. (citing here just for signature, even if not set explicitely):

    public final Callback <Integer,​ObservableValue<Boolean>> getSelectedStateCallback()

    Returns the Callback that is bound to by the CheckBox shown on screen.

    clearly talks about an ObservableValue, so we would expect that it at least shows the selection state.

    Actually, the implementation does exactly nothing if it's not a property, the relevant part from its updateItem:

    StringConverter<T> c = getConverter();
    
    if (showLabel) {
        setText(c.toString(item));
    }
    setGraphic(checkBox);
    
    if (booleanProperty instanceof BooleanProperty) {
        checkBox.selectedProperty().unbindBidirectional((BooleanProperty)booleanProperty);
    }
    ObservableValue<?> obsValue = getSelectedProperty();
    if (obsValue instanceof BooleanProperty) {
        booleanProperty = (ObservableValue<Boolean>) obsValue;
        checkBox.selectedProperty().bindBidirectional((BooleanProperty)booleanProperty);
    }
    
    checkBox.disableProperty().bind(Bindings.not(
            getTableView().editableProperty().and(
            getTableColumn().editableProperty()).and(
            editableProperty())
        ));
    

    To work around, use a custom cell that updates the selected state in its updateItem. With the added quirk that we need to disable the check's firing to really keep the visuals in sync with backing state:

    requiredColumn.setCellFactory(cc -> {
        TableCell<ViewModel, Boolean> cell = new TableCell<>() {
            CheckBox check = new CheckBox() {
    
                @Override
                public void fire() {
                    // do nothing - visualizing read-only property
                    // could do better, like actually changing the table's
                    // selection
                }
    
            };
            {
                getStyleClass().add("check-box-table-cell");
                check.setOnAction(e -> {
                    e.consume();
                });
            }
    
            @Override
            protected void updateItem(Boolean item, boolean empty) {
                super.updateItem(item, empty);
                if (empty || item == null) {
                    setText(null);
                    setGraphic(null);
                } else {
                    check.setSelected(item);
                    setGraphic(check);
                }
            }
    
        };
        return cell;
    });