6

I have to use a component which has autocomplete and multiselection, I will attach an image to show what I mean:

enter image description here

I know it is not supported by the base JavaFx but maybe you know where can I find any suggestion how to do it.

If there is any 3rd party library which has this functionality I would appreciate a link, or if doesn't then any suggestion / idea which helps me implementing it.

The autocomplete part is already implemented and answered here: JavaFX TextField Auto-suggestions so please don't suggest it. I'm interested in the multiselection part so after an element is found to be displayed in the textfield and I can look for further items.

Sunflame
  • 2,993
  • 4
  • 24
  • 48
  • 3
    About impementing this yourself there's already a question here https://stackoverflow.com/questions/37378973/implement-tags-bar-in-javafx (minus the suggestions popup.) The part of the question asking to suggest a library is off topic... – fabian Jun 11 '19 at 10:31
  • 1
    Thanks, I think I can combine both the autocomplete and the TagBar properties to achieve that I need. – Sunflame Jun 11 '19 at 10:40
  • 2
    The [JFoenix library](https://github.com/jfoenixadmin/JFoenix) has a control called `JFXChipView` (see the _Components_ section of the README). Based on your image that looks like what you want. If you don't want to use JFoenix maybe it can help you implement your own version (in addition to the question linked by fabian). – Slaw Jun 11 '19 at 11:37
  • 1
    I don't really can use JFoenix I asked for 3rd party solution exactly for this purpose to get some inspiration how to implement it, but the fabian's solution combined with the linked autocomplete, I added a keyevent to the backspace so I can delete an element pressing backspace, it works perfectly as I expect. – Sunflame Jun 11 '19 at 11:49
  • @Sunflame can you post your code? – trilogy Jun 11 '19 at 18:01
  • 2
    Sure, ill post it later, i have to make a demo project from my code – Sunflame Jun 12 '19 at 14:52

2 Answers2

2

Here is the solution which combines both the autocomplete and tagbar property.

public class AutocompleteMultiSelectionBox extends HBox {

    private final ObservableList<String> tags;
    private final ObservableSet<String> suggestions;
    private ContextMenu entriesPopup;
    private static final int MAX_ENTRIES = 10;

    private final TextField inputTextField;

    public AutocompleteMultiSelectionBox() {
        getStyleClass().setAll("tag-bar");
        getStylesheets().add(getClass().getResource("style.css").toExternalForm());
        tags = FXCollections.observableArrayList();
        suggestions = FXCollections.observableSet();
        inputTextField = new TextField();
        this.entriesPopup = new ContextMenu();
        setListner();
        inputTextField.setOnKeyPressed(event -> {
            // Remove last element with backspace
            if (event.getCode().equals(KeyCode.BACK_SPACE) && !tags.isEmpty() && inputTextField.getText().isEmpty()) {
                String last = tags.get(tags.size() - 1);
                suggestions.add(last);
                tags.remove(last);
            }
        });

        inputTextField.prefHeightProperty().bind(this.heightProperty());
        HBox.setHgrow(inputTextField, Priority.ALWAYS);
        inputTextField.setBackground(null);

        tags.addListener((ListChangeListener.Change<? extends String> change) -> {
            while (change.next()) {
                if (change.wasPermutated()) {
                    ArrayList<Node> newSublist = new ArrayList<>(change.getTo() - change.getFrom());
                    for (int i = change.getFrom(), end = change.getTo(); i < end; i++) {
                        newSublist.add(null);
                    }
                    for (int i = change.getFrom(), end = change.getTo(); i < end; i++) {
                        newSublist.set(change.getPermutation(i), getChildren().get(i));
                    }
                    getChildren().subList(change.getFrom(), change.getTo()).clear();
                    getChildren().addAll(change.getFrom(), newSublist);
                } else {
                    if (change.wasRemoved()) {
                        getChildren().subList(change.getFrom(), change.getFrom() + change.getRemovedSize()).clear();
                    }
                    if (change.wasAdded()) {
                        getChildren().addAll(change.getFrom(), change.getAddedSubList().stream().map(Tag::new).collect(
                                Collectors.toList()));
                    }
                }
            }
        });
        getChildren().add(inputTextField);
    }

    /**
     * Build TextFlow with selected text. Return "case" dependent.
     *
     * @param text   - string with text
     * @param filter - string to select in text
     * @return - TextFlow
     */
    private static TextFlow buildTextFlow(String text, String filter) {
        int filterIndex = text.toLowerCase().indexOf(filter.toLowerCase());
        Text textBefore = new Text(text.substring(0, filterIndex));
        Text textAfter = new Text(text.substring(filterIndex + filter.length()));
        Text textFilter = new Text(text.substring(filterIndex,
                filterIndex + filter.length())); //instead of "filter" to keep all "case sensitive"
        textFilter.setFill(Color.ORANGE);
        textFilter.setFont(Font.font("Helvetica", FontWeight.BOLD, 12));
        return new TextFlow(textBefore, textFilter, textAfter);
    }

    /**
     * "Suggestion" specific listners
     */
    private void setListner() {
        //Add "suggestions" by changing text
        inputTextField.textProperty().addListener((observable, oldValue, newValue) -> {
            //always hide suggestion if nothing has been entered (only "spacebars" are dissalowed in TextFieldWithLengthLimit)
            if (newValue.isEmpty()) {
                entriesPopup.hide();
            } else {
                //filter all possible suggestions depends on "Text", case insensitive
                List<String> filteredEntries = suggestions.stream()
                        .filter(e -> e.toLowerCase().contains(newValue.toLowerCase()))
                        .collect(Collectors.toList());
                //some suggestions are found
                if (!filteredEntries.isEmpty()) {
                    //build popup - list of "CustomMenuItem"
                    populatePopup(filteredEntries, newValue);
                    if (!entriesPopup.isShowing()) { //optional
                        entriesPopup.show(this, Side.BOTTOM, 0, 0); //position of popup
                    }
                    //no suggestions -> hide
                } else {
                    entriesPopup.hide();
                }
            }
        });

        //Hide always by focus-in (optional) and out
        focusedProperty().addListener((observableValue, oldValue, newValue) -> entriesPopup.hide());
    }

    /**
     * Populate the entry set with the given search results. Display is limited to 10 entries, for performance.
     *
     * @param searchResult The set of matching strings.
     */
    private void populatePopup(List<String> searchResult, String searchRequest) {
        //List of "suggestions"
        List<CustomMenuItem> menuItems = new LinkedList<>();
        //Build list as set of labels
        searchResult.stream()
                .limit(MAX_ENTRIES) // Limit to MAX_ENTRIES in the suggestions
                .forEach(result -> {
                    //label with graphic (text flow) to highlight founded subtext in suggestions
                    TextFlow textFlow = buildTextFlow(result, searchRequest);
                    textFlow.prefWidthProperty().bind(AutocompleteMultiSelectionBox.this.widthProperty());
                    CustomMenuItem item = new CustomMenuItem(textFlow, true);
                    menuItems.add(item);

                    //if any suggestion is select set it into text and close popup
                    item.setOnAction(actionEvent -> {
                        tags.add(result);
                        suggestions.remove(result);
                        inputTextField.clear();
                        entriesPopup.hide();
                    });
                });

        //"Refresh" context menu
        entriesPopup.getItems().clear();
        entriesPopup.getItems().addAll(menuItems);
    }

    public final ObservableList<String> getTags() {
        return tags;
    }

    public final ObservableSet<String> getSuggestions() {
        return suggestions;
    }

    /**
     * Clears then repopulates the entries with the new set of data.
     *
     * @param suggestions set of items.
     */

    public final void setSuggestions(ObservableSet<String> suggestions) {
        this.suggestions.clear();
        this.suggestions.addAll(suggestions);
    }

    private class Tag extends HBox {
        Tag(String tag) {
            // Style
            getStyleClass().add("tag");

            // Remove item button
            Button removeButton = new Button("x");
            removeButton.setBackground(null);
            removeButton.setOnAction(event -> {
                tags.remove(tag);
                suggestions.add(tag);
                inputTextField.requestFocus();
            });

            // Displayed text
            Text text = new Text(tag);
            text.setFill(Color.WHITE);
            text.setFont(Font.font(text.getFont().getFamily(), FontWeight.BOLD, text.getFont().getSize()));

            // Children position
            setAlignment(Pos.CENTER);
            setSpacing(5);
            setPadding(new Insets(0, 0, 0, 5));

            getChildren().addAll(text, removeButton);
        }
    }

}

.css

.tag-bar {
    -fx-border-color: lightblue;
    -fx-spacing: 3;
    -fx-padding: 3;
    -fx-max-height: 30;
}

.tag-bar .tag {
    -fx-background-color: -fx-selection-bar;
    -fx-border-radius: 5 5 5 5;
}

.tag-bar .tag {
    -fx-text-fill: white;
}

.tag-bar .tag .button{
    -fx-text-fill: orange;
    -fx-font-weight: bold;
}
Sunflame
  • 2,993
  • 4
  • 24
  • 48
0

Adding to @Sunflame's answer (Sorry for Kotlin code)

Add this after the popup is created on the constructor, if you want to add a new item

    inputTextField.onKeyTyped = EventHandler { event ->
        if ("\r" == event.character && inputTextField.text.isNotEmpty()) {
            val newTag = inputTextField.text
            suggestions.add(newTag)
            tags.add(newTag)
            inputTextField.text = ""
        }
    }

Thank you for the hard work, this is just a minor feature added

Florian Reisinger
  • 2,638
  • 4
  • 23
  • 34