3

In my JavaFX application I'm using Checkboxes in a TreeView to change the visibility of nodes.

  • checkbox selected = some nodes are visible
  • checkbox deselected = some nodes are invisible

In a special case, however, the user should be prompted to confirm their selection, because problems can arise when activating a specific checkbox. A dialog window opens in which the user can choose between "Yes" and "No". If the user chooses "Yes", nodes become visible and everything is fine. But if the user chooses "No", the checkbox should be deselected again.

My idea was to check the condition (in this case press "no" in a dialog window) in the ChangeListener and if it's true, set the selected value to false.

But for whatever reason, it didn't work. After that, I figured out that it works with the refresh() method of the TreeView.

Questions

  1. Why doesn't it work without the refresh() method, why setSelected() is ignored?
  2. Should I use the refresh() method?
  3. Is there a better workaround to change the selection status?

Minimal reproducible example

Using the refresh() line will show the desired behavior: The checkbox remains unselected after clicking because 5 > 4 (5>4 simulates for example pressing "no" in a dialog window).

import javafx.application.Application;
import javafx.beans.value.ChangeListener;
import javafx.scene.Scene;
import javafx.scene.control.CheckBoxTreeItem;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import org.controlsfx.control.CheckTreeView;

public class HelloApplication extends Application {

    enum Names { TEST1, TEST2, TEST3, TEST4 }

    private final CheckTreeView<String> checkTreeView = new CheckTreeView<>();

    @Override
    public void start(Stage stage) {
        VBox vBox = new VBox();
        Scene scene = new Scene(vBox, 500, 500);
        setTreeView();
        vBox.getChildren().add(checkTreeView);
        stage.setScene(scene);
        stage.show();
    }

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


    public void setTreeView() {
        CheckBoxTreeItem<String> rootItem = new CheckBoxTreeItem<>("Root");
        rootItem.setExpanded(true);
        for (Names name : Names.values()) {
            CheckBoxTreeItem<String> item = new CheckBoxTreeItem<>(name.toString(), null);
            item.selectedProperty().addListener(this.onSelectionChanged(item));
            rootItem.getChildren().add(item);
        }
        this.checkTreeView.setRoot(rootItem);
    }

    private ChangeListener<Boolean> onSelectionChanged(CheckBoxTreeItem<String> item) {
        return (observableValue, previousChoice, newChoice) -> {

            if (newChoice) { // if checkbox is selected
                // if anything happens... for example press a "no" button in a dialog window
                if (5 > 4) {
                    System.out.println("reset checkbox status");
                    item.setSelected(previousChoice);
                }
            }

            // it works with refresh:
            // this.checkTreeView.refresh();
        };
    }
}

EDIT:

The solution of @VGR with

Platform.runLater(() -> item.setSelected(previousChoice));

works for the minimal reproducible example, but it doesn't seem to be the best way to do that. As already discussed in the comments, with Platform.runLater() it's

working most of the time

and

there's no guarantee because the exact timing is unspecified, will break f.i. if something in the pending events does also use runlater.

A solution / workaround that always works is desired...

