package com.tokenautocomplete;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.os.Build;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.annotation.NonNull;
import android.text.Editable;
import android.text.InputFilter;
import android.text.InputType;
import android.text.Layout;
import android.text.NoCopySpan;
import android.text.Selection;
import android.text.SpanWatcher;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.method.QwertyKeyListener;
import android.text.style.ForegroundColorSpan;
import android.util.AttributeSet;
import android.util.Log;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.accessibility.AccessibilityEvent;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputConnectionWrapper;
import android.view.inputmethod.InputMethodManager;
import android.widget.Filter;
import android.widget.ListView;
import android.widget.MultiAutoCompleteTextView;
import android.widget.TextView;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Gmail style auto complete view with easy token customization
* override getViewForObject to provide your token view
* <br>
* Created by mgod on 9/12/13.
*
* @author mgod
*/
public abstract class TokenCompleteTextView<T> extends MultiAutoCompleteTextView implements TextView.OnEditorActionListener {
//Logging
public static final String TAG = "TokenAutoComplete";
//When the token is deleted...
public enum TokenDeleteStyle {
_Parent, //...do the parent behavior, not recommended
Clear, //...clear the underlying text
PartialCompletion, //...return the original text used for completion
ToString //...replace the token with toString of the token object
}
//When the user clicks on a token...
public enum TokenClickStyle {
None(false), //...do nothing, but make sure the cursor is not in the token
Delete(false),//...delete the token
Select(true),//...select the token. A second click will delete it.
SelectDeselect(true);
private boolean mIsSelectable = false;
TokenClickStyle(final boolean selectable) {
mIsSelectable = selectable;
}
public boolean isSelectable() {
return mIsSelectable;
}
}
private char[] splitChar = {',', ';'};
private Tokenizer tokenizer;
private T selectedObject;
private TokenListener<T> listener;
private TokenSpanWatcher spanWatcher;
private TokenTextWatcher textWatcher;
private ArrayList<T> objects;
private List<TokenCompleteTextView<T>.TokenImageSpan> hiddenSpans;
private TokenDeleteStyle deletionStyle = TokenDeleteStyle._Parent;
private TokenClickStyle tokenClickStyle = TokenClickStyle.None;
private CharSequence prefix = "";
private boolean hintVisible = false;
private Layout lastLayout = null;
private boolean allowDuplicates = true;
private boolean focusChanging = false;
private boolean initialized = false;
private boolean performBestGuess = true;
private boolean savingState = false;
private boolean shouldFocusNext = false;
private boolean allowCollapse = true;
private int tokenLimit = -1;
/**
* Add the TextChangedListeners
*/
protected void addListeners() {
Editable text = getText();
if (text != null) {
text.setSpan(spanWatcher, 0, text.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
addTextChangedListener(textWatcher);
}
}
/**
* Remove the TextChangedListeners
*/
protected void removeListeners() {
Editable text = getText();
if (text != null) {
TokenSpanWatcher[] spanWatchers = text.getSpans(0, text.length(), TokenSpanWatcher.class);
for (TokenSpanWatcher watcher : spanWatchers) {
text.removeSpan(watcher);
}
removeTextChangedListener(textWatcher);
}
}
/**
* Initialise the variables and various listeners
*/
private void init() {
if (initialized) return;
// Initialise variables
setTokenizer(new MultiAutoCompleteTextView.CommaTokenizer());
objects = new ArrayList<>();
Editable text = getText();
assert null != text;
spanWatcher = new TokenSpanWatcher();
textWatcher = new TokenTextWatcher();
hiddenSpans = new ArrayList<>();
// Initialise TextChangedListeners
addListeners();
setTextIsSelectable(false);
setLongClickable(false);
//In theory, get the soft keyboard to not supply suggestions. very unreliable < API 11
setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS | InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE);
setHorizontallyScrolling(false);
// Listen to IME action keys
setOnEditorActionListener(this);
// Initialise the textfilter (listens for the splitchars)
setFilters(new InputFilter[]{new InputFilter() {
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
// Token limit check
if (tokenLimit != -1 && objects.size() == tokenLimit) {
return "";
} else if (source.length() == 1) {//Detect split characters, remove them and complete the current token instead
if (isSplitChar(source.charAt(0))) {
performCompletion();
return "";
}
}
//We need to not do anything when we would delete the prefix
if (dstart < prefix.length()) {
//when settext is called, which should only be called during
//restoring, dstart and dend are 0. If not checked, it will clear out the prefix.
//this is why we need to return null in this if condition to preserve state.
if (dstart == 0 && dend == 0) {
return null;
} else if (dend <= prefix.length()) {
//Don't do anything
return prefix.subSequence(dstart, dend);
} else {
//Delete everything up to the prefix
return prefix.subSequence(dstart, prefix.length());
}
}
return null;
}
}});
//We had _Parent style during initialization to handle an edge case in the parent
//now we can switch to Clear, usually the best choice
setDeletionStyle(TokenDeleteStyle.Clear);
initialized = true;
}
public TokenCompleteTextView(Context context) {
super(context);
init();
}
public TokenCompleteTextView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public TokenCompleteTextView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init();
}
@Override
protected void performFiltering(@NonNull CharSequence text, int start, int end,
int keyCode) {
if (start < prefix.length()) {
start = prefix.length();
}
Filter filter = getFilter();
if (filter != null) {
if (hintVisible) {
filter.filter("");
} else {
filter.filter(text.subSequence(start, end), this);
}
}
}
@Override
public void setTokenizer(Tokenizer t) {
super.setTokenizer(t);
tokenizer = t;
}
/**
* Set the action to be taken when a Token is removed
*
* @param dStyle The TokenDeleteStyle
*/
public void setDeletionStyle(TokenDeleteStyle dStyle) {
deletionStyle = dStyle;
}
/**
* Set the action to be taken when a Token is clicked
*
* @param cStyle The TokenClickStyle
*/
@SuppressWarnings("unused")
public void setTokenClickStyle(TokenClickStyle cStyle) {
tokenClickStyle = cStyle;
}
/**
* Set the listener that will be notified of changes in the Tokenlist
*
* @param l The TokenListener
*/
public void setTokenListener(TokenListener<T> l) {
listener = l;
}
/**
* Override if you want to prevent a token from being removed. Defaults to true.
* @param token the token to check
* @return false if the token should not be removed, true if it's ok to remove it.
*/
@SuppressWarnings("unused")
public boolean isTokenRemovable(T token) {
return true;
}
/**
* A String of text that is shown before all the tokens inside the EditText
* (Think "To: " in an email address field. I would advise against this: use a label and a hint.
*
* @param p String with the hint
*/
public void setPrefix(CharSequence p) {
//Have to clear and set the actual text before saving the prefix to avoid the prefix filter
prefix = "";
Editable text = getText();
if (text != null) {
text.insert(0, p);
}
prefix = p;
updateHint();
}
/**
* <p>You can get a color integer either using
* {@link android.support.v4.content.ContextCompat#getColor(android.content.Context, int)}
* or with {@link android.graphics.Color#parseColor(String)}.</p>
* <p>{@link android.graphics.Color#parseColor(String)}
* accepts these formats (copied from android.graphics.Color):
* You can use: '#RRGGBB', '#AARRGGBB'
* or one of the following names: 'red', 'blue', 'green', 'black', 'white',
* 'gray', 'cyan', 'magenta', 'yellow', 'lightgray', 'darkgray', 'grey',
* 'lightgrey', 'darkgrey', 'aqua', 'fuchsia', 'lime', 'maroon', 'navy',
* 'olive', 'purple', 'silver', 'teal'.</p>
*
* @param prefix prefix
* @param color A single color value in the form 0xAARRGGBB.
*/
public void setPrefix(CharSequence prefix, int color) {
SpannableString spannablePrefix = new SpannableString(prefix);
spannablePrefix.setSpan(new ForegroundColorSpan(color), 0, spannablePrefix.length(), 0);
setPrefix(spannablePrefix);
}
/**
* Get the list of Tokens
*
* @return List of tokens
*/
public List<T> getObjects() {
return objects;
}
/**
* Set a list of characters that should trigger the token creation
* Because spaces are difficult to handle, we add '§' as an additional splitChar
*
* @param splitChar char[] with a characters that trigger the token creation
*/
public void setSplitChar(char[] splitChar) {
char[] fixed = splitChar;
if (splitChar[0] == ' ') {
fixed = new char[splitChar.length + 1];
fixed[0] = '§';
System.arraycopy(splitChar, 0, fixed, 1, splitChar.length);
}
this.splitChar = fixed;
// Keep the tokenizer and splitchars in sync
this.setTokenizer(new CharacterTokenizer(splitChar));
}
/**
* Sets a single character to trigger the token creation
*
* @param splitChar char that triggers the token creation
*/
@SuppressWarnings("unused")
public void setSplitChar(char splitChar) {
setSplitChar(new char[]{splitChar});
}
/**
* Returns true if the character is currently configured as a splitChar
*
* @param c the char to test
* @return boolean
*/
private boolean isSplitChar(char c) {
for (char split : splitChar) {
if (c == split) return true;
}
return false;
}
/**
* Sets whether to allow duplicate objects. If false, when the user selects
* an object that's already in the view, the current text is just cleared.
* <br>
* Defaults to true. Requires that the objects implement equals() correctly.
*
* @param allow boolean
*/
@SuppressWarnings("unused")
public void allowDuplicates(boolean allow) {
allowDuplicates = allow;
}
/**
* Set whether we try to guess an entry from the autocomplete spinner or allow any text to be
* entered
*
* @param guess true to enable guessing
*/
@SuppressWarnings("unused")
public void performBestGuess(boolean guess) {
performBestGuess = guess;
}
/**
* Set whether the view should collapse to a single line when it loses focus.
*
* @param allowCollapse true if it should collapse
*/
@SuppressWarnings("unused")
public void allowCollapse(boolean allowCollapse) {
this.allowCollapse = allowCollapse;
}
/**
* Set a number of tokens limit.
*
* @param tokenLimit The number of tokens permitted. -1 value disables limit.
*/
@SuppressWarnings("unused")
public void setTokenLimit(int tokenLimit) {
this.tokenLimit = tokenLimit;
}
/**
* A token view for the object
*
* @param object the object selected by the user from the list
* @return a view to display a token in the text field for the object
*/
abstract protected View getViewForObject(T object);
/**
* Provides a default completion when the user hits , and there is no item in the completion
* list
*
* @param completionText the current text we are completing against
* @return a best guess for what the user meant to complete
*/
abstract protected T defaultObject(String completionText);
/**
* Correctly build accessibility string for token contents
*
* This seems to be a hidden API, but there doesn't seem to be another reasonable way
* @return custom string for accessibility
*/
@SuppressWarnings("unused")
public CharSequence getTextForAccessibility() {
if (getObjects().size() == 0) {
return getText();
}
SpannableStringBuilder description = new SpannableStringBuilder();
Editable text = getText();
int selectionStart = -1;
int selectionEnd = -1;
int i;
//Need to take the existing tet buffer and
// - replace all tokens with a decent string representation of the object
// - set the selection span to the corresponding location in the new CharSequence
for (i = 0; i < text.length(); ++i) {
//See if this is where we should start the selection
int origSelectionStart = Selection.getSelectionStart(text);
if (i == origSelectionStart) {
selectionStart = description.length();
}
int origSelectionEnd = Selection.getSelectionEnd(text);
if (i == origSelectionEnd) {
selectionEnd = description.length();
}
//Replace token spans
TokenImageSpan[] tokens = text.getSpans(i, i, TokenImageSpan.class);
if (tokens.length > 0) {
TokenImageSpan token = tokens[0];
description = description.append(tokenizer.terminateToken(token.getToken().toString()));
i = text.getSpanEnd(token);
continue;
}
description = description.append(text.subSequence(i, i + 1));
}
int origSelectionStart = Selection.getSelectionStart(text);
if (i == origSelectionStart) {
selectionStart = description.length();
}
int origSelectionEnd = Selection.getSelectionEnd(text);
if (i == origSelectionEnd) {
selectionEnd = description.length();
}
if (selectionStart >= 0 && selectionEnd >= 0) {
Selection.setSelection(description, selectionStart, selectionEnd);
}
return description;
}
/**
* Clear the completion text only.
*/
@SuppressWarnings("unused")
public void clearCompletionText() {
//Respect currentCompletionText in case hint is visible or if other checks are added.
if (currentCompletionText().length() == 0){
return;
}
Editable editable = getText();
int end = getCorrectedTokenEnd();
int start = getCorrectedTokenBeginning(end);
editable.delete(start, end);
}
@Override
public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
super.onInitializeAccessibilityEvent(event);
if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
CharSequence text = getTextForAccessibility();
event.setFromIndex(Selection.getSelectionStart(text));
event.setToIndex(Selection.getSelectionEnd(text));
event.setItemCount(text.length());
}
}
private int getCorrectedTokenEnd() {
Editable editable = getText();
int cursorPosition = getSelectionEnd();
return tokenizer.findTokenEnd(editable, cursorPosition);
}
private int getCorrectedTokenBeginning(int end) {
int start = tokenizer.findTokenStart(getText(), end);
if (start < prefix.length()) {
start = prefix.length();
}
return start;
}
protected String currentCompletionText() {
if (hintVisible) return ""; //Can't have any text if the hint is visible
Editable editable = getText();
int end = getCorrectedTokenEnd();
int start = getCorrectedTokenBeginning(end);
//Some keyboards add extra spaces when doing corrections, so
return TextUtils.substring(editable, start, end);
}
protected float maxTextWidth() {
return getWidth() - getPaddingLeft() - getPaddingRight();
}
boolean inInvalidate = false;
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
private void api16Invalidate() {
if (initialized && !inInvalidate) {
inInvalidate = true;
setShadowLayer(getShadowRadius(), getShadowDx(), getShadowDy(), getShadowColor());
inInvalidate = false;
}
}
@Override
public void invalidate() {
//Need to force the TextView private mEditor variable to reset as well on API 16 and up
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
api16Invalidate();
}
super.invalidate();
}
@Override
public boolean enoughToFilter() {
if (tokenizer == null || hintVisible) {
return false;
}
int cursorPosition = getSelectionEnd();
if (cursorPosition < 0) {
return false;
}
int end = getCorrectedTokenEnd();
int start = getCorrectedTokenBeginning(end);
//Don't allow 0 length entries to filter
return end - start >= Math.max(getThreshold(), 1);
}
@Override
public void performCompletion() {
if ((getAdapter() == null || getListSelection() == ListView.INVALID_POSITION) && enoughToFilter()) {
Object bestGuess;
if (getAdapter() != null && getAdapter().getCount() > 0 && performBestGuess) {
bestGuess = getAdapter().getItem(0);
} else {
bestGuess = defaultObject(currentCompletionText());
}
replaceText(convertSelectionToString(bestGuess));
} else {
super.performCompletion();
}
}
@Override
public InputConnection onCreateInputConnection(@NonNull EditorInfo outAttrs) {
InputConnection superConn = super.onCreateInputConnection(outAttrs);
if (superConn != null) {
TokenInputConnection conn = new TokenInputConnection(superConn, true);
outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_EXTRACT_UI;
return conn;
} else {
return null;
}
}
/**
* Create a token and hide the keyboard when the user sends the DONE IME action
* Use IME_NEXT if you want to create a token and go to the next field
*/
private void handleDone() {
// Attempt to complete the current token token
performCompletion();
// Hide the keyboard
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(
Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(getWindowToken(), 0);
}
@Override
public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) {
boolean handled = super.onKeyUp(keyCode, event);
if (shouldFocusNext) {
shouldFocusNext = false;
handleDone();
}
return handled;
}
@Override
public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) {
boolean handled = false;
switch (keyCode) {
case KeyEvent.KEYCODE_TAB:
case KeyEvent.KEYCODE_ENTER:
case KeyEvent.KEYCODE_DPAD_CENTER:
if (event.hasNoModifiers()) {
shouldFocusNext = true;
handled = true;
}
break;
case KeyEvent.KEYCODE_DEL:
handled = !canDeleteSelection(1) || deleteSelectedObject(false);
break;
}
return handled || super.onKeyDown(keyCode, event);
}
private boolean deleteSelectedObject(boolean handled) {
if (tokenClickStyle != null && tokenClickStyle.isSelectable()) {
Editable text = getText();
if (text == null) return handled;
TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class);
for (TokenImageSpan span : spans) {
if (span.view.isSelected()) {
removeSpan(span);
handled = true;
break;
}
}
}
return handled;
}
@Override
public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) {
if (action == EditorInfo.IME_ACTION_DONE) {
handleDone();
return true;
}
return false;
}
@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
int action = event.getActionMasked();
Editable text = getText();
boolean handled = false;
if (tokenClickStyle == TokenClickStyle.None) {
handled = super.onTouchEvent(event);
}
if (isFocused() && text != null && lastLayout != null && action == MotionEvent.ACTION_UP) {
int offset = getOffsetForPosition(event.getX(), event.getY());
if (offset != -1) {
TokenImageSpan[] links = text.getSpans(offset, offset, TokenImageSpan.class);
if (links.length > 0) {
links[0].onClick();
handled = true;
} else {
//We didn't click on a token, so if any are selected, we should clear that
clearSelections();
}
}
}
if (!handled && tokenClickStyle != TokenClickStyle.None) {
handled = super.onTouchEvent(event);
}
return handled;
}
@Override
protected void onSelectionChanged(int selStart, int selEnd) {
if (hintVisible) {
//Don't let users select the hint
selStart = 0;
}
//Never let users select text
selEnd = selStart;
if (tokenClickStyle != null && tokenClickStyle.isSelectable()) {
Editable text = getText();
if (text != null) {
clearSelections();
}
}
if (prefix != null && (selStart < prefix.length() || selEnd < prefix.length())) {
//Don't let users select the prefix
setSelection(prefix.length());
} else {
Editable text = getText();
if (text != null) {
//Make sure if we are in a span, we select the spot 1 space after the span end
TokenImageSpan[] spans = text.getSpans(selStart, selEnd, TokenImageSpan.class);
for (TokenImageSpan span : spans) {
int spanEnd = text.getSpanEnd(span);
if (selStart <= spanEnd && text.getSpanStart(span) < selStart) {
if (spanEnd == text.length())
setSelection(spanEnd);
else
setSelection(spanEnd + 1);
return;
}
}
}
super.onSelectionChanged(selStart, selEnd);
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
lastLayout = getLayout(); //Used for checking text positions
}
/**
* Collapse the view by removing all the tokens not on the first line. Displays a "+x" token.
* Restores the hidden tokens when the view gains focus.
*
* @param hasFocus boolean indicating whether we have the focus or not.
*/
public void performCollapse(boolean hasFocus) {
// Pause the spanwatcher
focusChanging = true;
if (!hasFocus) {
Editable text = getText();
if (text != null && lastLayout != null) {
// Display +x thingy if appropriate
int lastPosition = lastLayout.getLineVisibleEnd(0);
TokenImageSpan[] tokens = text.getSpans(0, lastPosition, TokenImageSpan.class);
int count = objects.size() - tokens.length;
// Make sure we don't add more than 1 CountSpan
CountSpan[] countSpans = text.getSpans(0, lastPosition, CountSpan.class);
if (count > 0 && countSpans.length == 0) {
lastPosition++;
CountSpan cs = new CountSpan(count, getContext(), getCurrentTextColor(),
(int) getTextSize(), (int) maxTextWidth());
text.insert(lastPosition, cs.text);
float newWidth = Layout.getDesiredWidth(text, 0,
lastPosition + cs.text.length(), lastLayout.getPaint());
//If the +x span will be moved off screen, move it one token in
if (newWidth > maxTextWidth()) {
text.delete(lastPosition, lastPosition + cs.text.length());
if (tokens.length > 0) {
TokenImageSpan token = tokens[tokens.length - 1];
lastPosition = text.getSpanStart(token);
cs.setCount(count + 1);
} else {
lastPosition = prefix.length();
}
text.insert(lastPosition, cs.text);
}
text.setSpan(cs, lastPosition, lastPosition + cs.text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// Remove all spans behind the count span and hold them in the hiddenSpans List
// The generic type information is not captured in TokenImageSpan.class so we have
// to perform a cast for the returned spans to coerce them to the proper generic type.
hiddenSpans = new ArrayList<>(Arrays.asList(
(TokenImageSpan[]) text.getSpans(lastPosition + cs.text.length(), text.length(), TokenImageSpan.class)));
for (TokenImageSpan span : hiddenSpans) {
removeSpan(span);
}
}
}
} else {
final Editable text = getText();
if (text != null) {
CountSpan[] counts = text.getSpans(0, text.length(), CountSpan.class);
for (CountSpan count : counts) {
text.delete(text.getSpanStart(count), text.getSpanEnd(count));
text.removeSpan(count);
}
// Restore the spans we have hidden
for (TokenImageSpan span : hiddenSpans) {
insertSpan(span);
}
hiddenSpans.clear();
if (hintVisible) {
setSelection(prefix.length());
} else {
// Slightly delay moving the cursor to the end. Inserting spans seems to take
// some time. (ugly, but what can you do :( )
postDelayed(new Runnable() {
@Override
public void run() {
setSelection(text.length());
}
}, 10);
}
TokenSpanWatcher[] watchers = getText().getSpans(0, getText().length(), TokenSpanWatcher.class);
if (watchers.length == 0) {
//Someone removes watchers? I'm pretty sure this isn't in this code... -mgod
text.setSpan(spanWatcher, 0, text.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
}
}
}
// Start the spanwatcher
focusChanging = false;
}
@Override
public void onFocusChanged(boolean hasFocus, int direction, Rect previous) {
super.onFocusChanged(hasFocus, direction, previous);
// See if the user left any unfinished tokens and finish them
if (!hasFocus) performCompletion();
// Clear sections when focus changes to avoid a token remaining selected
clearSelections();
// Collapse the view to a single line
if (allowCollapse) performCollapse(hasFocus);
}
@SuppressWarnings("unchecked cast")
@Override
protected CharSequence convertSelectionToString(Object object) {
selectedObject = (T) object;
//if the token gets deleted, this text will get put in the field instead
switch (deletionStyle) {
case Clear:
return "";
case PartialCompletion:
return currentCompletionText();
case ToString:
return object != null ? object.toString() : "";
case _Parent:
default:
return super.convertSelectionToString(object);
}
}
private SpannableStringBuilder buildSpannableForText(CharSequence text) {
//Add a sentinel , at the beginning so the user can remove an inner token and keep auto-completing
//This is a hack to work around the fact that the tokenizer cannot directly detect spans
//We don't want a space as the sentinel, and splitChar[0] is guaranteed to be something non-space
char sentinel = splitChar[0];
return new SpannableStringBuilder(String.valueOf(sentinel) + tokenizer.terminateToken(text));
}
protected TokenImageSpan buildSpanForObject(T obj) {
if (obj == null) {
return null;
}
View tokenView = getViewForObject(obj);
return new TokenImageSpan(tokenView, obj, (int) maxTextWidth());
}
@Override
protected void replaceText(CharSequence text) {
clearComposingText();
// Don't build a token for an empty String
if (selectedObject == null || selectedObject.toString().equals("")) return;
SpannableStringBuilder ssb = buildSpannableForText(text);
TokenImageSpan tokenSpan = buildSpanForObject(selectedObject);
Editable editable = getText();
int cursorPosition = getSelectionEnd();
int end = cursorPosition;
int start = cursorPosition;
if (!hintVisible) {
//If you force the drop down to show when the hint is visible, you can run a completion
//on the hint. If the hint includes commas, this truncates and inserts the hint in the field
end = getCorrectedTokenEnd();
start = getCorrectedTokenBeginning(end);
}
String original = TextUtils.substring(editable, start, end);
if (editable != null) {
if (tokenSpan == null) {
editable.replace(start, end, "");
} else if (!allowDuplicates && objects.contains(tokenSpan.getToken())) {
editable.replace(start, end, "");
} else {
QwertyKeyListener.markAsReplaced(editable, start, end, original);
editable.replace(start, end, ssb);
editable.setSpan(tokenSpan, start, start + ssb.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
}
@Override
public boolean extractText(@NonNull ExtractedTextRequest request, @NonNull ExtractedText outText) {
try {
return super.extractText(request, outText);
} catch (IndexOutOfBoundsException ignored) {
Log.d(TAG, "extractText hit IndexOutOfBoundsException. This may be normal.", ignored);
return false;
}
}
/**
* Append a token object to the object list
*
* @param object the object to add to the displayed tokens
* @param sourceText the text used if this object is deleted
*/
public void addObject(final T object, final CharSequence sourceText) {
post(new Runnable() {
@Override
public void run() {
if (object == null) return;
if (!allowDuplicates && objects.contains(object)) return;
if (tokenLimit != -1 && objects.size() == tokenLimit) return;
insertSpan(object, sourceText);
if (getText() != null && isFocused()) setSelection(getText().length());
}
});
}
/**
* Shorthand for addObject(object, "")
*
* @param object the object to add to the displayed token
*/
public void addObject(T object) {
addObject(object, "");
}
/**
* Remove an object from the token list. Will remove duplicates or do nothing if no object
* present in the view.
*
* @param object object to remove, may be null or not in the view
*/
public void removeObject(final T object) {
post(new Runnable() {
@Override
public void run() {
//To make sure all the appropriate callbacks happen, we just want to piggyback on the
//existing code that handles deleting spans when the text changes
Editable text = getText();
if (text == null) return;
// If the object is currently hidden, remove it
ArrayList<TokenImageSpan> toRemove = new ArrayList<>();
for (TokenImageSpan span : hiddenSpans) {
if (span.getToken().equals(object)) {
toRemove.add(span);
}
}
for (TokenImageSpan span : toRemove) {
hiddenSpans.remove(span);
// Remove it from the state and fire the callback
spanWatcher.onSpanRemoved(text, span, 0, 0);
}
updateCountSpan();
// If the object is currently visible, remove it
TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class);
for (TokenImageSpan span : spans) {
if (span.getToken().equals(object)) {
removeSpan(span);
}
}
}
});
}
/**
* Set the count span the current number of hidden objects
*/
private void updateCountSpan() {
Editable text = getText();
CountSpan[] counts = text.getSpans(0, text.length(), CountSpan.class);
int newCount = hiddenSpans.size();
for (CountSpan count : counts) {
if (newCount == 0) {
// No more hidden Objects: remove the CountSpan
text.delete(text.getSpanStart(count), text.getSpanEnd(count));
text.removeSpan(count);
} else {
// Update the CountSpan
count.setCount(hiddenSpans.size());
text.setSpan(count, text.getSpanStart(count), text.getSpanEnd(count), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
}
/**
* Remove a span from the current EditText and fire the appropriate callback
*
* @param span TokenImageSpan to be removed
*/
private void removeSpan(TokenImageSpan span) {
Editable text = getText();
if (text == null) return;
//If the spanwatcher has been removed, we need to also manually trigger onSpanRemoved
TokenSpanWatcher[] spans = text.getSpans(0, text.length(), TokenSpanWatcher.class);
if (spans.length == 0) {
spanWatcher.onSpanRemoved(text, span, text.getSpanStart(span), text.getSpanEnd(span));
}
//Add 1 to the end because we put a " " at the end of the spans when adding them
text.delete(text.getSpanStart(span), text.getSpanEnd(span) + 1);
if (allowCollapse && !isFocused()) {
updateCountSpan();
}
}
/**
* Insert a new span for an Object
*
* @param object Object to create a span for
* @param sourceText CharSequence to show when the span is removed
*/
private void insertSpan(T object, CharSequence sourceText) {
SpannableStringBuilder ssb = buildSpannableForText(sourceText);
TokenImageSpan tokenSpan = buildSpanForObject(object);
Editable editable = getText();
if (editable == null) return;
// If we're focused, or haven't hidden any objects yet, we can try adding it
if (!allowCollapse || isFocused() || hiddenSpans.isEmpty()) {
int offset = editable.length();
//There might be a hint visible...
if (hintVisible) {
//...so we need to put the object in in front of the hint
offset = prefix.length();
editable.insert(offset, ssb);
} else {
String completionText = currentCompletionText();
if (completionText != null && completionText.length() > 0) {
// The user has entered some text that has not yet been tokenized.
// Find the beginning of this text and insert the new token there.
offset = TextUtils.indexOf(editable, completionText);
}
editable.insert(offset, ssb);
}
editable.setSpan(tokenSpan, offset, offset + ssb.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// If we're not focused: collapse the view if necessary
if (!isFocused() && allowCollapse) performCollapse(false);
//In some cases, particularly the 1 to nth objects when not focused and restoring
//onSpanAdded doesn't get called
if (!objects.contains(object)) {
spanWatcher.onSpanAdded(editable, tokenSpan, 0, 0);
}
} else {
hiddenSpans.add(tokenSpan);
//Need to manually call onSpanAdded here as we're not putting the span on the text
spanWatcher.onSpanAdded(editable, tokenSpan, 0, 0);
updateCountSpan();
}
}
private void insertSpan(T object) {
String spanString;
// The information about the original text is lost here, so other than "toString" we have no data
if (deletionStyle == TokenDeleteStyle.ToString) {
spanString = object != null ? object.toString() : "";
} else {
spanString = "";
}
insertSpan(object, spanString);
}
private void insertSpan(TokenImageSpan span) {
insertSpan(span.getToken());
}
/**
* Remove all objects from the token list.
* We're handling this separately because removeObject doesn't always reliably trigger
* onSpanRemoved when called too fast.
* If removeObject is working for you, you probably shouldn't be using this.
*/
@SuppressWarnings("unused")
public void clear() {
post(new Runnable() {
@Override
public void run() {
// If there's no text, we're already empty
Editable text = getText();
if (text == null) return;
// Get all spans in the EditText and remove them
TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class);
for (TokenImageSpan span : spans) {
removeSpan(span);
// Make sure the callback gets called
spanWatcher.onSpanRemoved(text, span, text.getSpanStart(span), text.getSpanEnd(span));
}
}
});
}
private void updateHint() {
Editable text = getText();
CharSequence hintText = getHint();
if (text == null || hintText == null) {
return;
}
//Show hint if we need to
if (prefix.length() > 0) {
HintSpan[] hints = text.getSpans(0, text.length(), HintSpan.class);
HintSpan hint = null;
int testLength = prefix.length();
if (hints.length > 0) {
hint = hints[0];
testLength += text.getSpanEnd(hint) - text.getSpanStart(hint);
}
if (text.length() == testLength) {
hintVisible = true;
if (hint != null) {
return;//hint already visible
}
//We need to display the hint manually
Typeface tf = getTypeface();
int style = Typeface.NORMAL;
if (tf != null) {
style = tf.getStyle();
}
ColorStateList colors = getHintTextColors();
HintSpan hintSpan = new HintSpan(null, style, (int) getTextSize(), colors, colors);
text.insert(prefix.length(), hintText);
text.setSpan(hintSpan, prefix.length(), prefix.length() + getHint().length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
setSelection(prefix.length());
} else {
if (hint == null) {
return; //hint already removed
}
//Remove the hint. There should only ever be one
int sStart = text.getSpanStart(hint);
int sEnd = text.getSpanEnd(hint);
text.removeSpan(hint);
text.replace(sStart, sEnd, "");
hintVisible = false;
}
}
}
private void clearSelections() {
if (tokenClickStyle == null || !tokenClickStyle.isSelectable()) return;
Editable text = getText();
if (text == null) return;
TokenImageSpan[] tokens = text.getSpans(0, text.length(), TokenImageSpan.class);
for (TokenImageSpan token : tokens) {
token.view.setSelected(false);
}
invalidate();
}
protected class TokenImageSpan extends ViewSpan implements NoCopySpan {
private T token;
public TokenImageSpan(View d, T token, int maxWidth) {
super(d, maxWidth);
this.token = token;
}
public T getToken() {
return this.token;
}
public void onClick() {
Editable text = getText();
if (text == null) return;
switch (tokenClickStyle) {
case Select:
case SelectDeselect:
if (!view.isSelected()) {
clearSelections();
view.setSelected(true);
break;
}
if (tokenClickStyle == TokenClickStyle.SelectDeselect || !isTokenRemovable(token)) {
view.setSelected(false);
invalidate();
break;
}
//If the view is already selected, we want to delete it
case Delete:
if (isTokenRemovable(token)) {
removeSpan(this);
}
break;
case None:
default:
if (getSelectionStart() != text.getSpanEnd(this) + 1) {
//Make sure the selection is not in the middle of the span
setSelection(text.getSpanEnd(this) + 1);
}
}
}
}
public interface TokenListener<T> {
void onTokenAdded(T token);
void onTokenRemoved(T token);
}
private class TokenSpanWatcher implements SpanWatcher {
@SuppressWarnings("unchecked cast")
@Override
public void onSpanAdded(Spannable text, Object what, int start, int end) {
if (what instanceof TokenCompleteTextView<?>.TokenImageSpan && !savingState && !focusChanging) {
TokenImageSpan token = (TokenImageSpan) what;
objects.add(token.getToken());
if (listener != null)
listener.onTokenAdded(token.getToken());
}
}
@SuppressWarnings("unchecked cast")
@Override
public void onSpanRemoved(Spannable text, Object what, int start, int end) {
if (what instanceof TokenCompleteTextView<?>.TokenImageSpan && !savingState && !focusChanging) {
TokenImageSpan token = (TokenImageSpan) what;
if (objects.contains(token.getToken())) {
objects.remove(token.getToken());
}
if (listener != null)
listener.onTokenRemoved(token.getToken());
}
}
@Override
public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart, int nend) {
}
}
/**
* deletes tokens if you delete the space in front of them
* without this, you get the auto-complete dropdown a character early
*/
private class TokenTextWatcher implements TextWatcher {
ArrayList<TokenImageSpan> spansToRemove = new ArrayList<>();
protected void removeToken(TokenImageSpan token, Editable text) {
text.removeSpan(token);
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
// count > 0 means something will be deleted
if (count > 0 && getText() != null) {
Editable text = getText();
int end = start + count;
//If we're deleting a space, we want spans from 1 character before this start
if (text.charAt(start) == ' ') {
start -= 1;
}
TokenImageSpan[] spans = text.getSpans(start, end, TokenImageSpan.class);
//NOTE: I'm not completely sure this won't cause problems if we get stuck in a text changed loop
//but it appears to work fine. Spans will stop getting removed if this breaks.
ArrayList<TokenImageSpan> spansToRemove = new ArrayList<>();
for (TokenImageSpan token : spans) {
if (text.getSpanStart(token) < end && start < text.getSpanEnd(token)) {
spansToRemove.add(token);
}
}
this.spansToRemove = spansToRemove;
}
}
@Override
public void afterTextChanged(Editable text) {
ArrayList<TokenImageSpan> spansCopy = new ArrayList<>(spansToRemove);
spansToRemove.clear();
for (TokenImageSpan token : spansCopy) {
int spanStart = text.getSpanStart(token);
int spanEnd = text.getSpanEnd(token);
removeToken(token, text);
//The end of the span is the character index after it
spanEnd--;
//Delete any extra split chars
if (spanEnd >= 0 && isSplitChar(text.charAt(spanEnd))) {
text.delete(spanEnd, spanEnd + 1);
}
if (spanStart >= 0 && isSplitChar(text.charAt(spanStart))) {
text.delete(spanStart, spanStart + 1);
}
}
clearSelections();
updateHint();
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
}
protected ArrayList<Serializable> getSerializableObjects() {
ArrayList<Serializable> serializables = new ArrayList<>();
for (Object obj : getObjects()) {
if (obj instanceof Serializable) {
serializables.add((Serializable) obj);
} else {
Log.e(TAG, "Unable to save '" + obj + "'");
}
}
if (serializables.size() != objects.size()) {
String message = "You should make your objects Serializable or override\n" +
"getSerializableObjects and convertSerializableArrayToObjectArray";
Log.e(TAG, message);
}
return serializables;
}
@SuppressWarnings("unchecked")
protected ArrayList<T> convertSerializableArrayToObjectArray(ArrayList<Serializable> s) {
return (ArrayList<T>) (ArrayList) s;
}
@Override
public Parcelable onSaveInstanceState() {
ArrayList<Serializable> baseObjects = getSerializableObjects();
//We don't want to save the listeners as part of the parent
//onSaveInstanceState, so remove them first
removeListeners();
//ARGH! Apparently, saving the parent state on 2.3 mutates the spannable
//prevent this mutation from triggering add or removes of token objects ~mgod
savingState = true;
Parcelable superState = super.onSaveInstanceState();
savingState = false;
SavedState state = new SavedState(superState);
state.prefix = prefix;
state.allowCollapse = allowCollapse;
state.allowDuplicates = allowDuplicates;
state.performBestGuess = performBestGuess;
state.tokenClickStyle = tokenClickStyle;
state.tokenDeleteStyle = deletionStyle;
state.baseObjects = baseObjects;
state.splitChar = splitChar;
//So, when the screen is locked or some other system event pauses execution,
//onSaveInstanceState gets called, but it won't restore state later because the
//activity is still in memory, so make sure we add the listeners again
//They should not be restored in onInstanceState if the app is actually killed
//as we removed them before the parent saved instance state, so our adding them in
//onRestoreInstanceState is good.
addListeners();
return state;
}
@Override
public void onRestoreInstanceState(Parcelable state) {
if (!(state instanceof SavedState)) {
super.onRestoreInstanceState(state);
return;
}
SavedState ss = (SavedState) state;
super.onRestoreInstanceState(ss.getSuperState());
setText(ss.prefix);
prefix = ss.prefix;
updateHint();
allowCollapse = ss.allowCollapse;
allowDuplicates = ss.allowDuplicates;
performBestGuess = ss.performBestGuess;
tokenClickStyle = ss.tokenClickStyle;
deletionStyle = ss.tokenDeleteStyle;
splitChar = ss.splitChar;
addListeners();
for (T obj : convertSerializableArrayToObjectArray(ss.baseObjects)) {
addObject(obj);
}
// Collapse the view if necessary
if (!isFocused() && allowCollapse) {
post(new Runnable() {
@Override
public void run() {
//Resize the view and display the +x if appropriate
performCollapse(isFocused());
}
});
}
}
/**
* Handle saving the token state
*/
private static class SavedState extends BaseSavedState {
CharSequence prefix;
boolean allowCollapse;
boolean allowDuplicates;
boolean performBestGuess;
TokenClickStyle tokenClickStyle;
TokenDeleteStyle tokenDeleteStyle;
ArrayList<Serializable> baseObjects;
char[] splitChar;
@SuppressWarnings("unchecked")
SavedState(Parcel in) {
super(in);
prefix = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in);
allowCollapse = in.readInt() != 0;
allowDuplicates = in.readInt() != 0;
performBestGuess = in.readInt() != 0;
tokenClickStyle = TokenClickStyle.values()[in.readInt()];
tokenDeleteStyle = TokenDeleteStyle.values()[in.readInt()];
baseObjects = (ArrayList<Serializable>) in.readSerializable();
splitChar = in.createCharArray();
}
SavedState(Parcelable superState) {
super(superState);
}
@Override
public void writeToParcel(@NonNull Parcel out, int flags) {
super.writeToParcel(out, flags);
TextUtils.writeToParcel(prefix, out, 0);
out.writeInt(allowCollapse ? 1 : 0);
out.writeInt(allowDuplicates ? 1 : 0);
out.writeInt(performBestGuess ? 1 : 0);
out.writeInt(tokenClickStyle.ordinal());
out.writeInt(tokenDeleteStyle.ordinal());
out.writeSerializable(baseObjects);
out.writeCharArray(splitChar);
}
@Override
public String toString() {
String str = "TokenCompleteTextView.SavedState{"
+ Integer.toHexString(System.identityHashCode(this))
+ " tokens=" + baseObjects;
return str + "}";
}
@SuppressWarnings("hiding")
public static final Parcelable.Creator<SavedState> CREATOR
= new Parcelable.Creator<SavedState>() {
public SavedState createFromParcel(Parcel in) {
return new SavedState(in);
}
public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
}
/**
* Checks if selection can be deleted. This method is called from TokenInputConnection .
* @param beforeLength the number of characters before the current selection end to check
* @return true if there are no non-deletable pieces of the section
*/
@SuppressWarnings("unused")
public boolean canDeleteSelection(int beforeLength) {
if (objects.size() < 1) return true;
// if beforeLength is 1, we either have no selection or the call is coming from OnKey Event.
// In these scenarios, getSelectionStart() will return the correct value.
int endSelection = getSelectionEnd();
int startSelection = beforeLength == 1 ? getSelectionStart() : endSelection - beforeLength;
Editable text = getText();
TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class);
// Iterate over all tokens and allow the deletion
// if there are no tokens not removable in the selection
for (TokenImageSpan span : spans) {
int startTokenSelection = text.getSpanStart(span);
int endTokenSelection = text.getSpanEnd(span);
// moving on, no need to check this token
if (isTokenRemovable(span.token)) continue;
if (startSelection == endSelection) {
// Delete single
if (endTokenSelection + 1 == endSelection) {
return false;
}
} else {
// Delete range
// Don't delete if a non removable token is in range
if (startSelection <= startTokenSelection
&& endTokenSelection + 1 <= endSelection) {
return false;
}
}
}
return true;
}
private class TokenInputConnection extends InputConnectionWrapper {
public TokenInputConnection(InputConnection target, boolean mutable) {
super(target, mutable);
}
// This will fire if the soft keyboard delete key is pressed.
// The onKeyPressed method does not always do this.
@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
// Shouldn't be able to delete any text with tokens that are not removable
if (!canDeleteSelection(beforeLength)) return false;
//Shouldn't be able to delete prefix, so don't do anything
if (getSelectionStart() <= prefix.length()) {
beforeLength = 0;
return deleteSelectedObject(false) || super.deleteSurroundingText(beforeLength, afterLength);
}
return super.deleteSurroundingText(beforeLength, afterLength);
}
}
}