Search code examples
javajavafxbindingpredicate

JavaFX TableView selectionModel apparently out of sync with what's visible in the GUI


We have a process that collects data and tries to automatically model it. We also have a reference for the data so we've got a process that compares the synthesized data against the reference, finding where it hits or misses, the misses being displayed prominently for user action.

To that end, I've created a JavaFX GUI in the "click on summary to display detail data" style where the synthesized data is selected from a TableView and the comparison and reference data are fetched from DB and displayed in auxiliary TableViews.

I'm also using some CheckComboBox from ControlsFX as filters to manipulate what's visible in the synthesized data TableView.

So I have the typical ObservableList<Data> (the data itself) -> FilteredList<Data> (the list filtered by predicate-driven CheckComboBoxes) -> SortedList<Data> (the list put into the TableView). So, to make it all work, I have to put a ListChangeListener on the TableView.getSelectionModel().getSelectedItems() ObservableList.

So, to the point: On the occasion that (a) a filter is in selected state and (b) something is selected in the TableView and (c) the filter is changed, the predicate actions and the selectedItems listener kinda go nuts with some kind of mutually recursive back and forth until, at the end, the selectedItems listener thinks that all of the items in the list are selected whereas visually, only one or no things are actually selected. (This also leads to a flurry of DB requests because of all the back and forth changes, which is really the main annoyance.)

Here's some code that illustrates the problem:

package com.example.javafx;

import javafx.beans.property.*;

public class Person implements Comparable<Person> {

    public static enum Altruism {
        LAWFUL, NEUTRAL, CHAOTIC;
    }

    public static enum Goodness {
        GOOD, NEUTRAL, EVIL;
    }

    private int id;
    private final StringProperty name = new SimpleStringProperty();
    private final StringProperty email = new SimpleStringProperty();
    private final ObjectProperty<Altruism> altruism = new SimpleObjectProperty<>();
    private final ObjectProperty<Goodness> goodness = new SimpleObjectProperty<>();

    public Person() {
    }

    public Person(int id, String n, String e, Altruism a, Goodness g) {
        setId(id);
        setName(n);
        setEmail(e);
        setAltruism(a);
        setGoodness(g);
    }

    private void setId(int id) {
        this.id = id;
    }

    public final Integer getId() {
        return id;
    }

    public final void setName(String n) {
        name.set(n);
    }

    public final String getName() {
        return name.get();
    }

    public final StringProperty nameProperty() {
        return name;
    }

    public final void setEmail(String e) {
        email.set(e);
    }

    public final String getEmail() {
        return email.get();
    }

    public final StringProperty emailProperty() {
        return email;
    }

    public final void setAltruism(Altruism a) {
        altruism.set(a);
    }

    public final Altruism getAltruism() {
        return altruism.get();
    }

    public final ObjectProperty<Altruism> altruismProperty() {
        return altruism;
    }

    public final void setGoodness(Goodness a) {
        goodness.set(a);
    }

    public final Goodness getGoodness() {
        return goodness.get();
    }

    public final ObjectProperty<Goodness> goodnessProperty() {
        return goodness;
    }

    @Override
    public int compareTo(Person o) {
        return getName().compareTo(o.getName());
    }
    
    @Override
    public String toString() { 
        String value = super.toString();
        value += " " + name;
        return value;
    }

}

package com.example.javafx;

import static com.example.javafx.Person.Altruism.CHAOTIC;
import static com.example.javafx.Person.Goodness.GOOD;
import java.util.*;

public class Database {

    static final Person[] PEOPLE_TEST_DATA = {
        new Person(0, "Martha", "martha@gmail.com", CHAOTIC, GOOD),
        new Person(1, "Pat", "pat@gmail.com", CHAOTIC, Person.Goodness.NEUTRAL),
        new Person(2, "Marisa", "marisa@gmail.com", Person.Altruism.NEUTRAL, Person.Goodness.NEUTRAL),
        new Person(3, "Russel", "russel@gmail.com", Person.Altruism.CHAOTIC, Person.Goodness.NEUTRAL),
        new Person(4, "Ron", "ron@gmail.com", Person.Altruism.LAWFUL, Person.Goodness.NEUTRAL)
    };

    public final List<Person> fetchAll() {
        List<Person> data = new ArrayList<>();
        for (Person p : PEOPLE_TEST_DATA) {
            data.add(p);
        }
        return data;
    }