Enrico
  • 415
  • 1
  • 10
  • 1
    don't use refresh, it's emergency api - to be used in extremely rare corner-cases only (which this scenario isn't)! – kleopatra Jan 15 '22 at 15:21
  • Thank you for the information. So when should it be used? – Enrico Jan 15 '22 at 15:26
  • if the data doesn't allow to be observed for some reason you have no control of (which personally, I would say never happens, but then the backend folks have different opinions :) – kleopatra Jan 15 '22 at 15:28
  • 1
    the slightly fishy thingy in this setup is that you revert the state of the sender in a listener to that state - that's not recommended (see api doc of ChangeListener). Again, we get away with doing it most of the time :) Not familar with controlsfx CheckTreeView, might have something that interferes .. – kleopatra Jan 15 '22 at 15:38
  • 1
    Find out what is changing the selected property. Execute your guard code that checks if the selection change would be valid *before* the selected property is set, only allowing the selected property if it is valid to do so. To implement this you may need to copy some library code into your project and modify it, as it may not be possible to do this without changing the internal implementation. – jewelsea Jan 16 '22 at 03:14
  • 1
    Perhaps disable or don’t show the checkbox if it is not valid to change it. You then enable it if the user permits it through some action, e.g. clicking on the disabled check box or an additional button or hyperlink. – jewelsea Jan 16 '22 at 03:17
  • @jewelsea In my example, the condition to changing the selected property is hitting a "yes" button in a dialog window (like a confirmation). How can I see the library code (in IntelliJ) and how can I implement a condition before the selected property is set? – Enrico Jan 16 '22 at 08:32
  • To see library code in idea: [Click the download button in the Maven tool window](https://www.jetbrains.com/help/idea/maven-projects-tool-window.html#toolbar). There may be a similar option for Gradle, I don’t know. Then you can browse and debug the library source exactly the same as you can do for your own. You could also clone the libraries as separate projects from git (openjfx and controlsfx source repos). – jewelsea Jan 16 '22 at 10:05
  • On implementing a condition before setting a selected property, that is trickier and I would have to spend quite some time to provide specific advice, which I will not do at this time. – jewelsea Jan 16 '22 at 10:10
  • Understandable, but thank you very much! – Enrico Jan 16 '22 at 10:27
  • after a bit of digging I think the culprit is the reverting of item's selected value in the listener: it's bidi-bound to the checkBox, reverting doesn't update the checkbox. Same happens if you have two checkboxes - just to see the selected - bound bidirectionally in their selectedProperty: listening/reverting the original directly does revert, doing the same in the bound does not revert the selected in the original. – kleopatra Jan 16 '22 at 13:37
  • _listening/reverting the original directly does revert, doing the same in the bound does not revert the selected in the original._ Shouldn't it be: reverting the original doesn't revert, but reverting the other does? – Enrico Jan 16 '22 at 13:56
  • no, but my comment is confusing ;) Will post some code as answer (and probably delete after you read it) – kleopatra Jan 16 '22 at 15:22

3 Answers3

2

What we are seeing in the OPs example actually is the standard behavior of bidirectionally bound properties, reduced to a simple (no ui, just properties) example.

The scenario:

  • there are two boolean properties, representing the data (checkBoxTreeItem) and ui (checkBox) selected properties
  • they are wired the same way as in a checkBoxTreeCell, that is the ui is bidirectionally bound to the data
  • a listener to the data reverts the data to false on receiving a change from false -> true

Just for showing that - though nor recommended - reverting a the state of the sender in a listener is working: we set the data property to true. At the end, both data and ui are false as we want it.

before data.set(true)
    state of data/ui: false / false
enter listener to ui: true
enter listener to data: true
enter listener to ui: false
enter listener to data: false
 ... returning
listener to data - after reset false
after data.set(true) - state of data/ui: false / false

For the real simulation of the OP's context: set the ui property (user clicks the checkBox) - at the end, the data property is reverted while the ui property is not. Technically, that's done in BidirectionalBinding which ignores incoming changes while updating.

before ui.set(true)
    state of data/ui: false / false
enter listener to data: true
enter listener to data: false
 ... returning
listener to data - after reset false
enter listener to ui: true
after ui.set(true) - state of data/ui: false / true

The example:

public class BidiBindingUpdating {

    public static void main(String[] args) {
        BooleanProperty data = new SimpleBooleanProperty();
        BooleanProperty ui = new SimpleBooleanProperty();
        ui.bindBidirectional(data);

        // listener to item: revert to false
        data.addListener((src, ov, nv) -> {
            System.out.println("enter listener to data: " + nv);
            if (!nv) {
                System.out.println(" ... returning");
                return;
            }
            data.set(ov);
            System.out.println("listener to data - after reset " + data.get());
        });

        // listener to check: logging only
        ui.addListener((src, ov, nv) -> {
            System.out.println("enter listener to ui: " + nv);
        });

        // set item directly:
        System.out.println("before data.set(true)");
        System.out.println("    state of data/ui: " + data.get() + " / " + ui.get());
        data.set(true);
        System.out.println("after data.set(true) - state of data/ui: " + data.get() + " / " + ui.get());

        // set bound property:
        System.out.println("\nbefore ui.set(true)");
        System.out.println("    state of data/ui: " + data.get() + " / " + ui.get());
        ui.set(true);
        System.out.println("after ui.set(true) - state of data/ui: " + data.get() + " / " + ui.get());

    }
}
kleopatra
  • 51,061
  • 28
  • 99
  • 211
  • Thanks for showing the complex behavior with an example. Can I access both properties (checkbox and item.selectedProperty()) ? So in my code they are automatically binded bidirectionally? And what I can't figure out right now: Does this give me the opportunity to fix the problem? – Enrico Jan 16 '22 at 17:11
  • no, the checkBox of the cell is internal api - application code must not talk to cells; yes, have a look at the implementation of CheckBoxTreeCell; no, unfortunately - it's not an answer, just for understanding :) – kleopatra Jan 16 '22 at 17:29
  • what you can try, though, is to implement a custom cell with a checkbox that uses the normal editing mechanism (vs. the core checkboxCell that changes the data under the feet of the editing mech) and have a commit handler that does the actual update of the data (though there's a bug in treeCell which doesn't respect handlers, you would have to work around that, too ;). – kleopatra Jan 16 '22 at 17:35
1

You are resetting the checkbox item too soon. It hasn’t finished its event processing yet.

You want to wait until it’s finished before you reset it. Use Platform.runLater to make sure you reset it after all pending events have been processed:

Platform.runLater(() -> item.setSelected(previousChoice));
VGR
  • 40,506
  • 4
  • 48
  • 63
  • Thanks for your answer, it works! But could you explain why this works? So ```Platform.runLater()``` waits for the event to finish? And is this the best solution in your opinion or is there a better way? – Enrico Jan 15 '22 at 15:25
  • the api doc: _Run the specified Runnable on the JavaFX Application Thread at some unspecified time in the future._ It's a hack we get away with most of the time. – kleopatra Jan 15 '22 at 15:30
  • My answer already explains why it works: the CheckBox item is still processing the events (such as a mouse event) that caused a change in its selected state. You were trying to change the state before the item was finished changing its own state, so your change was overwritten. – VGR Jan 15 '22 at 15:38
  • @VGR yeah, true - working most of the time (and here :). But again: there's no guarantee because the exact timing is unspecified, will break f.i. if something in the pending events does also use runlater. – kleopatra Jan 15 '22 at 15:41
  • "working most of the time" and "there's no guarantee because the exact timing is unspecified" sounds like this is not best practice – Enrico Jan 15 '22 at 15:50
1

I believe you got all the information about the issue from the previous answers and the comments in this post. So I will not repeat explaining the cause for the issue.

I can suggest one alternate workaround which is similar to @VGR's answer of using Platform.runLater. Though Platform.runLater acts like a master key to most of the issues, mostly it is not recommended regarding its unpredictability and the threading reasons.

The key reason for issue as mentioned in @VGR answer :

You are resetting the checkbox item too soon. It hasn’t finished its event processing yet. You want to wait until it’s finished before you reset it.

and in @kelopatra comment:

I think the culprit is the reverting of item's selected value in the listener

From the above reasons it looks like we can fix the issue, if we run the desired code at the end of the event processing (I mean after all the layout children is finished).

JavaFX has a very powerful class AnimationTimer. You can go through the API of this class, but in simple words, it will allow you to run your desired code at the end of every frame. In constrast to Platform.runLater, here we know when it is going to run. Infact usage of this class is actually recommened in Scene -> addPostLayoutPulseListener API.

Solution:

Create a utility class that accepts a Runnable and runs that code only ONCE at the end of the current scene pulse. By the time this code is executed, the layouting in the scene is already completed, so you will not have any issues.

import javafx.animation.AnimationTimer;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * Utility to run the provided runnable in each scene pulse.
 */
public final class PulseUtil extends AnimationTimer {

    /** Specifies the count of current pulse. */
    private static final int CURRENT_PULSE = 0;

    /** Specifies the count of the timer once it is started. */
    private final AtomicInteger count = new AtomicInteger(0);

    /** Specifies the order at which the runnable need to be executed. */
    private final int order;

    /** Runnable to execute in each frame. */
    private final Runnable callback;

    /**
     * Constructor.
     *
     * @param aCallBack runnable to execute
     * @param aOrder order at which the runnable need to be executed
     */
    private PulseUtil(final Runnable aCallBack, final int aOrder) {
        callback = aCallBack;
        order = aOrder;
    }

    /**
     * Executes the provided runnable at the end of the current pulse.
     *
     * @param callback runnable to execute
     */
    public static void doEndOfPulse(final Runnable callback) {
        new PulseUtil(callback, CURRENT_PULSE).start();
    }

    @Override
    public void handle(final long now) {
        if (count.getAndIncrement() == order) {
            try {
                callback.run();
            } finally {
                stop();
            }
        }
    }
}

And you will call this method in the change listener:

private ChangeListener<Boolean> onSelectionChanged(CheckBoxTreeItem<String> item) {
    return (observableValue, previousChoice, newChoice) -> {
        if (newChoice) { // if checkbox is selected
            // if anything happens... for example press a "no" button in a dialog window
            if (5 > 4) {
                System.out.println("reset checkbox status");
                PulseUtil.doEndOfPulse(()->item.setSelected(previousChoice));
            }
        }
    };
}
Sai Dandem
  • 8,229
  • 11
  • 26
  • Thank you for another solution! _Infact usage of this class is actually recommened in Scene_ so this is recommended in general, also for example to update the gui from another thread? – Enrico Jan 18 '22 at 07:49
  • This runs purely on JavaFX thread only. If you want to update gui from another thread, then you need to think for Platform.runLater – Sai Dandem Jan 18 '22 at 12:35