Building an autocomplete form that only accepts some values

A really common use case that doesn’t have an amazing out of the box solution is an auto complete textbox that needs to be constrained to a finite set of values. One example we have during our registration is choosing the nearest primary school to our farmer.

While it’s not immediately obvious whether you should be using filters, special adapters or array validators it’s actually rather easy to build a solution that works quite well for relatively small numbers of inputs.

We create a new view that extends AutoCompleteTextView. Since there’s no default constructor for AutoCompleteTextView, we end up extending all of the constructors. It’s quite likely that only some of these need to be overridden, but since the simplest ( super(context)) did not work, and the more complex constructors are hidden behind various min android SDKs, I kept it simple and overrode all of them.

public class FiniteAutoCompleteTextView extends AutoCompleteTextView {
    public FiniteAutoCompleteTextView(Context context) {
        super(context);
    }

    public FiniteAutoCompleteTextView(Context context,
                                      AttributeSet attrs) {
        super(context, attrs);
    }

    public FiniteAutoCompleteTextView(Context context,
                                      AttributeSet attrs,
                                      int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    public FiniteAutoCompleteTextView(Context context,
                                      AttributeSet attrs,
                                      int defStyleAttr,
                                      int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }

    @TargetApi(Build.VERSION_CODES.N)
    public FiniteAutoCompleteTextView(Context context,
                                      AttributeSet attrs,
                                      int defStyleAttr,
                                      int defStyleRes,
                                      Resources.Theme popupTheme) {
        super(context, attrs, defStyleAttr, defStyleRes, popupTheme);
    }
}

Then we want to build an AutoCompleteTextView.Validator, which is called to determine 1) if the text is valid and 2) if it’s not valid, to try and convert the text to valid text.

To implement #1, we implement public boolean isValid(CharSequence charSequence)

public class FiniteValidator<T extends ListAdapter & Filterable>
  implements AutoCompleteTextView.Validator {

  private T adapter;

  public FiniteValidator(T adapter) {
    this.adapter = adapter;
  }

  @Override
  public boolean isValid(CharSequence charSequence) {
    Filter filter = adapter.getFilter();
    for (int i = 0; i < adapter.getCount(); i++) {
      Object item = adapter.getItem(i);
      CharSequence stringItem = filter.convertResultToString(item);
      if (stringItem.toString().equals(charSequence.toString())) {
        return true;
      }
    }
    return false;
  }

  @Override
  public CharSequence fixText(CharSequence charSequence) {
    return null;
  }
}

Now, all you need to do is attach this validator to the AutoCompleteTextView anytime the adapter is set. Overriding the setAdapter method and we’re nearly done.

@Override
public <T extends ListAdapter & Filterable> void setAdapter(T adapter) {
    super.setAdapter(adapter);
    setValidator(new FiniteValidator<>(adapter));
}

At this point, you have a perfectly functioning, if still slightly unfriendly AutoCompleTextView. The painful bit here is that even if the casing is off in the text, the filter will interpret this as invalid and will wipe the input after the user loses focus on the field. While matching the user’s attempt at an input to the input item is probably domain specific, here’s a simple example that ignores case errors.

@Override
public CharSequence fixText(CharSequence charSequence) {
    Filter filter = adapter.getFilter();
    for (int i = 0; i < adapter.getCount(); i++) {
        Object item = adapter.getItem(i);
        CharSequence stringItem = filter.convertResultToString(item);
        if (stringItem
                .toString()
                .toUpperCase()
                .equals(charSequence.toString().toUpperCase())) {
            return stringItem;
        }
    }
    return null;
}

If your needs extend beyond basic matching, it may be worth looking into implementing a more complex matcher. For example, you could use Levenshtein Distance to compute the difference between the inputs and the canonical version and decide if they’re close enough together. Things get complicated quickly with more sophisticated approaches, as you may have multiple matches for your data set.

Putting it all together, here is a complete version of the code.

package com.apolloagriculture.charmander.view;

import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.Resources;
import android.os.Build;
import android.util.AttributeSet;
import android.widget.AutoCompleteTextView;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ListAdapter;

public class FiniteAutoCompleteTextView extends AutoCompleteTextView {
    public FiniteAutoCompleteTextView(Context context) {
        super(context);
    }

    public FiniteAutoCompleteTextView(Context context,
                                      AttributeSet attrs) {
        super(context, attrs);
    }

    public FiniteAutoCompleteTextView(Context context,
                                      AttributeSet attrs,
                                      int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    public FiniteAutoCompleteTextView(Context context,
                                      AttributeSet attrs,
                                      int defStyleAttr,
                                      int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }

    @TargetApi(Build.VERSION_CODES.N)
    public FiniteAutoCompleteTextView(Context context,
                                      AttributeSet attrs,
                                      int defStyleAttr,
                                      int defStyleRes,
                                      Resources.Theme popupTheme) {
        super(context, attrs, defStyleAttr, defStyleRes, popupTheme);
    }

    @Override
    public <T extends ListAdapter & Filterable> void setAdapter(T adapter) {
        super.setAdapter(adapter);
        setValidator(new FiniteValidator<>(adapter));
    }

    public class FiniteValidator<T extends ListAdapter & Filterable>
            implements AutoCompleteTextView.Validator {

        private T adapter;

        public FiniteValidator(T adapter) {
            this.adapter = adapter;
        }

        @Override
        public boolean isValid(CharSequence charSequence) {
            Filter filter = adapter.getFilter();
            for (int i = 0; i < adapter.getCount(); i++) {
                Object item = adapter.getItem(i);
                CharSequence stringItem = filter.convertResultToString(item);
                if (stringItem.toString().equals(charSequence.toString())) {
                    return true;
                }
            }
            return false;
        }

        @Override
        public CharSequence fixText(CharSequence charSequence) {
            Filter filter = adapter.getFilter();
            for (int i = 0; i < adapter.getCount(); i++) {
                Object item = adapter.getItem(i);
                CharSequence stringItem = filter.convertResultToString(item);
                if (stringItem
                        .toString()
                        .toUpperCase()
                        .equals(charSequence.toString().toUpperCase())) {
                    return stringItem;
                }
            }
            return null;
        }
    }
}