javafxcheckboxcallbackcomboboxtableview

TableView with a custom ComboBox randomly selects CheckBoxes when scrolling


I have a TableView which shows in a column in each TableCell a ComboBox with inside a Pane with each a CheckBox and a Label. When I check e.g. the first CheckBox or any other and scroll down a bit then another CheckBox is selected randomly. When I then scroll up the origionally CheckBox is not selected anymore.

What is wrong with the code? Please advise.

    Callback<TableColumn<ArrayList, String>, TableCell<ArrayList, String>> cellFactoryTableCellAttributeMapping = buildCallbackComboBoxTableCellAttributeMapping();
    tableColumn.setCellFactory(cellFactoryTableCellAttributeMapping);
public Callback<TableColumn<ArrayList, String>, TableCell<ArrayList, String>> buildCallbackComboBoxTableCellAttributeMapping() {
        Callback<TableColumn<ArrayList, String>, TableCell<ArrayList, String>> cellFactory = new Callback<TableColumn<ArrayList, String>, TableCell<ArrayList, String>>() {
            @Override
            public TableCell call(TableColumn tableColumn) {
                TableCell tableCell = new TableCell<Object, String>() {
                    ComboBox<String> comboBox = new ComboBox<String>();

                    {
                        ComboBoxListViewSkin comboBoxSkin = new ComboBoxListViewSkin(comboBox);
                        comboBoxSkin.hideOnClickProperty().setValue(Boolean.FALSE);
                        comboBox.setSkin(comboBoxSkin);
                        comboBox.setSelectionModel(null);

                        Callback<ListView<String>, ListCell<String>> callBackAttributeMapping = buildCallbackAttributeMapping(this);
                        comboBox.setCellFactory(callBackAttributeMapping);
                    }

                    @Override
                    protected void updateItem(String item, boolean empty) {
                        super.updateItem(item, empty);

                        if (empty) {
                            setText(null);
                            setGraphic(null);
                        } else {
                            // clear() necessary when reorder via the column header
                            comboBox.getItems().clear();
                            String[] arrayMappingData = new String[]{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15"};
                            comboBox.getItems().addAll(arrayMappingData);
                            setGraphic(comboBox);
                        }
                    }
                };

                tableCell.setAlignment(Pos.CENTER_RIGHT);

                return tableCell;
            }
        };
        return cellFactory;
    }
    public Callback<ListView<String>, ListCell<String>> buildCallbackAttributeMapping(TableCell tableCell) {
        Callback callBack = new Callback<ListView<String>, ListCell<String>>() {
            @Override
            public ListCell<String> call(ListView<String> param) {

                ListCell listCell = new ListCell<String>() {
                    private CheckBox checkBox = new CheckBox();
                    private Label label = new Label();
                    private Pane pane = new Pane();

                    {
                        checkBox.setLayoutY(2);
                        checkBox.setSelected(false);
                        checkBox.setFocusTraversable(false);
  
                        label.setLayoutX(30);
                        label.setLayoutY(2);

                        pane.getChildren().addAll(checkBox, label);
                    }

                    @Override
                    protected void updateItem(String item, boolean empty) {
                        super.updateItem(item, empty);

                        if (!empty) {
                            if (!item.isEmpty()) {
                                label.setText(item);
                                setGraphic(pane);
                            }
                        } else {
                            setText("");
                            setGraphic(null);
                        }
                    }

                };

                return listCell;
            }
        };
        return callBack;
    }

The following two pictures showing the issue:

enter image description here enter image description here

A minimal reproducible example:

public class TableViewComboBoxCheckBoxDemo extends Application {

    @Override
    public void start(Stage stage) throws Exception {
        Pane pane = new Pane();
        TableView tableView = new TableView();

        pane.setPrefHeight(400.0);
        pane.setPrefWidth(350.0);

        tableView.setLayoutX(50.0);
        tableView.setLayoutY(25.0);
        tableView.setPrefHeight(350.0);
        tableView.setPrefWidth(200.0);

        pane.getChildren().add(tableView);
        String[] tableColumns = new String[]{"Sample Column"};
        ArrayList<String> tableRowData = new ArrayList<>();
        tableRowData.add("-");

        ObservableList<TableColumn> tableViewColumns = buildTableColumns(tableColumns);
        ObservableList<ArrayList> tableViewRows = buildTableRows(tableRowData);

        tableView.getColumns().setAll(tableViewColumns);
        tableView.getItems().setAll(tableViewRows);

        Scene scene = new Scene(pane);

        stage.setScene(scene);

        stage.show();
    }

    private ObservableList<TableColumn> buildTableColumns(String[] columnNames) {
        ObservableList<TableColumn> tableViewColumns = FXCollections.observableArrayList();
        for (int i = 0; i < columnNames.length; i++) {
            String columnName = columnNames[i];
            TableColumn tableColumn = new TableColumn(columnName);
            tableColumn.setMinWidth(200);

            configureTableColumnCellRender(i, tableColumn);

            tableViewColumns.add(tableColumn);
        }
        return tableViewColumns;
    }

    private void configureTableColumnCellRender(int index, TableColumn tableColumn) {
        Callback<TableColumn.CellDataFeatures<ArrayList, String>, ObservableValue<String>> cellValueFactory = buildCallbackString(index);
        tableColumn.setCellValueFactory(cellValueFactory);

        Callback<TableColumn<ArrayList, String>, TableCell<ArrayList, String>> cellFactoryTableCellAttributeMapping = buildCallbackComboBoxTableCellAttributeMapping();
        tableColumn.setCellFactory(cellFactoryTableCellAttributeMapping);
    }

    private ObservableList<ArrayList> buildTableRows(ArrayList<String> tableRowData) {
        ObservableList<ArrayList> tableViewRows = FXCollections.observableArrayList();
        tableViewRows.add(tableRowData);
        return tableViewRows;
    }

    private Callback<TableColumn.CellDataFeatures<ArrayList, String>, ObservableValue<String>> buildCallbackString(int index) {
        Callback<TableColumn.CellDataFeatures<ArrayList, String>, ObservableValue<String>> cellValueFactory = new Callback<TableColumn.CellDataFeatures<ArrayList, String>, ObservableValue<String>>() {
            @Override
            public ObservableValue<String> call(TableColumn.CellDataFeatures<ArrayList, String> rowValues) {
                Object rowValue = rowValues.getValue().get(index);
                if (rowValue == null) {
                    return new SimpleStringProperty("");
                } else {
                    return new SimpleStringProperty(rowValue.toString());
                }
            }
        };
        return cellValueFactory;
    }

    private Callback<TableColumn<ArrayList, String>, TableCell<ArrayList, String>> buildCallbackComboBoxTableCellAttributeMapping() {
        Callback<TableColumn<ArrayList, String>, TableCell<ArrayList, String>> cellFactory = new Callback<TableColumn<ArrayList, String>, TableCell<ArrayList, String>>() {
            @Override
            public TableCell call(TableColumn tableColumn) {
                TableCell tableCell = new TableCell<Object, String>() {
                    ComboBox<String> comboBox = new ComboBox<String>();

                    {
                        ComboBoxListViewSkin comboBoxSkin = new ComboBoxListViewSkin(comboBox);
                        comboBoxSkin.hideOnClickProperty().setValue(Boolean.FALSE);
                        comboBox.setSkin(comboBoxSkin);
                        comboBox.setSelectionModel(null);

                        Callback<ListView<String>, ListCell<String>> callBackAttributeMapping = buildCallbackAttributeMapping(this);
                        comboBox.setCellFactory(callBackAttributeMapping);
                    }

                    @Override
                    protected void updateItem(String item, boolean empty) {
                        super.updateItem(item, empty);

                        if (empty) {
                            setText(null);
                            setGraphic(null);
                        } else {
                            comboBox.getItems().clear();
                            String[] arrayMappingData = new String[]{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15"};
                            comboBox.getItems().addAll(arrayMappingData);
                            setGraphic(comboBox);
                        }
                    }
                };

                tableCell.setAlignment(Pos.CENTER_RIGHT);

                return tableCell;
            }
        };
        return cellFactory;
    }

    private Callback<ListView<String>, ListCell<String>> buildCallbackAttributeMapping(TableCell tableCell) {
        Callback callBack = new Callback<ListView<String>, ListCell<String>>() {
            @Override
            public ListCell<String> call(ListView<String> param) {

                ListCell listCell = new ListCell<String>() {
                    private CheckBox checkBox = new CheckBox();
                    private Label label = new Label();
                    private Pane pane = new Pane();

                    {
                        checkBox.setLayoutY(2);
                        checkBox.setSelected(false);
                        checkBox.setFocusTraversable(false);

                        label.setLayoutX(30);
                        label.setLayoutY(2);

                        pane.getChildren().addAll(checkBox, label);
                    }

                    @Override
                    protected void updateItem(String item, boolean empty) {
                        super.updateItem(item, empty);

                        if (!empty) {
                            if (!item.isEmpty()) {
                                label.setText(item);
                                setGraphic(pane);
                            }
                        } else {
                            setText("");
                            setGraphic(null);
                        }
                    }

                };

                return listCell;
            }
        };
        return callBack;
    }
}

Solution

  • In JavaFX, virtualized controls (ListView, TableView, TreeView, etc.) are designed to display potentially large amounts of data without needing to create large numbers of UI elements. The way this works is that the control will create enough cells to fill the display, but not necessarily one cell for every data element. When the user scrolls, cells displaying items that scroll out of view are reused to display the items that are scrolled into view. The updateItem() method defined in the Cell class (and subclasses) is invoked so the cell can update its display.

    In the code in the question, the selected state of the check boxes are never updated in response to the displayed item changing, so when cells are reused, the selected state of items previously displayed by the cell is retained.

    Note also that other actions, such as clearing the backing list or otherwise removing elements from it, will result in cell reuse. In some cases (such as clearing the list) cells that previously displayed an item may become empty. The updateItem() method needs to be implemented in such a way that it handles all these possibilities.

    If the cells contain editable elements, such as check boxes, when the user modifies those elements the changes need to be sent back to the underlying data model. There are two distinct ways this can be done: one is to use the startEdit(), commitEdit(), cancelEdit() lifecycle defined in the cell classes; the other is to simply write back to the model with event handlers on the controls. The former approach is useful if you want a different display when the cell is in "editing mode" (e.g. switching from a label to a text field); the latter is often more convenient if the editing control (combo box or check box) is always displayed in the cell.

    The key to using these controls is to first define an appropriate data model (preferably using JavaFX properties). In the question, the table contains a column containing a combo box, and in the combo box there are check boxes, each apparently representing an attribute belonging to the item represented by the row in the table. The backing data (inferred from the types used in the TableView and TableRow) appear simply to be strings, and strings cannot possibly (or at least not without some really ugly code) represent a collection of attributes belonging to each item.

    Note here that the required functionality might be better (and more simply) implemented using a MenuButton with CheckBoxMenuItems in place of the ComboBox. I will stick with the combo box in case that is what is really needed; however it doesn't seem the correct choice of control as there's really no notion of selecting an item here.

    I started with a simple record to represent an Attribute. This might not be necessary in your actual application (e.g. a String or Integer might suffice), but I think it makes it a bit clearer what everything represents when entities are represented by specific types:

    Attribute.java:

    public record Attribute(String name) {}
    

    And an Item class to represent the things in the table (one row in the table represents one Item instance; obviously in the real application this class should have a more meaningful name). For the specific question, each Item has a set of attributes associated with it, which are represented by an ObservableSet<Attribute>. I also added a simple name property to make the example more complete.

    Item.java:

    import javafx.beans.property.SetProperty;
    import javafx.beans.property.SimpleSetProperty;
    import javafx.beans.property.SimpleStringProperty;
    import javafx.beans.property.StringProperty;
    import javafx.collections.FXCollections;
    
    public class Item {
        private final StringProperty name = new SimpleStringProperty();
        public StringProperty nameProperty() {
            return name;
        }
        public final String getName() {
            return nameProperty().get();
        }
        public final void setName(String name) {
            nameProperty().set(name);
        }
    
        private final SetProperty<Attribute> attributes = new SimpleSetProperty<>(
                FXCollections.observableSet()
        );
    
        public SetProperty<Attribute> attributesProperty() {
            return attributes;
        }
    
        public Item(String name) {
            setName(name);
        }
    }
    

    In the column representing the attributes, there are two levels of cells to think about. Each cell in the table column needs to display a combo box, representing the set of attributes belonging to the item in the table row, out of a collection of possible attributes. The second level of cell is the cell in the combo box, which we will come to shortly.

    Here is the implementation of the cell displaying the combo box. I implemented this as an inner class of the application class, but it could easily be a standalone class:

        private static class CheckedComboBoxTableCell extends TableCell<Item, ObservableSet<Attribute>> {
            private final ComboBox<Attribute> comboBox = new ComboBox<>();
    
            CheckedComboBoxTableCell(List<Attribute> attributes) {
                comboBox.getItems().setAll(attributes);
                comboBox.setCellFactory(_ -> {
                    CheckBoxListCell cell = new CheckBoxListCell();
                    itemProperty().subscribe(cell::setAttributes);
                    return cell;
                });
                comboBox.skinProperty().subscribe(skin -> {
                    if (skin instanceof ComboBoxListViewSkin<?> cblvs) {
                        cblvs.setHideOnClick(false);
                    }
                });
            }
    
            @Override
            protected void updateItem(ObservableSet<Attribute> attributes, boolean empty) {
                super.updateItem(attributes, empty);
                if (empty || attributes == null) {
                    setGraphic(null);
                } else {
                    setGraphic(comboBox);
                }
            }
        }
    

    The code that uses this is just

            attrColumn.setCellFactory( _ -> new CheckedComboBoxTableCell(allAttributes));
    

    The cell has a combo box, and the updateItem() method simply displays the combo box if the cell is non-empty, and displays nothing if the cell is empty.

    The code in the constructor sets the cell factory on the combo box (I will show this in the next section). As we will see, the cells in the combo box need to know not only the attribute they are displaying, but the set of attributes belonging to the current item. So the cell factory ensures the cell is updated with this information when it changes. This is arguably a bit of a hack, but working it properly into a MVC approach is probably more trouble than it is worth. There is also a little hack that ensures that when the skin is set on the combo box the seHideOnClick(false) method is called.

    Here is the cell implementation for the cells in the combo box. Each cell here represents an attribute, and the selected state needs to be synchronized with whether or not the item's collection of attributes contains that attribute.

        private static class CheckBoxListCell extends ListCell<Attribute> {
            private ObservableSet<Attribute> attributes;
            private final CheckBox checkBox;
    
            void setAttributes(ObservableSet<Attribute> attributes) {
                this.attributes = attributes;
                updateCheckBox(getItem());
            }
    
            CheckBoxListCell() {
                checkBox = new CheckBox();
                checkBox.selectedProperty().addListener((_, _, isNowSelected) -> {
                    Attribute attribute = getItem();
                    if (attribute != null && attributes != null) {
                        if (isNowSelected) {
                            attributes.add(attribute);
                        } else {
                            attributes.remove(attribute);
                        }
                    }
                });
            }
    
            @Override
            protected void updateItem(Attribute attribute, boolean empty) {
                super.updateItem(attribute, empty);
                if (empty) {
                    setGraphic(null);
                } else {
                    updateCheckBox(attribute);
                    setGraphic(checkBox);
                }
            }
    
            private void updateCheckBox(Attribute attribute) {
                if (attribute == null || attributes == null) {
                    checkBox.setText("");
                    checkBox.setSelected(false);
                } else {
                    checkBox.setText(attribute.name());
                    checkBox.setSelected(attributes.contains(attribute));
                }
            }
        }
    

    This cell contains a check box, and the updateItem() method, as in the previous case, displays the check box if the cell is non-empty and displays nothing otherwise. If the cell is non-empty, the state of the check box must also be updated, displaying the name of the attribute it represents, and updating its selected state depending on whether that attribute is contained in the item's collection of attributes. This is delegated to an updateCheckBox() method.

    There is one other scenario where we may have to update the check box (and in particular its selected state). If the cell containing the combo box is reused for a different item, then there will be a different set of attributes, associated with that item. So in this scenario, we need to pass the new set of attributes to the cell with the combo box, and potentially update the selected state of the check box. This is done via the setAttributes() method in the cell implementation (which calls updateCheckBox()) and is invoked from the listener on the itemProperty() in the combo box table cell above.

    Finally, when the user checks or unchecks the check box, the model needs to be updated. This is done via a listener on the selectedProperty() of the check box, which adds or removes the attribute from the item's list of attributes. The listener is registered in the cell's constructor.

    For completeness, here is the application class that ties all this together, including the two cell implementations above. The application can be run with this class, along with the Item and Attribute classes above.

    I strongly recommend not using raw types (i.e. never use TableView, it should always be TableView<SomeType>, and similarly for TableColumn<>, List<>, ObservableList<> etc. etc.). If you try to do this with anonymous inner classes instead of lambda expressions, it will basically be unreadable (JavaFX was written entirely with lambda expressions in mind).

    TableViewComboBoxCheckBoxDemo.java:

    import javafx.application.Application;
    import javafx.collections.ObservableSet;
    import javafx.scene.Scene;
    import javafx.scene.control.*;
    import javafx.scene.control.skin.ComboBoxListViewSkin;
    import javafx.scene.layout.BorderPane;
    import javafx.stage.Stage;
    
    import java.util.ArrayList;
    import java.util.List;
    
    public class TableViewComboBoxCheckBoxDemo extends Application {
    
        @Override
        public void start(Stage stage) {
            BorderPane pane = new BorderPane();
            TableView<Item> tableView = new TableView<>();
            pane.setCenter(tableView);
    
            TableColumn<Item, String> nameColumn = new TableColumn<>("Item");
            nameColumn.setCellValueFactory(cellData -> cellData.getValue().nameProperty());
            tableView.getColumns().add(nameColumn);
    
            TableColumn<Item, ObservableSet<Attribute>> attrColumn
                    = new TableColumn<>("Attributes");
            attrColumn.setCellValueFactory(cellData -> cellData.getValue().attributesProperty());
    
            List<Attribute> allAttributes = new ArrayList<>();
            for (int i = 1; i <= 15; i++) {
                allAttributes.add(new Attribute("Attr"+i));
            }
            attrColumn.setCellFactory(_ -> new CheckedComboBoxTableCell(allAttributes));
            tableView.getColumns().add(attrColumn);
    
            for (int i = 1 ; i <= 20 ; i++) {
                tableView.getItems().add(new Item("Item "+i));
            }
    
            Scene scene = new Scene(pane);
    
            stage.setScene(scene);
    
            stage.show();
        }
    
        private static class CheckedComboBoxTableCell extends TableCell<Item, ObservableSet<Attribute>> {
            private final ComboBox<Attribute> comboBox = new ComboBox<>();
    
            CheckedComboBoxTableCell(List<Attribute> attributes) {
                comboBox.getItems().setAll(attributes);
                comboBox.setCellFactory(_ -> {
                    CheckBoxListCell cell = new CheckBoxListCell();
                    itemProperty().subscribe(cell::setAttributes);
                    return cell;
                });
                comboBox.skinProperty().subscribe(skin -> {
                    if (skin instanceof ComboBoxListViewSkin<?> cblvs) {
                        cblvs.setHideOnClick(false);
                    }
                });
            }
    
            @Override
            protected void updateItem(ObservableSet<Attribute> attributes, boolean empty) {
                super.updateItem(attributes, empty);
                if (empty || attributes == null) {
                    setGraphic(null);
                } else {
                    setGraphic(comboBox);
                }
            }
        }
    
        private static class CheckBoxListCell extends ListCell<Attribute> {
            private ObservableSet<Attribute> attributes;
            private final CheckBox checkBox;
    
            void setAttributes(ObservableSet<Attribute> attributes) {
                this.attributes = attributes;
                updateCheckBox(getItem());
            }
    
            CheckBoxListCell() {
                checkBox = new CheckBox();
                checkBox.selectedProperty().addListener((_, _, isNowSelected) -> {
                    Attribute attribute = getItem();
                    if (attribute != null && attributes != null) {
                        if (isNowSelected) {
                            attributes.add(attribute);
                        } else {
                            attributes.remove(attribute);
                        }
                    }
                });
            }
    
            @Override
            protected void updateItem(Attribute attribute, boolean empty) {
                super.updateItem(attribute, empty);
                if (empty) {
                    setGraphic(null);
                } else {
                    updateCheckBox(attribute);
                    setGraphic(checkBox);
                }
            }
    
            private void updateCheckBox(Attribute attribute) {
                if (attribute == null || attributes == null) {
                    checkBox.setText("");
                    checkBox.setSelected(false);
                } else {
                    checkBox.setText(attribute.name());
                    checkBox.setSelected(attributes.contains(attribute));
                }
            }
        }
    }
    

    Also, for comparison, here is a version that uses a MenuButton instead. The functionality is similar and the code is quite a lot cleaner. I also added updating the text of the menu button to display how many attributes were associated with the item.

    import javafx.application.Application;
    import javafx.beans.binding.Bindings;
    import javafx.collections.ObservableSet;
    import javafx.scene.Scene;
    import javafx.scene.control.*;
    import javafx.scene.layout.BorderPane;
    import javafx.stage.Stage;
    
    import java.util.ArrayList;
    import java.util.List;
    import java.util.Set;
    
    public class TableViewComboBoxCheckBoxDemo extends Application {
    
        @Override
        public void start(Stage stage) {
            BorderPane pane = new BorderPane();
            TableView<Item> tableView = new TableView<>();
            pane.setCenter(tableView);
    
            TableColumn<Item, String> nameColumn = new TableColumn<>("Item");
            nameColumn.setCellValueFactory(cellData -> cellData.getValue().nameProperty());
            tableView.getColumns().add(nameColumn);
    
            TableColumn<Item, ObservableSet<Attribute>> attrColumn
                    = new TableColumn<>("Attributes");
            attrColumn.setCellValueFactory(cellData -> cellData.getValue().attributesProperty());
    
            List<Attribute> allAttributes = new ArrayList<>();
            for (int i = 1; i <= 15; i++) {
                allAttributes.add(new Attribute("Attr"+i));
            }
            attrColumn.setCellFactory(_ -> new CheckedMenuButtonTableCell(allAttributes));
            tableView.getColumns().add(attrColumn);
    
            for (int i = 1 ; i <= 20 ; i++) {
                tableView.getItems().add(new Item("Item "+i));
            }
    
            Scene scene = new Scene(pane);
    
            stage.setScene(scene);
    
            stage.show();
        }
    
        private static class CheckedMenuButtonTableCell extends TableCell<Item, ObservableSet<Attribute>> {
            private final MenuButton menuButton = new MenuButton();
    
            CheckedMenuButtonTableCell(List<Attribute> attributes) {
                for (Attribute attribute : attributes) {
                    CheckMenuItem menuItem = new CheckMenuItem(attribute.name());
                    menuItem.selectedProperty().subscribe(selected -> {
                        Set<Attribute> currentAttributes = getItem();
                        if (currentAttributes != null) {
                            if (selected) {
                                currentAttributes.add(attribute);
                            } else {
                                currentAttributes.remove(attribute);
                            }
                        }
                    });
                    itemProperty().subscribe(currentAttributes -> {
                        if (currentAttributes != null) {
                            menuItem.setSelected(currentAttributes.contains(attribute));
                        }
                    });
                    menuButton.getItems().add(menuItem);
                }
                menuButton.textProperty().bind(
                        itemProperty().flatMap(Bindings::size).map(size -> String.format("(%d attributes)",size))
                );
            }
            @Override
            protected void updateItem(ObservableSet<Attribute> attributes, boolean empty) {
                super.updateItem(attributes, empty);
                if (attributes == null || empty) {
                    setGraphic(null);
                } else {
                    setGraphic(menuButton);
                }
            }
        }
    
    }