package carbon.widget; import android.annotation.SuppressLint; import android.annotation.TargetApi; import android.content.Context; import android.os.Build; import android.support.annotation.NonNull; import android.text.Editable; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextWatcher; import android.text.style.ForegroundColorSpan; import android.util.AttributeSet; import android.view.inputmethod.EditorInfo; import java.util.ArrayList; import java.util.Collections; import java.util.List; import carbon.internal.SimpleTextWatcher; /** * This implementation extends EditText directly and uses TextWatcher for tracking text changes. * This class can be used to create new material search fields with drop down menus separated by a * seam. */ public class AutoCompleteEditText extends EditText { public static final int FILTERING_START = 0, FILTERING_PARTIAL = 1; private boolean autoCompleting = false; private int prevOptions; private OnFilterListener onFilterListener; private String prevText = ""; public void setDataProvider(AutoCompleteDataProvider dataProvider) { this.dataProvider = dataProvider; } @SuppressLint("ParcelCreator") public static class HintSpan extends ForegroundColorSpan { public HintSpan(int color) { super(color); } } public static class FilterResult implements Comparable<FilterResult> { int type; Spannable text; private Object item; public FilterResult(int type, Spannable text, Object item) { this.type = type; this.text = text; this.item = item; } public int getType() { return type; } public Spannable getText() { return text; } public Object getItem() { return item; } @Override public int compareTo(@NonNull FilterResult o) { if (type != o.type) return type - o.type; if (type == FILTERING_PARTIAL) { if (text.length() != o.text.length()) return text.length() - o.text.length(); } return text.toString().compareTo(o.text.toString()); } @Override public boolean equals(Object obj) { return text.equals(((FilterResult) obj).text); } } public interface AutoCompleteDataProvider<Type> { int getItemCount(); Type getItem(int i); String[] getItemWords(int i); } protected TextWatcher autoCompleteTextWatcher; AutoCompleteDataProvider dataProvider; public AutoCompleteEditText(Context context) { super(context); initAutoCompleteEditText(); } /** * XML constructor. Gets default parameters from R.attr.carbon_editTextStyle. * * @param context * @param attrs */ public AutoCompleteEditText(Context context, AttributeSet attrs) { super(context, attrs); initAutoCompleteEditText(); } public AutoCompleteEditText(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); initAutoCompleteEditText(); } @TargetApi(Build.VERSION_CODES.LOLLIPOP) public AutoCompleteEditText(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); initAutoCompleteEditText(); } private void initAutoCompleteEditText() { autoCompleteTextWatcher = new SimpleTextWatcher() { @Override public void afterTextChanged(Editable text) { if (!prevText.equals(text.toString())) autoComplete(); prevText = text.toString(); } }; addTextChangedListener(autoCompleteTextWatcher); setOnEditorActionListener((textView, actionId, keyEvent) -> { if (actionId == EditorInfo.IME_ACTION_DONE) { autoCompleting = true; Editable text = getText(); HintSpan[] spans = text.getSpans(0, length(), HintSpan.class); if (spans.length > 1) { throw new IllegalStateException("more than one HintSpan"); } int cursorPosition = getSelectionStart(); for (HintSpan span : spans) { if (cursorPosition == text.getSpanStart(span)) { text.removeSpan(span); break; } } setSelection(cursorPosition); AutoCompleteEditText.super.setImeOptions(prevOptions); autoCompleting = false; } return false; }); } private void autoComplete() { if (dataProvider == null) { return; } Editable text = getText(); if (autoCompleting) { return; } HintSpan[] spans = text.getSpans(0, length(), HintSpan.class); if (spans.length > 1) { throw new IllegalStateException("more than one HintSpan"); } int selStart = getSelectionStart(); if (selStart != getSelectionEnd()) { return; } for (HintSpan span : spans) { text.delete(text.getSpanStart(span), text.getSpanEnd(span)); } Word currentWord = getCurrentWord(); if (currentWord == null || currentWord.length() == 0) { fireOnFilterEvent(null); return; } autoCompleting = true; filter(currentWord); fireOnFilterEvent(filteredItems); if (filteredItems.size() != 0 && filteredItems.get(0).type == FILTERING_START) { String word = filteredItems.get(0).text.toString(); String remainingPart = word.substring(currentWord.preCursor.length()); text.insert(selStart, remainingPart); HintSpan span = new HintSpan(getCurrentHintTextColor()); setSelection(selStart); text.setSpan(span, selStart, selStart + remainingPart.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); AutoCompleteEditText.super.setImeOptions(EditorInfo.IME_ACTION_DONE); } autoCompleting = false; } private void fireOnFilterEvent(List<FilterResult> filteredItems) { if (onFilterListener != null) onFilterListener.onFilter(filteredItems); } static class Word { String preCursor; String postCursor; @Override public String toString() { return preCursor + postCursor; } public int length() { return preCursor.length() + postCursor.length(); } } private Word getCurrentWord() { if (getSelectionStart() != getSelectionEnd()) return null; int position = getSelectionStart(); Editable text = getText(); Word word = new Word(); int i; for (i = position - 1; i >= 0; i--) { char c = text.charAt(i); if (!Character.isLetterOrDigit(c)) break; } word.preCursor = text.subSequence(i + 1, position).toString(); for (i = position; i < length(); i++) { char c = text.charAt(i); if (!Character.isLetterOrDigit(c)) break; } HintSpan[] spans = text.getSpans(0, length(), HintSpan.class); if (spans.length > 0) position = text.getSpanStart(spans[0]); word.postCursor = text.subSequence(position, i).toString(); if (word.length() == 0) { text.delete(getSelectionStart(), i); return null; } return word; } @Override public void setText(CharSequence text, BufferType type) { prevText = getText().toString(); super.setText(text, type); } @Override public Editable getText() { try { return super.getText(); } catch (ClassCastException e) { return new SpannableStringBuilder(""); } } @Override protected void onSelectionChanged(int selStart, int selEnd) { if (autoCompleting) return; if (selStart == selEnd) { Editable text = getText(); HintSpan[] spans = text.getSpans(0, length(), HintSpan.class); if (spans.length > 1) throw new IllegalStateException("more than one HintSpan"); autoCompleting = true; if (spans.length == 1) { HintSpan span = spans[0]; if (selStart >= text.getSpanStart(span) && selStart < text.getSpanEnd(span)) { setSelection(text.getSpanStart(span)); } else if (selStart == text.getSpanEnd(span)) { text.removeSpan(span); super.setImeOptions(prevOptions); } } } autoComplete(); autoCompleting = false; super.onSelectionChanged(selStart, selEnd); } List<FilterResult> filteredItems = new ArrayList<>(); public void filter(AutoCompleteEditText.Word word) { filteredItems.clear(); if (word.length() == 0) return; String preCursor = word.preCursor.toLowerCase(); for (int i = 0; i < dataProvider.getItemCount(); i++) { String[] itemWords = dataProvider.getItemWords(i); matchItem(word, preCursor, i, itemWords); } Collections.sort(filteredItems); } private void matchItem(Word word, String preCursor, int i, String[] itemWords) { for (int j = 0; j < itemWords.length; j++) { String itemText = itemWords[j]; if (itemText.length() == word.length()) continue; itemText = itemText.toLowerCase(); if (itemText.indexOf(preCursor) == 0 && word.postCursor.length() == 0) { Spannable spannable = new SpannableStringBuilder(itemText); spannable.setSpan(new HintSpan(getCurrentHintTextColor()), preCursor.length(), itemText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); filteredItems.add(new FilterResult(AutoCompleteEditText.FILTERING_START, spannable, dataProvider.getItem(i))); return; } else { Spannable spannable = partialMatch(itemText, word); if (spannable != null) { filteredItems.add(new FilterResult(AutoCompleteEditText.FILTERING_PARTIAL, spannable, dataProvider.getItem(i))); return; } } } } private Spannable partialMatch(String item, AutoCompleteEditText.Word word) { // item: 'lemon', text: 'le' Spannable spannable = new SpannableStringBuilder(item); int i = 0, j = 0; String text = word.toString().toLowerCase(); for (; i < item.length() && j < text.length(); i++) { if (item.charAt(i) == text.charAt(j)) { j++; } else { spannable.setSpan(new AutoCompleteEditText.HintSpan(getCurrentHintTextColor()), i, i + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } spannable.setSpan(new AutoCompleteEditText.HintSpan(getCurrentHintTextColor()), i, item.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); if (j == text.length()) return spannable; return null; } @Override public void setImeOptions(int imeOptions) { super.setImeOptions(imeOptions); prevOptions = imeOptions; } /** * Replaces the current word with s. Used by Adapter to set the selected item as text. * * @param s text to replace with */ public void performCompletion(String s) { int selStart = getSelectionStart(); int selEnd = getSelectionEnd(); if (selStart != selEnd) return; Editable text = getText(); HintSpan[] spans = text.getSpans(0, length(), HintSpan.class); if (spans.length > 1) throw new IllegalStateException("more than one HintSpan"); Word word = getCurrentWord(); if (word == null) throw new IllegalStateException("no word to complete"); autoCompleting = true; //for (HintSpan span : spans) // text.delete(text.getSpanStart(span), text.getSpanEnd(span)); text.delete(selStart, selStart + word.postCursor.length()); text.delete(selStart - word.preCursor.length(), selStart); text.insert(selStart - word.preCursor.length(), s); setSelection(selStart - word.preCursor.length() + s.length()); fireOnFilterEvent(null); super.setImeOptions(prevOptions); autoCompleting = false; } public interface OnFilterListener { void onFilter(List<FilterResult> filterResults); } public void setOnFilterListener(OnFilterListener onFilterListener) { this.onFilterListener = onFilterListener; } public List<FilterResult> getFilteredItems() { return filteredItems; } }