1

Using this example here:

https://stackoverflow.com/a/47933342

I was able to create an auto complete dropdown search, however when I add a change listener to refresh data from the database, it gets called 3 times even though I've only selected a value once. I type in a country and click on the country and the output is:

CONNECT TO DATABASE

CONNECT TO DATABASE

CONNECT TO DATABASE

expected output is:

CONNECT TO DATABASE

Here is my code:

package autocomplete;

import com.sun.javafx.scene.control.skin.ComboBoxListViewSkin;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.Observable;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.scene.Scene;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Control;
import javafx.scene.control.ListView;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.stage.Stage;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;

public class Main extends Application
{

    public static class HideableItem<T>
    {

        private final ObjectProperty<T> object = new SimpleObjectProperty<>();
        private final BooleanProperty hidden = new SimpleBooleanProperty();

        private HideableItem(T object)
        {
            setObject(object);
        }

        private ObjectProperty<T> objectProperty()
        {
            return this.object;
        }

        private T getObject()
        {
            return this.objectProperty().get();
        }

        private void setObject(T object)
        {
            this.objectProperty().set(object);
        }

        private BooleanProperty hiddenProperty()
        {
            return this.hidden;
        }

        private boolean isHidden()
        {
            return this.hiddenProperty().get();
        }

        private void setHidden(boolean hidden)
        {
            this.hiddenProperty().set(hidden);
        }

        @Override
        public String toString()
        {
            return getObject() == null ? null : getObject().toString();
        }
    }

    @Override
    public void start(Stage stage)
    {
        List<String> countries = new ArrayList<>();
        for (String countryCode : Locale.getISOCountries())
        {

            Locale obj = new Locale("", countryCode);
            countries.add(obj.getDisplayCountry());

        }

        ComboBox<HideableItem<String>> comboBox = createComboBoxWithAutoCompletionSupport(countries);
        comboBox.setMaxWidth(Double.MAX_VALUE);

        comboBox.valueProperty().addListener(new ChangeListener()
        {
            @Override
            public void changed(ObservableValue observable, Object oldValue, Object newValue)
            {
                System.out.println("CONNECT TO DATABASE");
            }

        });

        HBox root = new HBox();
        root.getChildren().add(comboBox);

        Scene scene = new Scene(root);
        stage.setScene(scene);
        stage.show();

        comboBox.setMinWidth(comboBox.getWidth());
        comboBox.setPrefWidth(comboBox.getWidth());
    }

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

    private static <T> ComboBox<HideableItem<T>> createComboBoxWithAutoCompletionSupport(List<T> items)
    {
        ObservableList<HideableItem<T>> hideableHideableItems = FXCollections.observableArrayList(hideableItem -> new Observable[]
        {
            hideableItem.hiddenProperty()
        });

        items.forEach(item ->
        {
            HideableItem<T> hideableItem = new HideableItem<>(item);
            hideableHideableItems.add(hideableItem);
        });

        FilteredList<HideableItem<T>> filteredHideableItems = new FilteredList<>(hideableHideableItems, t -> !t.isHidden());

        ComboBox<HideableItem<T>> comboBox = new ComboBox<>();
        comboBox.setItems(filteredHideableItems);

        @SuppressWarnings("unchecked")
        HideableItem<T>[] selectedItem = (HideableItem<T>[]) new HideableItem[1];

        comboBox.addEventHandler(KeyEvent.KEY_PRESSED, event ->
        {
            if (!comboBox.isShowing())
            {
                return;
            }

            comboBox.setEditable(true);
            comboBox.getEditor().clear();
        });

        comboBox.showingProperty().addListener((observable, oldValue, newValue) ->
        {
            if (newValue)
            {
                @SuppressWarnings("unchecked")
                ListView<HideableItem> lv = ((ComboBoxListViewSkin<HideableItem>) comboBox.getSkin()).getListView();

                Platform.runLater(() ->
                {
                    if (selectedItem[0] == null) // first use
                    {
                        double cellHeight = ((Control) lv.lookup(".list-cell")).getHeight();
                        lv.setFixedCellSize(cellHeight);
                    }
                });

                lv.scrollTo(comboBox.getValue());
            } else
            {
                HideableItem<T> value = comboBox.getValue();
                if (value != null)
                {
                    selectedItem[0] = value;
                }

                comboBox.setEditable(false);

                Platform.runLater(() ->
                {
                    comboBox.getSelectionModel().select(selectedItem[0]);
                    comboBox.setValue(selectedItem[0]);
                });
            }
        });

        comboBox.setOnHidden(event -> hideableHideableItems.forEach(item -> item.setHidden(false)));

        comboBox.getEditor().textProperty().addListener((obs, oldValue, newValue) ->
        {
            if (!comboBox.isShowing())
            {
                return;
            }

            Platform.runLater(() ->
            {
                if (comboBox.getSelectionModel().getSelectedItem() == null)
                {
                    hideableHideableItems.forEach(item -> item.setHidden(!item.getObject().toString().toLowerCase().contains(newValue.toLowerCase())));
                } else
                {
                    boolean validText = false;

                    for (HideableItem hideableItem : hideableHideableItems)
                    {
                        if (hideableItem.getObject().toString().equals(newValue))
                        {
                            validText = true;
                            break;
                        }
                    }

                    if (!validText)
                    {
                        comboBox.getSelectionModel().select(null);
                    }
                }
            });
        });

        return comboBox;
    }
}

