Search code examples
javafxdata-bindingtreeview

JavaFX TreeView - repaint node when its underlying data changes


I have a TreeView<Node>, containing TreeItem<Node>s.

The Node class has a field:

private final CashflowSet cashflowSet = new CashflowSet();

and the CashflowSet in turn contains an observable list property of Cashflows:

private final SimpleListProperty<Cashflow> cashflows = new SimpleListProperty<>(observableArrayList());

(Don't bother why I have it nested like this; both the Node class and the CashflowSet class have various other fields, which aren't relevant here)

Also, I have a custom cell factory for the tree:

tree.setCellFactory(cell -> new NodeRenderer());

which displays the count of cashflows on each node:

@Override
protected void updateItem(Node node, boolean empty) {
    super.updateItem(node, empty);

    if (node == null || empty) {
        setGraphic(null);
        setText(null);
    } else {
        int cashflowCount = node.getCashflowSet().getCashflows().size();
        label.setText(String.valueOf(cashflowCount));
        setGraphic(label);
    }
}

(the cell factory renders other things too, but again I left out everything that is not relevant here)

Now, when I add/remove cashflows in my data model, I find the appropriate Node and add/remove the cashflow to/from its observable cashflow list, e.g.:

treeItem.getValue().getCashflowSet().addCashflow(cashflow);

My problem: When cashflows are added or removed in a node's cashflow set, the renderer doesn't repaint the node, so it still shows the outdated cashflow count. Only when I force the tree to repaint, e.g. by collapsing and expanding the nodes, it will show the updated data. I understand that the tree doesn't automatically repaint the node because it isn't notified about these changes of the underlying data. And I would know how to fix this for example for a ListView or TableView, where the items are bound to an observable list, and I could just define extractors on various properties that would trigger when the properties change. But a TreeView's data model is different and I'm not sure what the proper solution is here. Do I have to manually add listeners somewhere? Or even bind() the label of my renderer the the sizeProperty() of the observable cashflow list? I don't understand well enough how these cell factories work, so I'm not sure if it is the correct place for something like this.

I know that I could just call refresh() on the tree, however the tree can contain a lot of data, and I would like to have good performance, and refreshing the whole thing whenever anything changes in a single node seems like a poor solution.

So my Question is this: How can I let the tree trigger the repaint of a particular node, whenever the node's underlying cashflow list changes (i.e.: cashflows are removed or added). (Note that the cashflow objects themselves don't change, so I really just need to watch changes in the list's size, not in the list's elements)

Thanks