    public final List<Person> fetchByIds(List<Integer> ids) {
        List<Person> data = new ArrayList<>();
        for (Person p : PEOPLE_TEST_DATA) {
            if (ids.contains(p.getId())) {
                data.add(p);
            }
        }
        return data;
    }
}
package com.example.javafx;

import com.example.javafx.Person.Altruism;
import com.example.javafx.Person.Goodness;
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
import javafx.application.Application;
import javafx.beans.binding.*;
import javafx.beans.property.*;
import javafx.collections.*;
import javafx.collections.transformation.*;
import javafx.geometry.*;
import javafx.scene.*;
import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import org.controlsfx.control.CheckComboBox;

public class Main extends Application {

    CheckComboBox altruismSelector
            = new CheckComboBox(FXCollections.observableArrayList(Altruism.values()));

    public static void main(String[] args) {
        launch(args);
    }
    
    Database db = new Database();

    @Override
    public void start(Stage stage) throws Exception {
        BorderPane layout = new BorderPane();
        VBox controls = new VBox(5);

        Button clearFilter = new Button("reset filter");
        clearFilter.setOnAction(event -> {
            resetChecks();
        });

        controls.getChildren().setAll(
                new Label("altruism:"),
                altruismSelector,
                clearFilter);

        ObjectProperty<Predicate<Person>> altruismPredicate = new SimpleObjectProperty<>();
        altruismPredicate.bind(Bindings.createObjectBinding(
                () -> person -> {
                    final Altruism a = person.getAltruism();
                    final ObservableList<Altruism> checkedItems = altruismSelector.getCheckModel().getCheckedItems();
                    return checkedItems.contains(a) || checkedItems.isEmpty();
                }, altruismSelector.getCheckModel().getCheckedItems()));

        ObservableList<Person> people = FXCollections.observableArrayList(db.fetchAll());
        FilteredList<Person> filteredPeople = people.filtered(p -> true);
        SortedList<Person> sortedPeople = filteredPeople.sorted();

        filteredPeople.predicateProperty()
                .bind(Bindings.createObjectBinding(
                        () -> altruismPredicate.get()/*.and(goodnessPredicate.get())*/,
                        altruismPredicate /*, goodnessPredicate*/));

        TableView<Person> peopleTableView = setupAllPeopleTableView(sortedPeople);
        
        ObservableList<Person> selectedPeople = FXCollections.observableArrayList();
        TableView<Person> detailTableView = setupDetailedPersonTableView(selectedPeople);

        sortedPeople.addListener(new ListChangeListener<Person>() {
            @Override
            public void onChanged(ListChangeListener.Change<? extends Person> c) {
                System.out.println(c.getList().size() + " items in visible list: " + c.getList());
            }
        });

        peopleTableView.getSelectionModel().getSelectedItems().addListener(new ListChangeListener<Person>() {
            @Override
            public void onChanged(ListChangeListener.Change<? extends Person> c) {
                System.out.println(c.getList().size() + " items selected: " + c.getList());
                selectedPeople.clear();
                List<Integer> ids = c.getList().stream()
                        .map(e->e.getId())
                        .distinct()
                        .collect(Collectors.toList());
                selectedPeople.setAll(db.fetchByIds(ids));
            }
        });

        SplitPane tableViews = new SplitPane(peopleTableView, detailTableView);
        tableViews.setOrientation(Orientation.HORIZONTAL);
        
        layout.setCenter(tableViews);
        layout.setLeft(controls);

        resetChecks();

        stage.setScene(new Scene(layout));
        stage.show();
    }

    private TableView<Person> setupAllPeopleTableView(ObservableList<Person> people) {
        
        TableColumn<Person, String> nameColumn = new TableColumn("name");
        nameColumn.setId("name");
        nameColumn.setCellValueFactory(cell -> cell.getValue().nameProperty());
        
        TableColumn<Person, Altruism> altruismColumn = new TableColumn("altruism");
        altruismColumn.setId("altruism");
        altruismColumn.setCellValueFactory(cell -> cell.getValue().altruismProperty());
        
        TableView<Person> peopleTableView = new TableView<>();
        peopleTableView.getColumns().setAll(
                nameColumn,
                altruismColumn
        );
        peopleTableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
        peopleTableView.setItems(people);
        return peopleTableView;
    }

