codenameone / CodenameOne

Cross-platform framework for building truly native mobile apps with Java or Kotlin. Write Once Run Anywhere support for iOS, Android, Desktop & Web.
https://www.codenameone.com/
Other
1.72k stars 409 forks source link

Do you want my AutoCompleteTextComponent class? #2705

Closed jsfan3 closed 5 years ago

jsfan3 commented 5 years ago

I created an AutoCompleteTextComponent class, that extends TextComponent and that can be used inside a TextModeLayout with a Validator. I extended TextComponent instead of InputComponent for compatibility with the current code of Validator. My use case is to allow the user to choose a city in the world, with a validation logic that disallows to choose an item that in not in the AutoComplete list. Of course this logic can be used every time that we need to force the user to choose an item in a given autocompleting list.

I'd like to share my code, so you can evaluate if my AutoCompleteTextComponent can be integrated in Codename One API. If you are interested, you can also fix anything that you can think wrong. At the moment, I have doubts about my overriding of initInput() (that is necessary to avoid a NullPointerException), for the rest it seems ok.

Screenshots on Android skin:

Looking for a city, see the autocomplete working: android_1

Selecting one of the items of the autocomplete list: android_2

Manually typing a non existent city, the Validator shows the error and disables the Button: android_3

Screenshots on iOS skin:

Looking for a city, see the autocomplete working: ios_1

Selecting one of the items of the autocomplete list: ios_2

Manually typing a non existent city, the Validator shows the error and disables the Button: ios_3

Please note that, in the last screenshot, the validation error message is not shown in the Label on the left because in my overriding of initInput() there isn't getEditor().setLabelForComponent(lbl); lbl.setFocusable(false);, that is something that I'm not able to fix.

Example of usage:

public void start() {
        if (current != null) {
            current.show();
            return;
        }
        Form hi = new Form("AutoComplete", new TextModeLayout(1, 1));

        final DefaultListModel<String> cities = new DefaultListModel<>();
        Map<String, String> resultQuery = new LinkedHashMap<>(5);

        AutoCompleteTextComponent textComponent = new AutoCompleteTextComponent(cities, new AutoCompleteFilter() {
            @Override
            public boolean filter(String text) {
                if (text.length() == 0) {
                    return false;
                }
                resultQuery.clear();
                resultQuery.putAll(searchLocations(text));
                String[] l = new String[resultQuery.size()];
                l = resultQuery.keySet().toArray(l);
                if (l == null || l.length == 0) {
                    return false;
                }

                cities.removeAll();
                for (String s : l) {
                    cities.addItem(s);
                }
                return true;
            }
        }).label("Città");

        textComponent.getAutoCompleteField().setMinimumElementsShownInPopup(5);
        textComponent.getAutoCompleteField().setStartsWithMode(true);

        Validator val = new Validator();
        Button button = new Button("Check");

        textComponent.getAutoCompleteField().addListListener(e -> {
            String selectedItem = (String) ((List) e.getSource()).getSelectedItem();
            String place_id = resultQuery.get(selectedItem);
            Log.p("Selected city: " + selectedItem + ", place_id: " + place_id, Log.DEBUG);
        });

        val.addConstraint(textComponent, new Constraint() {
            @Override
            public boolean isValid(Object value) {
                return resultQuery.containsKey(textComponent.getAutoCompleteField().getText());
            }

            @Override
            public String getDefaultFailMessage() {
                return "Seleziona dall'elenco";
            }
        });
        val.addSubmitButtons(button);

        hi.add(textComponent);
        hi.add(button);
        hi.show();
    }

    // Documentation:
    // https://www.codenameone.com/blog/dynamic-autocomplete.html
    // https://developers.google.com/places/web-service/autocomplete
    // https://developers.google.com/maps/documentation/javascript/examples/geocoding-place-id
    Map<String, String> searchLocations(String text) {
        try {
            if (text.length() > 0) {
                ConnectionRequest r = new ConnectionRequest();
                r.setPost(false);
                r.setUrl("https://maps.googleapis.com/maps/api/place/autocomplete/json");
                r.addArgument("key", apiKey);
                r.addArgument("input", text);
                r.addArgument("types", "(cities)");
                // uncomment the following two lines to restrict the search to a country
                // r.addArgument("components", "country:IT");
                // r.addArgument("region", "IT");
                r.addArgument("language", "IT");
                NetworkManager.getInstance().addToQueueAndWait(r);
                Map<String, Object> result = new JSONParser().parseJSON(new InputStreamReader(new ByteArrayInputStream(r.getResponseData()), "UTF-8"));
                String[] cities = Result.fromContent(result).getAsStringArray("//description");
                String[] place_ids = Result.fromContent(result).getAsStringArray("//place_id");
                Map<String, String> res = new LinkedHashMap<>();
                for (int i = 0; i < cities.length; i++) {
                    res.put(cities[i], place_ids[i]);
                }
                return res;
            }
        } catch (Exception err) {
            Log.e(err);
        }
        return null;
    }

