Set ComboBox to read only without disabling the ComboBox using JavaFX

Viewed 95

I do not want to disable the ComboBox because I want the user to be able to select the ComboBox button and look through the ComboBox items. And if the user tries selecting an item in the ComboBox, a window should pop up saying the user is in read only mode and the ComboBox should still have the original item in the ComboBox button cell.

Is there a way to do this?

By the way, I saw a previous post that asks this same question but using CheckComboBox from ControlsFX. But since I'm using a normal ComboBox from JavaFX 8, the solution from that post does not apply to a standard ComboBox.

Here's a minimal reproducible code example:

public class Main extends Application {
    Stage window;
    Scene scene;
    public static void main(String[] args) {
        launch(args);
    }

    @Override
    public void start(Stage primaryStage) {
        window = primaryStage;
        window.setTitle("Read Only ComboBox");

        ObservableList<String> strings = FXCollections.observableArrayList();
        for (int i = 0; i <= 10; i++)
            strings.add("Item " + i);

        // Create the ComboBox with the data
        ComboBox<String> comboBox = new ComboBox<>(strings);
        comboBox.getSelectionModel().select(3);

        // Set comboBox to read only

        HBox layout = new HBox(10);
        layout.setPadding(new Insets(20, 20, 20,20));
        layout.getChildren().addAll(comboBox);

        scene = new Scene(layout, 300, 250);
        window.setScene(scene);
        window.show();
    }
}

And I'm trying to get the ComboBox to look like this when a user selects the ComboBox button cell. And then once they select any item, a window should pop up saying they are in Read Only mode and the ComboBox should still have "Item 3" selected.

Edit 1: Here is the full stacktrace using Abra's code. I did not modify any of the code.

Exception in thread "JavaFX Application Thread" java.lang.IndexOutOfBoundsException
at com.sun.javafx.scene.control.ReadOnlyUnbackedObservableList.subList(ReadOnlyUnbackedObservableList.java:136)
at javafx.collections.ListChangeListener$Change.getAddedSubList(ListChangeListener.java:242)
at com.sun.javafx.scene.control.behavior.ListViewBehavior.lambda$new$59(ListViewBehavior.java:269)
at javafx.collections.WeakListChangeListener.onChanged(WeakListChangeListener.java:88)
at com.sun.javafx.collections.ListListenerHelper$Generic.fireValueChangedEvent(ListListenerHelper.java:329)
at com.sun.javafx.collections.ListListenerHelper.fireValueChangedEvent(ListListenerHelper.java:73)
at com.sun.javafx.scene.control.ReadOnlyUnbackedObservableList.callObservers(ReadOnlyUnbackedObservableList.java:75)
at javafx.scene.control.MultipleSelectionModelBase.clearAndSelect(MultipleSelectionModelBase.java:378)
at javafx.scene.control.ListView$ListViewBitSetSelectionModel.clearAndSelect(ListView.java:1403)
at com.sun.javafx.scene.control.behavior.CellBehaviorBase.simpleSelect(CellBehaviorBase.java:256)
at com.sun.javafx.scene.control.behavior.CellBehaviorBase.doSelect(CellBehaviorBase.java:220)
at com.sun.javafx.scene.control.behavior.CellBehaviorBase.mousePressed(CellBehaviorBase.java:150)
at com.sun.javafx.scene.control.skin.BehaviorSkinBase$1.handle(BehaviorSkinBase.java:95)
at com.sun.javafx.scene.control.skin.BehaviorSkinBase$1.handle(BehaviorSkinBase.java:89)
at com.sun.javafx.event.CompositeEventHandler$NormalEventHandlerRecord.handleBubblingEvent(CompositeEventHandler.java:218)
at com.sun.javafx.event.CompositeEventHandler.dispatchBubblingEvent(CompositeEventHandler.java:80)
at com.sun.javafx.event.EventHandlerManager.dispatchBubblingEvent(EventHandlerManager.java:238)
at com.sun.javafx.event.EventHandlerManager.dispatchBubblingEvent(EventHandlerManager.java:191)
at com.sun.javafx.event.CompositeEventDispatcher.dispatchBubblingEvent(CompositeEventDispatcher.java:59)
at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:58)
at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:56)
at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:56)
at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
at com.sun.javafx.event.BasicEventDispatcher.dispatchEvent(BasicEventDispatcher.java:56)
at com.sun.javafx.event.EventDispatchChainImpl.dispatchEvent(EventDispatchChainImpl.java:114)
at com.sun.javafx.event.EventUtil.fireEventImpl(EventUtil.java:74)
at com.sun.javafx.event.EventUtil.fireEvent(EventUtil.java:54)
at javafx.event.Event.fireEvent(Event.java:198)
at javafx.scene.Scene$MouseHandler.process(Scene.java:3757)
at javafx.scene.Scene$MouseHandler.access$1500(Scene.java:3485)
at javafx.scene.Scene.impl_processMouseEvent(Scene.java:1762)
at javafx.scene.Scene$ScenePeerListener.mouseEvent(Scene.java:2494)
at com.sun.javafx.tk.quantum.GlassViewEventHandler$MouseEventNotification.run(GlassViewEventHandler.java:394)
at com.sun.javafx.tk.quantum.GlassViewEventHandler$MouseEventNotification.run(GlassViewEventHandler.java:295)
at java.security.AccessController.doPrivileged(Native Method)
at com.sun.javafx.tk.quantum.GlassViewEventHandler.lambda$handleMouseEvent$358(GlassViewEventHandler.java:432)
at com.sun.javafx.tk.quantum.QuantumToolkit.runWithoutRenderLock(QuantumToolkit.java:389)
at com.sun.javafx.tk.quantum.GlassViewEventHandler.handleMouseEvent(GlassViewEventHandler.java:431)
at com.sun.glass.ui.View.handleMouseEvent(View.java:555)
at com.sun.glass.ui.View.notifyMouse(View.java:937)
at com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
at com.sun.glass.ui.win.WinApplication.lambda$null$152(WinApplication.java:177)
at java.lang.Thread.run(Thread.java:748)