    private TableView<Person> setupDetailedPersonTableView(ObservableList<Person> people) {

        TableColumn<Person, String> nameColumn = new TableColumn("name");
        nameColumn.setId("name");
        nameColumn.setCellValueFactory(cell -> cell.getValue().nameProperty());

        TableColumn<Person, String> emailColumn = new TableColumn("email");
        emailColumn.setId("email");
        emailColumn.setCellValueFactory(cell -> cell.getValue().emailProperty());

        TableColumn<Person, Altruism> altruismColumn = new TableColumn("altruism");
        altruismColumn.setId("altruism");
        altruismColumn.setCellValueFactory(cell -> cell.getValue().altruismProperty());
        
        TableColumn<Person, Goodness> goodnessColumn = new TableColumn("goodness");
        goodnessColumn.setId("goodness");
        goodnessColumn.setCellValueFactory(cell -> cell.getValue().goodnessProperty());

        TableView<Person> tableView = new TableView<>();
        tableView.getColumns().setAll(
                nameColumn,
                emailColumn,
                altruismColumn,
                goodnessColumn
        );
        tableView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
        tableView.setItems(people);
        
        return tableView;
    }

    private void resetChecks() {
        System.out.println("resetChecks");
        altruismSelector.getCheckModel().clearChecks();
    }