Code of the AutoCompleteTextComponent class (and of the interface AutoCompleteFilter), that is mostly copied from TextComponent (at the moment it is without documentation, maybe the previous example of usage can be inserted in the documentation):

package com.codename1.ui;

public interface AutoCompleteFilter {
    boolean filter(String text);
}
package com.codename1.ui;

import com.codename1.ui.animations.ComponentAnimation;
import com.codename1.ui.layouts.BorderLayout;
import com.codename1.ui.layouts.LayeredLayout;
import com.codename1.ui.list.ListModel;

public class AutoCompleteTextComponent extends TextComponent {

    private final AutoCompleteTextField field;

    private Container animationLayer;
    private Boolean focusAnimation;
    private static int animationSpeed = 100;

    @Override
    protected void initInput() {
        setUIID("TextComponent");
    }

    /**
     * Allows us to invoke setters/getters and bind listeners to the text field
     * @return the text field instance
     */
    @Override
    public TextField getField() {
        return field;
    }

    /**
     * This constructor allows us to create an AutoCompleteTextComponent with the
     * given listModel and customFilter
     * @param listModel
     * @param customFilter
     */
    public AutoCompleteTextComponent(ListModel<String> listModel, AutoCompleteFilter customFilter) {
        field = new AutoCompleteTextField(listModel) {
            @Override
            void paintHint(Graphics g) {
                if (isFocusAnimation()) {
                    if (!hasFocus()) {
                        super.paintHint(g);
                    }
                } else {
                    super.paintHint(g);
                }
            }

            @Override
            void focusGainedInternal() {
                super.focusGainedInternal();
                if (isInitialized() && isFocusAnimation()) {
                    getLabel().setFocus(true);
                    if (!getLabel().isVisible()) {
                        final Label text = new Label(getHint(), "TextHint");
                        setHint("");
                        final Label placeholder = new Label();
                        Component.setSameSize(placeholder, field);
                        animationLayer.add(BorderLayout.NORTH, text);
                        animationLayer.add(BorderLayout.CENTER, placeholder);
                        text.setX(getX());
                        text.setY(getY());
                        text.setWidth(getWidth());
                        text.setHeight(getHeight());
                        ComponentAnimation anim = ComponentAnimation.compoundAnimation(animationLayer.createAnimateLayout(animationSpeed), text.createStyleAnimation("FloatingHint", animationSpeed));
                        getAnimationManager().addAnimation(anim, new Runnable() {
                            public void run() {
                                Component.setSameSize(field);
                                text.remove();
                                placeholder.remove();
                                getLabel().setVisible(true);
                            }
                        });
                    }
                }
            }

            @Override
            void focusLostInternal() {
                super.focusLostInternal();
                if (isInitialized() && isFocusAnimation()) {
                    getLabel().setFocus(false);
                    if (getText().length() == 0 && getLabel().isVisible()) {
                        final Label text = new Label(getLabel().getText(), getLabel().getUIID());
                        final Label placeholder = new Label();
                        Component.setSameSize(placeholder, getLabel());
                        animationLayer.add(BorderLayout.NORTH, placeholder);
                        animationLayer.add(BorderLayout.CENTER, text);
                        text.setX(getLabel().getX());
                        text.setY(getLabel().getY());
                        text.setWidth(getLabel().getWidth());
                        text.setHeight(getLabel().getHeight());
                        String hintLabelUIID = "TextHint";
                        if (getHintLabel() != null) {
                            hintLabelUIID = getHintLabel().getUIID();
                        }
                        ComponentAnimation anim = ComponentAnimation.compoundAnimation(animationLayer.createAnimateLayout(animationSpeed), text.createStyleAnimation(hintLabelUIID, animationSpeed));
                        getAnimationManager().addAnimation(anim, new Runnable() {
                            public void run() {
                                setHint(getLabel().getText());
                                getLabel().setVisible(false);
                                Component.setSameSize(getLabel());
                                text.remove();
                                placeholder.remove();
                            }
                        });
                    }
                }
            }

            @Override
            protected boolean filter(String text) {
                return customFilter.filter(text);
            }
        };
        initInput();
    }

