Custom AutoCompleteTextView with KMP Algorithm Implementation

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.

Tags: Android AutoCompleteTextView KMP Algorithm Text Matching Custom UI Components

Posted on Thu, 27 Aug 2026 16:29:51 +0000 by stukov