EDIT:

It seems that there is no real solution to this... so I just ended up listening to a ObjectProperty instead of listening to the change of the dropdown. I then updated the property if the value is not null.

trilogy
  • 1,738
  • 15
  • 31
  • What are the values of `oldValue` and `newValue` during each invocation? – Slaw Apr 15 '19 at 20:53
  • @Slaw they go like `oldValue = null`, `newValue = Canada` --- then it goes `oldValue = Canada`, `newValue = null`, then back to `oldValue = null`, `newValue = Canada` – trilogy Apr 15 '19 at 20:54
  • Hmm... like in [Simou's answer](https://stackoverflow.com/a/55697252/6395627) I can't reproduce the problem using your code. I only get one "CONNECT TO DATABASE" per change in selection. – Slaw Apr 15 '19 at 21:18
  • @Slaw Did you click the item with the mouse ? – trilogy Apr 15 '19 at 21:28
  • Ah, I wasn't doing it properly. I missed the fact I had to type and the select a filtered item. I _can_ reproduce the problem. – Slaw Apr 15 '19 at 21:31

3 Answers3

1

Was reading through and saw this, was pretty interesting, the reason you're seeing the extra entries is the following:

  • comboBox.setEditable(true) causes the comboBox to create a TextField and therefore set the value to null (the value of the text field)
  • Type into the box filters the list and then click selects an item
  • comboBox.setEditable(false) causes the comboBox to make the TextField null, therefore making the value null
  • comboBox.getSelectionModel().select(selectedItem[0]) finally sets it back to not null

Looking at the ComboBox/ComboBoxBase the comments on the editableProperty are

Note that when the editable property changes, the value property is reset, along with any other relevant state.

I'm not sure if there's a specific reason you're setting the editable flag off and on when the ComboBox is typed into and closed, but probably something you don't really need to do.

Finally, just for my own knowledge, is using a HideableItem and not resetting the predicate preferable over just using a String and updating the predicate when the text field value changes better? I've never seen an autocomplete in FX implemented this way and am curious.

kendavidson
  • 1,430
  • 1
  • 11
  • 18
  • Interesting... That would fix it, but an autocomplete dropdown is meant to function as a dropdown with a searchable field. For example, I can select a value, then start typing into the field again (which is counter-intuitive). Then it would seem that it's a `TextField` with suggestions, rather than a dropdown with a searchable field. – trilogy May 01 '19 at 13:55
  • I get where you're coming from, we've never actually had an issue or had it brought up doing it the textfield with suggestions way. To get exactly what you want, without testing it, I think the choices would be a) keep track of the original value and set it back when changing the editable flag b) create a new component that stacks a separate text field on top of the combo box to allow for typing, and marry the values together. – kendavidson May 01 '19 at 14:17
0

i just copied and ran your code, and it works properly, just as you expected, it show me just one output.

here is a screen of it

after your comment i tried with keyboard, since the purpose is autocompletion, and it happened to print 3 times, but not at once. and let me tell you, this is not a problem, this is how the on change listener works, each time you point at a new value, the listner fires the block of code it contains. HERE IS THE ORACLE DOCUMENTATION OF IT

but still, it depends on what are you planning to make in the listener, for now i advise you to work slowly and carefully. i'm available if you need any help with it.

Simou
  • 682
  • 9
  • 28
  • Did you click it with the mouse ? – trilogy Apr 15 '19 at 21:28
  • No, the problem—which I also initially missed—is when you type in order to filter the drop down and _then_ select a value. As mentioned in [trilogy's comment](https://stackoverflow.com/questions/55696979/combobox-changelistener-getting-called-3-times-in-autocompletedropdown?noredirect=1#comment98076503_55696979), the value becomes the newly selected item, becomes null, and then becomes the newly selected item again. – Slaw Apr 15 '19 at 21:51
  • Yes, this happens when you type something in, and then click a filtered value. Is there no way I can have the listener fire once? It's only being selected once. I don't want 3 calls to the database when it only needs 1. – trilogy Apr 16 '19 at 00:58
0

You can achieve 1 call to Database instead of 3 by filtering the values using simple if-else logic.

   boolean changeStat = true;
    String oldVal;

    cmbState.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> {
        try {
            if (!oldValue.getStateName().equals(newValue.getStateName())) {
                if (newValue.getStateName() != null) {
                    System.out.println("new : " + cmbState.getSelectionModel().getSelectedItem().getStateName());
                    oldVal = newValue.getStateName();
                }
            }
        } catch (NullPointerException ne) {
            if (oldValue == null && newValue != null && (oldValue != cmbState.getSelectionModel().getSelectedItem()) && changeStat) {
                System.out.println("new : " + cmbState.getSelectionModel().getSelectedItem().getStateName());
                oldVal = newValue.getStateName();
                changeStat = false;
            } else if (oldValue == null && newValue != null && (!oldVal.equals(cmbState.getSelectionModel().getSelectedItem().getStateName()))) {
                System.out.println("new1 : " + cmbState.getSelectionModel().getSelectedItem().getStateName());
                oldVal = newValue.getStateName();
            }
        }
    });