    void constructUI() {
        if (getComponentCount() == 0) {
            if (isOnTopMode() && isFocusAnimation()) {
                getLabel().setUIID("FloatingHint");
                setLayout(new LayeredLayout());
                Container tfContainer = BorderLayout.center(field).
                        add(BorderLayout.NORTH, getLabel()).
                        add(BorderLayout.SOUTH, getErrorMessage());
                add(tfContainer);

                Label errorMessageFiller = new Label();
                Component.setSameSize(errorMessageFiller, getErrorMessage());
                animationLayer = BorderLayout.south(errorMessageFiller);
                add(animationLayer);
                if (field.getText() == null || field.getText().length() == 0) {
                    field.setHint(getLabel().getText());
                    getLabel().setVisible(false);
                }
            } else {
                super.constructUI();
            }
        }
    }

    /**
     * Returns the editor component e.g. text field picker etc.
     *
     * @return the editor component
     */
    @Override
    public Component getEditor() {
        return field;
    }

    void refreshForGuiBuilder() {
        if (guiBuilderMode) {
            if (animationLayer != null) {
                animationLayer.remove();
            }
            super.refreshForGuiBuilder();
        }
    }

    /**
     * The focus animation mode forces the hint and text to be identical and
     * animates the hint to the label when focus is in the text field as is
     * common on Android. This can be customized using the theme constant
     * {@code textComponentAnimBool} which is true by default on Android. Notice
     * that this is designed for the {@code onTopMode} and might not work if
     * that is set to false...
     *
     * @return true if the text should be on top
     */
    public boolean isFocusAnimation() {
        if (focusAnimation != null) {
            return focusAnimation.booleanValue();
        }
        return getUIManager().isThemeConstant("textComponentAnimBool", false);
    }

    /**
     * The focus animation mode forces the hint and text to be identical and
     * animates the hint to the label when focus is in the text field as is
     * common on Android. This can be customized using the theme constant
     * {@code textComponentAnimBool} which is true by default on Android. Notice
     * that this is designed for the {@code onTopMode} and might not work if
     * that is set to false...
     *
     * @param focusAnimation true for the label to animate into place on focus,
     * false otherwise
     * @return this for chaining calls E.g. {@code AutoCompleteTextComponent tc = new AutoCompleteTextComponent().text("Text").label("Label");
     * }
     */
    public AutoCompleteTextComponent focusAnimation(boolean focusAnimation) {
        this.focusAnimation = Boolean.valueOf(focusAnimation);
        refreshForGuiBuilder();
        return this;
    }

    /**
     * Sets the text of the field
     *
     * @param text the text
     * @return this for chaining calls E.g. {@code AutoCompleteTextComponent tc = new AutoCompleteTextComponent().text("Text").label("Label");
     * }
     */
    public AutoCompleteTextComponent text(String text) {
        field.setText(text);
        refreshForGuiBuilder();
        return this;
    }

    /**
     * Overridden for covariant return type {@inheritDoc}
     */
    public AutoCompleteTextComponent onTopMode(boolean onTopMode) {
        return (AutoCompleteTextComponent) super.onTopMode(onTopMode);
    }

    /**
     * Overridden for covariant return type {@inheritDoc}
     */
    public AutoCompleteTextComponent errorMessage(String errorMessage) {
        super.errorMessage(errorMessage);
        return this;
    }

    /**
     * Overridden for covariant return type      * {@inheritDoc}
 }
     */
    public AutoCompleteTextComponent label(String text) {
        super.label(text);
        return this;
    }

    /**
     * Convenience method for setting the label and hint together
     *
     * @param text the text and hint
     * @return this for chaining calls E.g. {@code AutoCompleteTextComponent tc = new AutoCompleteTextComponent().text("Text").label("Label");
     * }
     */
    public AutoCompleteTextComponent labelAndHint(String text) {
        super.label(text);
        hint(text);
        return this;
    }

    /**
     * Sets the hint of the field
     *
     * @param hint the text of the hint
     * @return this for chaining calls E.g. {@code AutoCompleteTextComponent tc = new AutoCompleteTextComponent().text("Text").label("Label");
     * }
     */
    public AutoCompleteTextComponent hint(String hint) {
        field.setHint(hint);
        refreshForGuiBuilder();
        return this;
    }