Solution

  • Potential solutions:

    1. When the data is updated, either:

      a) Change the treeItem.

      OR

      b) Fire a tree item value change event on the existing tree item.

    OR

    2. Subclass tree item and override its value property with a custom property implementation aware of your changes.

    As noted by James_D in comments, option 2 won't work because the value property is private, so it cannot be overridden.

    Example code is supplied for potential approach 1b.

    This code will fire a change event on an existing tree item whenever the size of a given list associated with a value of the tree item changes.

    Bindings.size(
        treeItem.getValue().getCashflowSet().getCashflows()
    ).addListener((o, old, new) -> 
        Event.fireEvent(
            treeItem, 
            new TreeItem.TreeModificationEvent<>(
                TreeItem.valueChangedEvent(), 
                treeItem, 
                treeItem.getValue()
            )
        )
    );
    

    You would also need to remove the listener when you wish to invalidate the binding (e.g. the tree item value changes), and potentially reassociate the item with a new binding.

    Example

    Example code is based on demo code from Sai's answer and includes logic for removing stale bindings and creating new bindings when the value associated with the tree item changes.

    import javafx.application.Application;
    import javafx.beans.binding.*;
    import javafx.beans.property.*;
    import javafx.beans.value.ChangeListener;
    import javafx.collections.*;
    import javafx.event.Event;
    import javafx.geometry.Insets;
    import javafx.scene.Scene;
    import javafx.scene.control.*;
    import javafx.scene.layout.VBox;
    import javafx.stage.Stage;
    
    import java.util.Random;
    
    public class TreeViewDemo extends Application {
    
        public static void main(String[] args) {
            launch(args);
        }
    
        private Task demoTask;
        private TreeItem<Task> demoTreeItem;
    
        @Override
        public void start(Stage primaryStage) {
            // BUILD DATA
            Random rnd = new Random();
            ObservableList<Task> tasks = FXCollections.observableArrayList();
            for (int i = 1; i < 10; i++) {
                Task sub1 = new Task("Sub Task A", rnd.nextBoolean());
                Task sub2 = new Task("Sub Task B", rnd.nextBoolean());
    
                Task tsk = new Task("Task " + i, rnd.nextBoolean());
                if (demoTask == null) {
                    tsk.setName("Demo Task");
                    demoTask = tsk;
                }
                tsk.getTasks().addAll(sub1, sub2);
                tasks.addAll(tsk);
            }
    
            // BUILD TREE ITEMS
            TreeItem<Task> rootItem = new MyTreeItem();
            rootItem.setExpanded(true);
            for (Task task : tasks) {
                TreeItem<Task> item = new MyTreeItem(task);
    
                for (Task subTask : task.getTasks()) {
                    TreeItem<Task> subItem = new MyTreeItem(subTask);
                    item.getChildren().add(subItem);
    
                    if (subTask == demoTask) {
                        demoTreeItem = subItem;
                    }
                }
    
                if (task == demoTask) {
                    demoTreeItem = item;
                }
    
                rootItem.getChildren().add(item);
            }
    
            TreeView<Task> treeView = new TreeView<>();
            treeView.setRoot(rootItem);
            treeView.setCellFactory(taskTreeView -> new MyTreeCell());
    
            Button addButton = new Button("Add");
            addButton.setOnAction(e -> demoTask.getCashFlows().add(1));
    
            Button changeButton = new Button("Change Task");
            changeButton.setOnAction(e -> {
                demoTask = createChangeTask();
                demoTreeItem.setValue(demoTask);
            });
    
            VBox root = new VBox(addButton, changeButton, treeView);
            root.setSpacing(10);
            root.setPadding(new Insets(10));
            primaryStage.setScene(new Scene(root));
            primaryStage.setTitle("TreeView Demo");
            primaryStage.show();
        }
    
        private Task createChangeTask() {
            Task changeTask = new Task("Change It", false);
    
            changeTask.getCashFlows().add(1);
            changeTask.getCashFlows().add(2);
    
            return changeTask;
        }
    
        class MyTreeItem extends TreeItem<Task> {
            public MyTreeItem() {
                super();
            }
    
            public MyTreeItem(Task value) {
                super(value);
                establishBindingForValueProperty();
                establishBindingForCashflowSize(null, value);
            }
    
            private void establishBindingForCashflowSize(Task oldValue, Task newValue) {
                // remove old size binding listener, so that if the cashflow associated with the old task changes,
                // it no longer triggers a value change event on this TreeItem.
                if (oldValue != null) {
                    sizeBinding.removeListener(sizeBindingListener);
                    sizeBinding = null;
                    sizeBindingListener = null;
                }
    
                // create a new size binding listener, so that when the cashflow associated with the task changes,
                // it triggers a value change event on this TreeItem.
                if (newValue != null) {
                    sizeBinding = Bindings.size(
                            getValue().getCashFlows()
                    );
    
                    sizeBindingListener = (observable1, oldValue1, newValue1) -> Event.fireEvent(
                            MyTreeItem.this,
                            new TreeModificationEvent<>(
                                    TreeItem.valueChangedEvent(),
                                    MyTreeItem.this,
                                    getValue()
                            )
                    );
    
                    sizeBinding.addListener(sizeBindingListener);
                }
            }
    
            private IntegerBinding sizeBinding;
            private ChangeListener<Number> sizeBindingListener;
            private void establishBindingForValueProperty() {
                valueProperty().addListener((observable, oldValue, newValue) ->
                        establishBindingForCashflowSize(oldValue, newValue)
                );
            }
        }
    
        class MyTreeCell extends TreeCell<Task> {
            @Override
            protected void updateItem(Task item, boolean empty) {
                super.updateItem(item, empty);
                if (item != null && !empty) {
                    setText(item.getName() + " (" + item.getCashFlows().size() + ")");
                } else {
                    setText(null);
                }
            }
        }
    
        class Task {
            StringProperty name = new SimpleStringProperty();
            BooleanProperty completed = new SimpleBooleanProperty();
            ObservableList<Task> tasks = FXCollections.observableArrayList();
    
            ObservableList<Integer> cashFlows = FXCollections.observableArrayList();
    
            public Task(String n, boolean c) {
                setName(n);
                setCompleted(c);
            }
    
            public String getName() {
                return name.get();
            }
    
            public StringProperty nameProperty() {
                return name;
            }
    
            public void setName(String name) {
                this.name.set(name);
            }
    
            public boolean isCompleted() {
                return completed.get();
            }
    
            public BooleanProperty completedProperty() {
                return completed;
            }
    
            public void setCompleted(boolean completed) {
                this.completed.set(completed);
            }
    
            public ObservableList<Task> getTasks() {
                return tasks;
            }
    
            public ObservableList<Integer> getCashFlows() {
                return cashFlows;
            }
        }
    }