Skywarp
Skywarp

Reputation: 1059

How do you make a ComboBox CellFactory properly disable items on the fly?

I have several ComboBox that are used to pick a date and time for an appointment scheduler application, but when I customize the CellFactory it only properly disables the correct items the very first time you click and expand. Basically it should disable selections that allow you to select a date and time that is prior to LocalDateTime.now(). Here is a video showing the erratic behavior: https://www.youtube.com/watch?v=niGoIvVx9Y0

Here is the code for my cell factories:

startHour.setCellFactory((final ListView<String> param) -> {
        return new ComboBoxListCell<String>() {
            @Override
            public void updateItem(String item, boolean empty) {
                super.updateItem(item, empty);
                if(empty || item == null) {
                    setText(null);
                    setGraphic(null);
                } else {
                    LocalDateTime selected = createDateTime(startDate.getValue(), item,
                            startMinute.getValue(), startMeridian.getValue());
                    if(selected.isBefore(LocalDateTime.now())) {
                        System.out.print(item + ", ");
                        setDisable(true);
                        setStyle(COMBOCELL_DISABLED_COLOR);
                    }
                }
            }
        };
    });

    startMinute.setCellFactory((final ListView<String> listView) -> {
        return new ComboBoxListCell<String>() {
            @Override
            public void updateItem(String item, boolean empty) {
                super.updateItem(item, empty);
                if(empty || item == null) {
                    setText(null);
                    setGraphic(null);
                } else {
                    LocalDateTime selected = createDateTime(startDate.getValue(), startHour.getValue(),
                            item, startMeridian.getValue());
                    if(selected.isBefore(LocalDateTime.now())) {
                        setDisable(true);
                        setStyle(COMBOCELL_DISABLED_COLOR);
                    }
                }
            }
        };
    });

    startMeridian.setCellFactory((final ListView<String> listView) -> {
        return new ComboBoxListCell<String>() {
            @Override public void updateItem(String item, boolean empty) {
                super.updateItem(item, empty);
                if(empty || item == null) {
                    setText(null);
                    setGraphic(null);
                } else {
                    LocalDateTime selected = createDateTime(startDate.getValue(), startHour.getValue(),
                            startMinute.getValue(), item);
                    if(selected.isBefore(LocalDateTime.now())) {
                        setDisable(true);
                        setStyle(COMBOCELL_DISABLED_COLOR);
                    }
                }
            }
        };
    });

the selected LocalDateTime is calculated using this method:

private LocalDateTime createDateTime(LocalDate date, String hour, String minute, String meridian) {
    String dateTimeStr = date + " " + hour + " "  + minute + " " + meridian;
    return LocalDateTime.parse(dateTimeStr, DateTimeFormatter.ofPattern("yyyy-MM-dd h m a"));
}

in startHour#setCellFactory I inserted a println statement showing the selected item converted into a LocalDateTime if it occured before LocalDateTime.now(), and this is the output I got:

1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 12, 12, 1, 9, 12, 1, 9, 12, 2, 1, 7, 9,

The current time was 9:48AM and the selected time was 10:00AM, so the intended condition appears to be met. However, as the video shows, the behavior is very erratic AFTER expanding the ComboBox for the first time. I have similar cell factories for the DatePicker nodes and they work just fine. Am I misunderstanding how ComboBox#setCellFactory is intended to be used?

Upvotes: 0

Views: 670

Answers (1)

James_D
James_D

Reputation: 209330

For the startHour, you need the disabled state of the cell to (potentially) update when any of the cell's current item, the selected start date, the selected start minute, or the selected meridian change. You can do this with a binding.

(Note that you are using the wrong cell class. A ComboBoxListCell is for displaying an editable cell in a list that uses a combo box as the editor. You just need a plain ListCell here.)

startHour.setCellFactory((final ListView<String> param) -> {
    ListCell<String> cell = new ListCell<String>() {
        @Override
        public void updateItem(String item, boolean empty) {
            super.updateItem(item, empty);
            setText(item); // works in empty case as item will be null, as desired
        }
    };

    cell.disableProperty().bind(Bindings.createBooleanBinding(
        () -> {
            if (cell.getItem() == null) return false ;
            LocalDateTime selected = createDateTime(startDate.getValue(), cell.getItem(),
                        startMinute.getValue(), startMeridian.getValue());
            return selected.isBefore(LocalDateTime.now());
        }, 
        cell.itemProperty(), 
        startMinute.valueProperty(), 
        startMeridian.valueProperty(), 
        startDate.valueProperty()
    ));

    return cell ;
});

The other cell factories will be similar.

For the style, I strongly recommend you use an external style sheet, with a rule such as:

.combo-box .list-cell:disabled {
    /* style for disabled cell here */
}

This keeps the code simpler, and means there is only one place to define the style for all the relevant disabled combo box cells.

Note that once a cell has been displayed that represents a time after the current time (so it is enabled), if it is displayed sufficiently later that it is no longer after the current time, then it won't be automatically disabled. (So, for example, if a cell representing 1pm on April 24th 2017 is first displayed at 12:55pm that day, it will be enabled. If it is displayed again 10 minutes later with no other UI changes that cause updateItem() to be called, it should be disabled, but will not be.) A complete solution would have the binding also observe the "current time" as it changes, for which you could do:

ObjectProperty<LocalDateTime> clock = new SimpleObjectProperty<>(LocalDateTime.now());
Timeline clockwork = new Timeline(new KeyFrame(Duration.seconds(1), 
        e -> clock.set(LocalDateTime.now())));
clockwork.setCycleCount(Animation.INDEFINITE);
clockwork.play();

and then add clock to the list of dependent values in the binding:

cell.disableProperty().bind(Bindings.createBooleanBinding(
    () -> {
        if (cell.getItem() == null) return false ;
        LocalDateTime selected = createDateTime(startDate.getValue(), cell.getItem(),
                    startMinute.getValue(), startMeridian.getValue());
        return selected.isBefore(clock.get());
    }, 
    cell.itemProperty(), 
    startMinute.valueProperty(), 
    startMeridian.valueProperty(), 
    startDate.valueProperty(),
    clock
));

Upvotes: 1

Related Questions