    private static void delay(final int delay) {
        try {
            Thread.sleep(delay);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

If you run "Main", above, engage the filter of NEUTRAL, select Marisa, then click on the reset button, you'll see the weirdness in the logging as a well as the listener reporting everything selected when visually, only Marisa is selected.

I expect that there's a problem with the filter changing the underlying SortedList which is indirectly tied to the TableView SelectionModel, but I don't know where to go from here. Am I doing it wrong or is there perhaps a bug in JavaFX/ControlsFX that might be exacerbating this problem?

Edit: I completely forgot to mention that this is with Java 1.8/JavaFX 8 and ControlsFX 8.40.18.


Solution

  • I've come up with a workaround for the bug by not using the SortedList<Data> transform but instead handling the updates from the FilteredList<Data> and manipulating the final list in my own handler. This is similar to @kleopatra's suggestion in the comments.

    package com.example.javafx;
    
    import com.example.javafx.Person.Altruism;
    import com.example.javafx.Person.Goodness;
    import java.util.*;
    import java.util.function.*;
    import java.util.stream.*;
    import javafx.application.Application;
    import javafx.beans.binding.*;
    import javafx.beans.property.*;
    import javafx.collections.*;
    import javafx.collections.transformation.*;
    import javafx.geometry.*;
    import javafx.scene.*;
    import javafx.scene.control.*;
    import javafx.scene.control.TableView.TableViewSelectionModel;
    import javafx.scene.layout.*;
    import javafx.stage.Stage;
    import org.controlsfx.control.CheckComboBox;
    
    public class Solution extends Application {
    
        CheckComboBox altruismSelector
                = new CheckComboBox(FXCollections.observableArrayList(Altruism.values()));
    
        public static void main(String[] args) {
            launch(args);
        }
        
        Database db = new Database();
    
        @Override
        public void start(Stage stage) throws Exception {
            BorderPane layout = new BorderPane();
            VBox controls = new VBox(5);
    
            Button clearFilter = new Button("reset filter");
            clearFilter.setOnAction(event -> {
                resetChecks();
            });
    
            controls.getChildren().setAll(
                    new Label("altruism:"),
                    altruismSelector,
                    clearFilter);
    
            ObjectProperty<Predicate<Person>> altruismPredicate = new SimpleObjectProperty<>();
            altruismPredicate.bind(Bindings.createObjectBinding(
                    () -> person -> {
                        final Altruism a = person.getAltruism();
                        final ObservableList<Altruism> checkedItems = altruismSelector.getCheckModel().getCheckedItems();
                        return checkedItems.contains(a) || checkedItems.isEmpty();
                    }, altruismSelector.getCheckModel().getCheckedItems()));
    
            ObservableList<Person> people = FXCollections.observableArrayList(db.fetchAll());
            FilteredList<Person> filteredPeople = people.filtered(p -> true);
            ObservableList<Person> sortedPeople = FXCollections.observableArrayList(filteredPeople);
            TableView<Person> peopleTableView = setupAllPeopleTableView(sortedPeople);
            
            filteredPeople.addListener(new ListChangeListener<Person>() {
                @Override
                public void onChanged(ListChangeListener.Change<? extends Person>c) {
                    TableViewSelectionModel selection = peopleTableView.getSelectionModel();
                    // capture existing selection from the table's selection model
                    List<Person> selectedPeople = new ArrayList<>(selection.getSelectedItems());
                    // clear table's selection
                    selection.clearSelection();
                    // jam the contents of the change into the people list
                    sortedPeople.setAll(c.getList());
                    // restore selections if they're still in the list after the change
                    for (Person p : selectedPeople) {
                        if (c.getList().contains(p)) {
                            selection.select(p);
                        }
                    }
                    // help out the GC?
                    selectedPeople.clear();
                }
            });
    
            filteredPeople.predicateProperty()
                    .bind(Bindings.createObjectBinding(
                            () -> altruismPredicate.get()/*.and(goodnessPredicate.get())*/,
                            altruismPredicate /*, goodnessPredicate*/));
    
            
            ObservableList<Person> selectedPeople = FXCollections.observableArrayList();
            TableView<Person> detailTableView = setupDetailedPersonTableView(selectedPeople);
    
            sortedPeople.addListener(new ListChangeListener<Person>() {
                @Override
                public void onChanged(ListChangeListener.Change<? extends Person> c) {
                    System.out.println(c.getList().size() + " items in visible list: " + c.getList());
                }
            });
    
            peopleTableView.getSelectionModel().getSelectedItems().addListener(new ListChangeListener<Person>() {
                @Override
                public void onChanged(ListChangeListener.Change<? extends Person> c) {
                    System.out.println(c.getList().size() + " items selected: " + c.getList());
                    selectedPeople.clear();
                    List<Integer> ids = c.getList().stream()
                            .map(e->e.getId())
                            .distinct()
                            .collect(Collectors.toList());
                    selectedPeople.setAll(db.fetchByIds(ids));
                }
            });
    
            SplitPane tableViews = new SplitPane(peopleTableView, detailTableView);
            tableViews.setOrientation(Orientation.HORIZONTAL);
            
            layout.setCenter(tableViews);
            layout.setLeft(controls);
    
            resetChecks();
    
            stage.setScene(new Scene(layout));
            stage.show();
        }
    
        private TableView<Person> setupAllPeopleTableView(ObservableList<Person> people) {
            
            TableColumn<Person, String> nameColumn = new TableColumn("name");
            nameColumn.setId("name");
            nameColumn.setCellValueFactory(cell -> cell.getValue().nameProperty());
            
            TableColumn<Person, Altruism> altruismColumn = new TableColumn("altruism");
            altruismColumn.setId("altruism");
            altruismColumn.setCellValueFactory(cell -> cell.getValue().altruismProperty());
            
            TableView<Person> peopleTableView = new TableView<>();
            peopleTableView.getColumns().setAll(
                    nameColumn,
                    altruismColumn
            );
            peopleTableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
            peopleTableView.setItems(people);
            return peopleTableView;
        }
    
        private TableView<Person> setupDetailedPersonTableView(ObservableList<Person> people) {
    
            TableColumn<Person, String> nameColumn = new TableColumn("name");
            nameColumn.setId("name");
            nameColumn.setCellValueFactory(cell -> cell.getValue().nameProperty());
    
            TableColumn<Person, String> emailColumn = new TableColumn("email");
            emailColumn.setId("email");
            emailColumn.setCellValueFactory(cell -> cell.getValue().emailProperty());
    
            TableColumn<Person, Altruism> altruismColumn = new TableColumn("altruism");
            altruismColumn.setId("altruism");
            altruismColumn.setCellValueFactory(cell -> cell.getValue().altruismProperty());
            
            TableColumn<Person, Goodness> goodnessColumn = new TableColumn("goodness");
            goodnessColumn.setId("goodness");
            goodnessColumn.setCellValueFactory(cell -> cell.getValue().goodnessProperty());
    
            TableView<Person> tableView = new TableView<>();
            tableView.getColumns().setAll(
                    nameColumn,
                    emailColumn,
                    altruismColumn,
                    goodnessColumn
            );
            tableView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
            tableView.setItems(people);
            
            return tableView;
        }
    
        private void resetChecks() {
            System.out.println("resetChecks");
            altruismSelector.getCheckModel().clearChecks();
        }
    
        private static void delay(final int delay) {
            try {
                Thread.sleep(delay);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }
    

    It's not perfect, a bit brute-force, but it gets the job done for me. Remember, this is for Java 8/JavaFX 8 and ControlsFX 8.40.18. Newer setups may or may not need this workaround.