Enhanced AutoCompleteTextView Component with KMP Pattern Matching
This implementation enhances the standard Android AutoCompleteTextView by integrating the Knuth-Morris-Pratt (KMP) algorithm for efficient text patern matching. The component addresses a common bug where filtered results remain visible after clearing the input field.
public class KMPAutoCompleteTextView extends AppCompatAutoCompleteTextView {
private static final int DEFAULT_HIGHLIGHT_COLOR = Color.parseColor("#FF4081");
private static final int DEFAULT_TEXT_COLOR = Color.parseColor("#80000000");
private static final int DEFAULT_TEXT_SIZE = 40;
private float mTextSize;
private boolean mIgnoreCase;
private KMPAdapter mAdapter;
private ColorStateList mHighlightColor, mTextColor;
private List<PopupTextItem> mSourceData, mFilteredData;
private OnItemSelectedListener mSelectionListener;
public KMPAutoCompleteTextView(Context context) {
this(context, null);
}
public KMPAutoCompleteTextView(Context context, AttributeSet attrs) {
this(context, attrs, android.R.attr.autoCompleteTextViewStyle);
}
public KMPAutoCompleteTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize(context, attrs);
}
private void initialize(Context context, AttributeSet attrs) {
if (attrs != null) {
final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.KMPAutoCompleteTextView);
mTextColor = a.getColorStateList(R.styleable.KMPAutoCompleteTextView_completionTextColor);
mHighlightColor = a.getColorStateList(R.styleable.KMPAutoCompleteTextView_completionHighlightColor);
mTextSize = a.getDimensionPixelSize(R.styleable.KMPAutoCompleteTextView_completionTextSize, DEFAULT_TEXT_SIZE);
mIgnoreCase = a.getBoolean(R.styleable.KMPAutoCompleteTextView_completionIgnoreCase, false);
a.recycle();
}
setupTextWatcher();
}
private void setupTextWatcher() {
addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable s) {
handleTextChange(s.toString());
}
});
}
private void handleTextChange(String input) {
filterResults(input);
if (mAdapter.mList.size() == 0) {
KMPAutoCompleteTextView.this.dismissDropDown();
return;
}
mAdapter.notifyDataSetChanged();
if (!KMPAutoCompleteTextView.this.isPopupShowing() || mAdapter.mList.size() > 0) {
showDropDown();
}
}
public void showSuggestions() {
if (!KMPAutoCompleteTextView.this.isPopupShowing() || mAdapter.mList.size() > 0) {
showDropDown();
}
}
/**
* Sets the data source for suggestions
*
* @param items List of suggestion items
*/
public void setDataSource(final List<String> items) {
mAdapter = new KMPAdapter(getContext(), prepareData(items));
setAdapter(mAdapter);
}
public void setOnItemSelectedListener(OnItemSelectedListener listener) {
mSelectionListener = listener;
this.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
if (mSelectionListener == null) {
return;
}
mSelectionListener.onItemSelected(KMPAutoCompleteTextView.this.getText().toString());
}
});
}
private void filterResults(String input) {
List<PopupTextItem> source = mSourceData;
if (source == null || source.size() == 0) {
return;
}
List<PopupTextItem> filteredItems = new ArrayList<>();
List<String> filteredStrings = new ArrayList<>();
if(TextUtils.isEmpty(input)){
for (PopupTextItem item : source) {
PopupTextItem newItem = new PopupTextItem(item.text);
filteredItems.add(newItem);
filteredStrings.add(item.text);
}
} else {
for (PopupTextItem item : source) {
int matchIndex = findPatternMatch(item.text, input, mIgnoreCase);
if (-1 != matchIndex) {
PopupTextItem newItem = new PopupTextItem(item.text, matchIndex, matchIndex + input.length());
filteredItems.add(newItem);
filteredStrings.add(item.text);
}
}
}
mFilteredData = new ArrayList<>();
mFilteredData.addAll(filteredItems);
mAdapter.mList.clear();
mAdapter.mList.addAll(filteredStrings);
}
private List<String> prepareData(List<String> items) {
if (items == null || items.size() == 0) {
return null;
}
List<PopupTextItem> list = new ArrayList<>();
for (String text : items) {
list.add(new PopupTextItem(text));
}
mSourceData = new ArrayList<>();
mSourceData.addAll(list);
return items;
}
public void setIgnoreCase(boolean ignoreCase) {
mIgnoreCase = ignoreCase;
}
public boolean getIgnoreCase() {
return mIgnoreCase;
}
class KMPAdapter extends BaseAdapter implements Filterable {
private List<String> mList;
private Context mContext;
private CustomFilter mFilter;
public KMPAdapter(Context context, List<String> list) {
mContext = context;
mList = new ArrayList<>();
mList.addAll(list);
}
@Override
public int getCount() {
return mList == null ? 0 : mList.size();
}
@Override
public Object getItem(int position) {
return mList == null ? null : mList.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
ViewHolder holder = null;
if (convertView == null) {
holder = new ViewHolder();
TextView tv = new TextView(mContext);
int paddingX = DisplayUtil.dip2px(getContext(), 10.0f);
int paddingY = DisplayUtil.dip2px(getContext(), 5.0f);
tv.setPadding(paddingX, paddingY, paddingX, paddingY);
holder.tv = tv;
convertView = tv;
convertView.setTag(holder);
} else {
holder = (ViewHolder) convertView.getTag();
}
PopupTextItem item = mFilteredData == null ? mSourceData.get(position) : mFilteredData.get(position);
SpannableString ss = new SpannableString(item.text);
holder.tv.setTextColor(mTextColor == null ? DEFAULT_TEXT_COLOR : mTextColor.getDefaultColor());
holder.tv.setTextSize(mTextSize == 0 ? DEFAULT_TEXT_SIZE : DisplayUtil.px2sp(getContext(), mTextSize));
// Apply highlight color
if (-1 != item.startIndex) {
ss.setSpan(new ForegroundColorSpan(mHighlightColor == null ? DEFAULT_HIGHLIGHT_COLOR : mHighlightColor.getDefaultColor()),
item.startIndex, item.endIndex, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
holder.tv.setText(ss);
} else {
holder.tv.setText(item.text);
}
return convertView;
}
@Override
public Filter getFilter() {
if (mFilter == null) {
mFilter = new CustomFilter();
}
return mFilter;
}
private class ViewHolder {
TextView tv;
}
private class CustomFilter extends Filter {
@Override
protected FilterResults performFiltering(CharSequence constraint) {
FilterResults results = new FilterResults();
if (mList == null) {
mList = new ArrayList<>();
}
results.values = mList;
results.count = mList.size();
return results;
}
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
if (results.count > 0) {
notifyDataSetChanged();
} else {
notifyDataSetInvalidated();
}
}
}
}
public interface OnItemSelectedListener {
void onItemSelected(CharSequence text);
}
/**
* Computes the next function values for KMP algorithm
*
* @param pattern Character array of the pattern
* @return Next function values array
*/
private static int[] computeNextArray(char[] pattern) {
int[] next = new int[pattern.length];
next[0] = -1;
int i = 0;
int j = -1;
while (i < pattern.length - 1) {
if (j == -1 || pattern[i] == pattern[j]) {
i++;
j++;
if (pattern[i] != pattern[j]) {
next[i] = j;
} else {
next[i] = next[j];
}
} else {
j = next[j];
}
}
return next;
}
/**
* KMP pattern matching implementation
*
* @param text Source text
* @param pattern Pattern to search for
* @param ignoreCase Flag to ignore case sensitivity
* @return Index of match if found, -1 otherwise
*/
public int findPatternMatch(CharSequence text, CharSequence pattern, boolean ignoreCase) {
char[] patternArray = pattern.toString().toCharArray();
char[] textArray = text.toString().toCharArray();
int[] next = computeNextArray(patternArray);
int i = 0;
int j = 0;
while (i <= textArray.length - 1 && j <= patternArray.length - 1) {
if (ignoreCase) {
if (j == -1 || textArray[i] == patternArray[j] ||
String.valueOf(textArray[i]).equalsIgnoreCase(String.valueOf(patternArray[j]))) {
i++;
j++;
} else {
j = next[j];
}
} else {
if (j == -1 || textArray[i] == patternArray[j]) {
i++;
j++;
} else {
j = next[j];
}
}
}
if (j < patternArray.length) {
return -1;
} else
return i - patternArray.length; // Return starting index of pattern in text
}
}
PopupTextItem.java
public class PopupTextItem implements Serializable {
public String text;
public int startIndex = -1;
public int endIndex = -1;
public PopupTextItem(String text) {
this.text = text;
}
public PopupTextItem(String text, int startIndex) {
this.text = text;
this.startIndex = startIndex;
if (-1 != startIndex) {
this.endIndex = startIndex + text.length();
}
}
public PopupTextItem(String text, int startIndex, int endIndex) {
this.text = text;
this.startIndex = startIndex;
this.endIndex = endIndex;
}
}
attrs.xml
<declare-styleable name="KMPAutoCompleteTextView">
<attr name="completionHighlightColor" format="reference|color" />
<attr name="completionTextColor" format="reference|color" />
<attr name="completionTextSize" format="dimension" />
<attr name="completionIgnoreCase" format="boolean" />
</declare-styleable>
Implementation Example
List<String> data = new ArrayList<>();
data.add("Red roses for wedding");
data.add("Bouquet with red roses");
data.add("Single red rose flower");
data.add("Bouquet with red roses");
data.add("Single red rose flower");
data.add("Bouquet with red roses");
data.add("Single red rose flower");
data.add("Bouquet with red roses");
data.add("Single red rose flower");
data.add("Bouquet with red roses");
data.add("Single red rose flower");
data.add("Bouquet with red roses");
data.add("Single red rose flower");
data.add("Bouquet with red roses");
data.add("Single red rose flower");
data.add("Bouquet with red roses");
data.add("Single red rose flower");
data.add("Bouquet with red roses");
data.add("Single red rose flower");
KMPAutoCompleteTextView autoCompleteView = findViewById(R.id.autoCompleteTextView);
// Set threshold to show suggestions after first character
autoCompleteView.setThreshold(1);
autoCompleteView.setDataSource(data);
autoCompleteView.setOnItemSelectedListener(new KMPAutoCompleteTextView.OnItemSelectedListener() {
@Override
public void onItemSelected(CharSequence text) {
Toast.makeText(MainActivity.this, text.toString(), Toast.LENGTH_SHORT).show();
}
});
autoCompleteView.setOnClickListener(v -> autoCompleteView.showSuggestions());
binding.testButton.setOnClickListener(v -> autoCompleteView.showSuggestions());
Extension: AutoCompleteTextView with Clear Button
This component can be further extended to include a clear button functionality, allowing users too quickly remove the entered text and reset the suggestion list.