Edit 2: Both sorifiend's and Abra's code work as intended but not on JDK 8 that includes JavaFX. I used Zulu JDK 17 w/ JavaFX and both of their code worked using that JDK. I'm still looking for a solution since the project I'm working on is set on using Java 8 SE for a desktop application.

3 Answers

Here is a rough solution that will capture the event and revert it to the default selection and we can do whatever else we want like showing a message. If the application is not in read only mode, then we can process the action as normal:

//Value to track read only mode
Boolean readOnlyMode = true;

//Values to help revert the selection
int defaultSelection = 3;
//We use a flag so that the message is not displayed twice when we reset the selection
//You can remove then need for this by checking the item that was selected instead and only show the message if it was not te default item
boolean flagToggle = false;

//Add an event to revert the selection and show a message
comboBox.setOnAction((ActionEvent t) ->
{
    if(readOnlyMode){
        //Code here to show a message and revert the selection
        if(!flagToggle){
            //Replace this line with your pop up dialogue, etc
            System.out.println("The application is in Read Only mode");
            //flip the flag for displaying the message
            flagToggle = true;
        }
        //restore the flag
        else{
            flagToggle = false;
        }

        //reset the selection to default, if the selection is already back to the default then this will not trigger another selection event
        comboBox.getSelectionModel().select(defaultSelection);
    }
    else{
        System.out.println("The application is in Edit mode. Item "+ comboBox.getValue() + " selected.");
        //perform normal actions
        //call some method here?
    }
});

Beware: the UX is terrible - we must not fool our users into believing they can change anything and tell them they couldn't after they tried!

Also, we must not (I know it's in the java doc somewhere but never find it when I need it ;) change the state of a property in a listener to that property: most of the time we get away with doing it, but it might have nasty, hard to debug side-effects.

All that said (and the boss insists on implementing the wrongish UX :) - here's an alternative to the doing the wrong thingy in a listener. The basic idea is to bind the combo's value to a fixed value. Doing so will effectively disconnect it from the selection state - users can use keys to change the selection (if the popup is closed) or navigate in the drop-down list (if the popup is showing) without changing the value.

Below is an example that

  • has a property that holds the fixed value
  • has a property that toggles the readOnly state
  • un/binds the combo's value from/to the fixed value based on the toggle state
  • sync's the selection on un/bind and on showing the popup
  • note: while bound, listeners to the selection state will still receive notifications from user interaction (which at that time are not in-sync with combo's value) - application code must be aware of that fact

The code:

public class ReadonlyComboSelection extends Application {
    StringProperty fixedValue;
    BooleanProperty readonly;
    @Override
    public void start(Stage primaryStage) {
        primaryStage.setTitle("Read Only ComboBox");

        ObservableList<String> strings = FXCollections.observableArrayList();
        for (int i = 0; i <= 10; i++)
            strings.add("Item " + i);

        // Create the ComboBox with the data
        ComboBox<String> comboBox = new ComboBox<>(strings);
        // initialize the fixed selection
        fixedValue = new SimpleStringProperty(strings.get(3));
        readonly = new SimpleBooleanProperty() {

            @Override
            protected void invalidated() {
                if (get()) {
                    comboBox.valueProperty().bind(fixedValue);
                } else {
                    comboBox.valueProperty().unbind();
                }
                comboBox.getSelectionModel().select(comboBox.getValue());
            }

        };
        readonly.set(true);

        // make sure the selection in the popup is showing the value
        comboBox.setOnShowing(e -> {
            if (comboBox.valueProperty().isBound()) {
                comboBox.getSelectionModel().select(comboBox.getValue());
                if (comboBox.getSkin() instanceof ComboBoxListViewSkin skin) {
                    ListView<String> list = (ListView<String>) skin.getPopupContent();
                    list.getSelectionModel().select(comboBox.getValue());
                }
            }
        });

        // just for fun: dynamically change the readonly state
        CheckBox check = new CheckBox("selection is readonly");
        check.selectedProperty().bindBidirectional(readonly);

        HBox layout = new HBox(10, comboBox, check);

        Scene scene = new Scene(layout, 300, 250);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

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

Add a ChangeListener to the selection model of the ComboBox. Whenever the selection changes, the below code restores the original selection and displays a message. Note that I arbitrarily set the initial ComboBox selection to the first item in its list of values.

import javafx.application.Application;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.SingleSelectionModel;
import javafx.stage.Stage;

public class ApplicationMain extends Application {

    @Override
    public void start(Stage stage) throws Exception {
        ComboBox<String> combo = new ComboBox<>(FXCollections.observableArrayList("First",
                                                                                  "Second",
                                                                                  "Third",
                                                                                  "Fourth",
                                                                                  "Last"));
        SingleSelectionModel<String> selectionModel = combo.getSelectionModel();
        selectionModel.select(0);
        selectionModel.selectedItemProperty().addListener(new ChangeListener<String>() {
            boolean flag = true;
            @Override
            public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
                if (flag) {
                    flag = false;
                    selectionModel.select(oldValue);
                    flag = true;
                    Alert alert = new Alert(AlertType.WARNING, "Read only mode.", ButtonType.CLOSE);
                    alert.setHeaderText(null);
                    alert.showAndWait();
                }
            }
        });
        Group root = new Group(combo);
        Scene scene = new Scene(root, 250.0D, 60.0D);
        stage.setTitle("Example");
        stage.setScene(scene);
        stage.show();
    }

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