    /**
     * Sets the hint of the field
     *
     * @param hint the icon for the hint
     * @return this for chaining calls E.g. {@code AutoCompleteTextComponent tc = new AutoCompleteTextComponent().text("Text").label("Label");
     * }
     */
    public AutoCompleteTextComponent hint(Image hint) {
        field.setHintIcon(hint);
        refreshForGuiBuilder();
        return this;
    }

    /**
     * Sets the text field to multiline or single line
     *
     * @param multiline true for multiline, false otherwise
     * @return this for chaining calls E.g. {@code AutoCompleteTextComponent tc = new AutoCompleteTextComponent().text("Text").label("Label");
     * }
     */
    public AutoCompleteTextComponent multiline(boolean multiline) {
        field.setSingleLineTextArea(!multiline);
        refreshForGuiBuilder();
        return this;
    }

    /**
     * Sets the columns in the text field
     *
     * @param columns the number of columns which is used for preferred size
     * calculations
     * @return this for chaining calls E.g. {@code AutoCompleteTextComponent tc = new AutoCompleteTextComponent().text("Text").label("Label");
     * }
     */
    public AutoCompleteTextComponent columns(int columns) {
        field.setColumns(columns);
        refreshForGuiBuilder();
        return this;
    }

    /**
     * Sets the rows in the text field
     *
     * @param rows the number of rows which is used for preferred size
     * calculations
     * @return this for chaining calls E.g. {@code AutoCompleteTextComponent tc = new AutoCompleteTextComponent().text("Text").label("Label");
     * }
     */
    public AutoCompleteTextComponent rows(int rows) {
        field.setRows(rows);
        refreshForGuiBuilder();
        return this;
    }

    /**
     * Sets the constraint for text input matching the constraints from the text
     * area class
     *
     * @param constraint one of the constants from the
     * {@link com.codename1.ui.TextArea} class see
     * {@link com.codename1.ui.TextArea#setConstraint(int)}
     * @return this for chaining calls E.g. {@code AutoCompleteTextComponent tc = new AutoCompleteTextComponent().text("Text").label("Label");
     * }
     */
    public AutoCompleteTextComponent constraint(int constraint) {
        field.setConstraint(constraint);
        return this;
    }

    /**
     * Allows us to invoke setters/getters and bind listeners to the text field
     *
     * @return the text field instance
     */
    public AutoCompleteTextField getAutoCompleteField() {
        return field;
    }

    /**
     * {@inheritDoc}
     */
    public String[] getPropertyNames() {
        return new String[]{"text", "label", "hint", "multiline", "columns", "rows", "constraint"};
    }

    /**
     * {@inheritDoc}
     */
    public Class[] getPropertyTypes() {
        return new Class[]{String.class, String.class, String.class, Boolean.class, Integer.class, Integer.class, Integer.class};
    }

    /**
     * {@inheritDoc}
     */
    public String[] getPropertyTypeNames() {
        return new String[]{"String", "String", "String", "Boolean", "Integer", "Integer", "Integer"};
    }

    /**
     * {@inheritDoc}
     */
    public Object getPropertyValue(String name) {
        if (name.equals("text")) {
            return field.getText();
        }
        if (name.equals("hint")) {
            return field.getHint();
        }
        if (name.equals("multiline")) {
            return Boolean.valueOf(!field.isSingleLineTextArea());
        }
        if (name.equals("columns")) {
            return field.getColumns();
        }
        if (name.equals("rows")) {
            return field.getRows();
        }
        if (name.equals("constraint")) {
            return field.getConstraint();
        }

        return super.getPropertyValue(name);
    }

    /**
     * {@inheritDoc}
     */
    public String setPropertyValue(String name, Object value) {
        if (name.equals("text")) {
            text((String) value);
            return null;
        }
        if (name.equals("hint")) {
            hint((String) value);
            return null;
        }
        if (name.equals("multiline")) {
            field.setSingleLineTextArea(!((Boolean) value).booleanValue());
            return null;
        }
        if (name.equals("columns")) {
            field.setColumns((Integer) value);
            return null;
        }
        if (name.equals("rows")) {
            field.setRows((Integer) value);
            return null;
        }
        if (name.equals("constraint")) {
            field.setConstraint((Integer) value);
            return null;
        }
        return super.setPropertyValue(name, value);
    }

    /**
     * Returns the text in the field {@link com.codename1.ui.TextArea#getText()}
     *
     * @return the text
     */
    @Override
    public String getText() {
        return field.getText();
    }
}
codenameone commented 5 years ago

Thanks. I think this can be simplified by making some protected API's in TextComponent. I don't think we'll have a chance to work on that before the code freeze for 6.0 but it's something we should do.