/* * Odoo, Open Source Management Solution * Copyright (C) 2012-today Odoo SA (<http:www.odoo.com>) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http:www.gnu.org/licenses/> * */ package odoo.controls; import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.ColorStateList; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.Typeface; import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import android.text.Editable; import android.text.InputFilter; import android.text.InputType; import android.text.Layout; import android.text.SpanWatcher; import android.text.Spannable; 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.ReplacementSpan; import android.text.style.TextAppearanceSpan; import android.util.AttributeSet; import android.util.TypedValue; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.widget.Filter; import android.widget.ListView; import android.widget.MultiAutoCompleteTextView; import android.widget.TextView; public abstract class MultiTagsTextView extends MultiAutoCompleteTextView implements TextView.OnEditorActionListener { // When the token is deleted... public enum TokenDeleteStyle { _Parent, // ...do the parent behaviour, 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, // ...do nothing, but make sure the cursor is not in the token Delete, // ...delete the token Select// ...select the token. A second click will delete it. } private Tokenizer tokenizer; private Object selectedObject; private TokenListener listener; private TokenSpanWatcher spanWatcher; private ArrayList<Object> objects; private TokenDeleteStyle deletionStyle = TokenDeleteStyle._Parent; private TokenClickStyle tokenClickStyle = TokenClickStyle.None; private String prefix = ""; private boolean hintVisible = false; private Layout lastLayout = null; private boolean allowDuplicates = true; private boolean initialized = false; private boolean savingState = false; private boolean shouldFocusNext = false; private void resetListeners() { // reset listeners that get discarded when you set text Editable text = getText(); if (text != null) { text.setSpan(spanWatcher, 0, text.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE); // This handles some cases where older Android SDK versions don't // send onSpanRemoved // Needed in 2.2, 2.3.3, 3.0 // Not needed after 4.0 // I haven't tested on other 3.x series SDKs if (Build.VERSION.SDK_INT < 14) { addTextChangedListener(new TokenTextWatcherAPI8()); } else { addTextChangedListener(new TokenTextWatcher()); } } } private void init() { setTokenizer(new MultiAutoCompleteTextView.CommaTokenizer()); objects = new ArrayList<Object>(); Editable text = getText(); assert null != text; spanWatcher = new TokenSpanWatcher(); resetListeners(); if (Build.VERSION.SDK_INT >= 11) { setTextIsSelectable(false); } setLongClickable(false); // In theory, get the soft keyboard to not supply suggestions. very // unreliable < API 11 setInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS | InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE); setOnEditorActionListener(this); setFilters(new InputFilter[] { new InputFilter() { @Override public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) { // Detect single commas, remove them and complete the current // token instead if (source.length() == 1 && source.charAt(0) == ',') { performCompletion(); return ""; } // We need to not do anything when we would delete the prefix if (dstart < prefix.length() && dend == prefix.length()) { return prefix.substring(dstart, dend); } return null; } } }); // We had _Parent style during initialisation to handle an edge case in // the parent // now we can switch to Clear, usually the best choice setDeletionStyle(TokenDeleteStyle.Clear); initialized = true; } public MultiTagsTextView(Context context) { super(context); init(); } public MultiTagsTextView(Context context, AttributeSet attrs) { super(context, attrs); init(); } public MultiTagsTextView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(); } @Override protected void performFiltering(CharSequence text, int start, int end, int keyCode) { if (start < prefix.length()) { start = prefix.length(); } Filter filter = getFilter(); if (filter != null) { filter.filter(text.subSequence(start, end), this); } } @Override public void setTokenizer(Tokenizer t) { super.setTokenizer(t); tokenizer = t; } public void setDeletionStyle(TokenDeleteStyle dStyle) { deletionStyle = dStyle; } public void setTokenClickStyle(TokenClickStyle cStyle) { tokenClickStyle = cStyle; } public void setTokenListener(TokenListener l) { listener = l; } public void setPrefix(String 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(); } public List<Object> getObjects() { return objects; } /** * 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. * * Defaults to true. Requires that the objects implement equals() correctly. */ public void allowDuplicates(boolean allow) { allowDuplicates = allow; } /** * 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(Object 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 Object defaultObject(String completionText); protected String currentCompletionText() { if (hintVisible) return ""; // Can't have any text if the hint is visible Editable editable = getText(); int end = getSelectionEnd(); int start = tokenizer.findTokenStart(editable, end); if (start < prefix.length()) { start = prefix.length(); } return TextUtils.substring(editable, start, end); } private float maxTextWidth() { return getWidth() - getPaddingLeft() - getPaddingRight(); } boolean inInvalidate = false; @SuppressLint("NewApi") @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 >= 16 && initialized && !inInvalidate) { inInvalidate = true; setShadowLayer(getShadowRadius(), getShadowDx(), getShadowDy(), getShadowColor()); inInvalidate = false; } super.invalidate(); } @Override public boolean enoughToFilter() { Editable text = getText(); int end = getSelectionEnd(); if (end < 0 || tokenizer == null) { return false; } int start = tokenizer.findTokenStart(text, end); if (start < prefix.length()) { start = prefix.length(); } return end - start >= getThreshold(); } @Override public void performCompletion() { try { if (getListSelection() == ListView.INVALID_POSITION) { Object bestGuess; if (getAdapter().getCount() > 0) { bestGuess = getAdapter().getItem(0); } else { bestGuess = defaultObject(currentCompletionText()); } if (bestGuess != null) replaceText(convertSelectionToString(bestGuess)); } else { super.performCompletion(); } } catch (NullPointerException e) { } } @Override public InputConnection onCreateInputConnection(EditorInfo outAttrs) { // Override normal multiline text handling of enter/done and force a // done button InputConnection connection = super.onCreateInputConnection(outAttrs); int imeActions = outAttrs.imeOptions & EditorInfo.IME_MASK_ACTION; if ((imeActions & EditorInfo.IME_ACTION_DONE) != 0) { // clear the existing action outAttrs.imeOptions ^= imeActions; // set the DONE action outAttrs.imeOptions |= EditorInfo.IME_ACTION_DONE; } if ((outAttrs.imeOptions & EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) { outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; } return connection; } private void handleDone() { // If there is enough text to filter, attempt to complete the token if (enoughToFilter()) { performCompletion(); } else { // ...otherwise look for the next field and focus it // TODO: should clear existing text as well View next = focusSearch(View.FOCUS_DOWN); if (next != null) { next.requestFocus(); } } } @Override public boolean onKeyUp(int keyCode, KeyEvent event) { boolean handled = super.onKeyUp(keyCode, event); if (shouldFocusNext) { shouldFocusNext = false; handleDone(); } return handled; } @Override public boolean onKeyDown(int keyCode, KeyEvent event) { boolean handled = false; switch (keyCode) { case KeyEvent.KEYCODE_TAB: case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_DPAD_CENTER: if (Build.VERSION.SDK_INT >= 11) { if (event.hasNoModifiers()) { shouldFocusNext = true; handled = true; } } else { shouldFocusNext = true; handled = true; } break; case KeyEvent.KEYCODE_DEL: if (tokenClickStyle == TokenClickStyle.Select) { Editable text = getText(); if (text == null) break; TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class); for (TokenImageSpan span : spans) { if (span.view.isSelected()) { removeSpan(span); handled = true; break; } } } } return handled || super.onKeyDown(keyCode, event); } @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(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; if (Build.VERSION.SDK_INT < 14) { offset = TextPositionCompatibilityAPI8.getOffsetForPosition( event.getX(), event.getY(), this, lastLayout); } else { 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; } } } 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 == TokenClickStyle.Select) { 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) { 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 } protected void handleFocus(boolean hasFocus) { if (!hasFocus) { setSingleLine(true); 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; if (count > 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); } } } else { setSingleLine(false); 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); } if (hintVisible) { setSelection(prefix.length()); } else { setSelection(text.length()); } 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); } } } } @Override public void onFocusChanged(boolean hasFocus, int direction, Rect previous) { super.onFocusChanged(hasFocus, direction, previous); handleFocus(hasFocus); } @Override protected CharSequence convertSelectionToString(Object object) { selectedObject = 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.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 return new SpannableStringBuilder("," + tokenizer.terminateToken(text)); } private TokenImageSpan buildSpanForObject(Object obj) { View tokenView = getViewForObject(obj); return new TokenImageSpan(tokenView, obj); } @Override protected void replaceText(CharSequence text) { clearComposingText(); SpannableStringBuilder ssb = buildSpannableForText(text); TokenImageSpan tokenSpan = buildSpanForObject(selectedObject); Editable editable = getText(); int end = getSelectionEnd(); int start = tokenizer.findTokenStart(editable, end); if (start < prefix.length()) { start = prefix.length(); } String original = TextUtils.substring(editable, start, end); if (editable != null) { if (!allowDuplicates && objects.contains(selectedObject)) { 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); } } } /** * 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 Object object, final CharSequence sourceText) { post(new Runnable() { @Override public void run() { if (!allowDuplicates && objects.contains(object)) { return; } SpannableStringBuilder ssb = buildSpannableForText(sourceText); TokenImageSpan tokenSpan = buildSpanForObject(object); Editable editable = getText(); if (editable != null) { 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 { editable.append(ssb); } editable.setSpan(tokenSpan, offset, offset + ssb.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); // 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, offset, offset + ssb.length() - 1); } setSelection(editable.length()); } } }); } /** * Shorthand for addObject(object, "") * * @param object * the object to add to the displayed token */ public void addObject(Object 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 Object 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; TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class); for (TokenImageSpan span : spans) { if (span.getToken().equals(object)) { removeSpan(span); } } } }); } private void removeSpan(TokenImageSpan span) { Editable text = getText(); if (text == null) return; if (Build.VERSION.SDK_INT < 14) { // HACK: Need to manually trigger on Span removed if there is only 1 // object // not sure if there's a cleaner way if (objects.size() == 1) { 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); } 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 != TokenClickStyle.Select) 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(); } public static class HintSpan extends TextAppearanceSpan { public HintSpan(String family, int style, int size, ColorStateList color, ColorStateList linkColor) { super(family, style, size, color, linkColor); } } private class ViewSpan extends ReplacementSpan { protected View view; public ViewSpan(View v) { view = v; } private void prepView() { int widthSpec = MeasureSpec.makeMeasureSpec((int) maxTextWidth(), MeasureSpec.AT_MOST); int heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); view.measure(widthSpec, heightSpec); view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); } public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) { prepView(); canvas.save(); // Centering the token looks like a better strategy that aligning // the bottom int padding = (bottom - top - view.getBottom()) / 2; canvas.translate(x, bottom - view.getBottom() - padding); view.draw(canvas); canvas.restore(); } public int getSize(Paint paint, CharSequence charSequence, int i, int i2, Paint.FontMetricsInt fm) { prepView(); if (fm != null) { // We need to make sure the layout allots enough space for the // view int height = view.getMeasuredHeight(); int need = height - (fm.descent - fm.ascent); if (need > 0) { int ascent = need / 2; // This makes sure the text drawing area will be tall enough // for the view fm.descent += need - ascent; fm.ascent -= ascent; fm.bottom += need - ascent; fm.top -= need / 2; } } return view.getRight(); } } private class CountSpan extends ViewSpan { public String text = ""; private int count; public CountSpan(int count, Context ctx, int textColor, int textSize, int maxWidth) { super(new TextView(ctx)); TextView v = (TextView) view; v.setTextColor(textColor); v.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); // Make the view as wide as the parent to push the tokens off screen v.setMinimumWidth(maxWidth); setCount(count); } public void setCount(int c) { count = c; text = "+" + count; ((TextView) view).setText(text); } } private class TokenImageSpan extends ViewSpan { private Object token; public TokenImageSpan(View d, Object token) { super(d); this.token = token; } public Object getToken() { return this.token; } public void onClick() { Editable text = getText(); if (text == null) return; switch (tokenClickStyle) { case Select: if (!view.isSelected()) { clearSelections(); view.setSelected(true); break; } // If the view is already selected, we want to delete it case Delete: removeSpan(this); break; case None: listener.onTokenSelected(getToken(), view); break; 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 static interface TokenListener { public void onTokenAdded(Object token, View view); public void onTokenSelected(Object token, View view); public void onTokenRemoved(Object token); } private class TokenSpanWatcher implements SpanWatcher { @Override public void onSpanAdded(Spannable text, Object what, int start, int end) { if (what instanceof TokenImageSpan && !savingState) { TokenImageSpan token = (TokenImageSpan) what; objects.add(token.getToken()); if (listener != null) listener.onTokenAdded(token.getToken(), token.view); } } @Override public void onSpanRemoved(Spannable text, Object what, int start, int end) { if (what instanceof TokenImageSpan && !savingState) { TokenImageSpan token = (TokenImageSpan) what; 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 { protected void removeToken(TokenImageSpan token, Editable text) { text.removeSpan(token); } @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void afterTextChanged(Editable s) { } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { Editable text = getText(); if (text == null) return; clearSelections(); updateHint(); TokenImageSpan[] spans = text.getSpans(start - before, start - before + count, TokenImageSpan.class); for (TokenImageSpan token : spans) { int position = start + count; if (text.getSpanStart(token) < position && position <= text.getSpanEnd(token)) { // We may have to manually reverse the auto-complete and // remove the extra ,'s 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--; if (spanEnd >= 0 && text.charAt(spanEnd) == ',') { text.delete(spanEnd, spanEnd + 1); } if (spanStart >= 0 && text.charAt(spanStart) == ',') { text.delete(spanStart, spanStart + 1); } } } } } /** * On some older versions of android sdk, the onSpanRemoved and * onSpanChanged are not reliable this class supplements the * TokenSpanWatcher to manually trigger span updates */ private class TokenTextWatcherAPI8 extends TokenTextWatcher { private ArrayList<TokenImageSpan> currentTokens = new ArrayList<TokenImageSpan>(); @Override protected void removeToken(TokenImageSpan token, Editable text) { currentTokens.remove(token); super.removeToken(token, text); } @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { currentTokens.clear(); Editable text = getText(); if (text == null) return; TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class); currentTokens.addAll(Arrays.asList(spans)); } @Override public void afterTextChanged(Editable s) { TokenImageSpan[] spans = s.getSpans(0, s.length(), TokenImageSpan.class); for (TokenImageSpan token : currentTokens) { if (!Arrays.asList(spans).contains(token)) { spanWatcher.onSpanRemoved(s, token, s.getSpanStart(token), s.getSpanEnd(token)); } } } } private static class TextPositionCompatibilityAPI8 { // Borrowing some code from API 14 static public int getOffsetForPosition(float x, float y, TextView tv, Layout layout) { if (layout == null) return -1; final int line = getLineAtCoordinate(y, tv, layout); return getOffsetAtCoordinate(line, x, tv, layout); } static private float convertToLocalHorizontalCoordinate(float x, TextView tv) { if (tv.getLayout() == null) { x -= tv.getCompoundPaddingLeft(); } else { x -= tv.getTotalPaddingLeft(); } // Clamp the position to inside of the view. x = Math.max(0.0f, x); float rightSide = tv.getWidth() - 1; if (tv.getLayout() == null) { rightSide -= tv.getCompoundPaddingRight(); } else { rightSide -= tv.getTotalPaddingRight(); } x = Math.min(rightSide, x); x += tv.getScrollX(); return x; } static private int getLineAtCoordinate(float y, TextView tv, Layout layout) { if (tv.getLayout() == null) { y -= tv.getCompoundPaddingTop(); } else { y -= tv.getTotalPaddingTop(); } // Clamp the position to inside of the view. y = Math.max(0.0f, y); float bottom = tv.getHeight() - 1; if (tv.getLayout() == null) { bottom -= tv.getCompoundPaddingBottom(); } else { bottom -= tv.getTotalPaddingBottom(); } y = Math.min(bottom, y); y += tv.getScrollY(); return layout.getLineForVertical((int) y); } static private int getOffsetAtCoordinate(int line, float x, TextView tv, Layout layout) { x = convertToLocalHorizontalCoordinate(x, tv); return layout.getOffsetForHorizontal(line, x); } } protected ArrayList<Serializable> getSerializableObjects() { ArrayList<Serializable> serializables = new ArrayList<Serializable>(); for (Object obj : getObjects()) { if (obj instanceof Serializable) { serializables.add((Serializable) obj); } else { System.out.println("Unable to save '" + obj.toString() + "'"); } } if (serializables.size() != objects.size()) { System.out .println("You should make your objects Serializable or override"); System.out .println("getSerializableObjects and convertSerializableArrayToObjectArray"); } return serializables; } @SuppressWarnings({ "unchecked", "rawtypes" }) protected ArrayList<Object> convertSerializableArrayToObjectArray( ArrayList<Serializable> s) { return (ArrayList<Object>) (ArrayList) s; } @Override public Parcelable onSaveInstanceState() { ArrayList<Serializable> baseObjects = getSerializableObjects(); // 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.allowDuplicates = allowDuplicates; state.tokenClickStyle = tokenClickStyle; state.tokenDeleteStyle = deletionStyle; state.baseObjects = baseObjects; 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(); allowDuplicates = ss.allowDuplicates; tokenClickStyle = ss.tokenClickStyle; deletionStyle = ss.tokenDeleteStyle; resetListeners(); ss.baseObjects.clear(); /* * Creates duplicate tags when restoring from cache. EC01 */ // for (Object obj : // convertSerializableArrayToObjectArray(ss.baseObjects)) { // addObject(obj); // } // This needs to happen after all the objects get added (which also get // posted) // or the view truncates really oddly if (!isFocused()) { post(new Runnable() { @Override public void run() { // Resize the view nad display the +x if appropriate handleFocus(isFocused()); } }); } } /** * Handle saving the token state */ private static class SavedState extends BaseSavedState { String prefix; boolean allowDuplicates; TokenClickStyle tokenClickStyle; TokenDeleteStyle tokenDeleteStyle; ArrayList<Serializable> baseObjects; @SuppressWarnings("unchecked") SavedState(Parcel in) { super(in); prefix = in.readString(); allowDuplicates = in.readInt() != 0; tokenClickStyle = TokenClickStyle.values()[in.readInt()]; tokenDeleteStyle = TokenDeleteStyle.values()[in.readInt()]; baseObjects = (ArrayList<Serializable>) in.readSerializable(); } SavedState(Parcelable superState) { super(superState); } @Override public void writeToParcel(Parcel out, int flags) { super.writeToParcel(out, flags); out.writeString(prefix); out.writeInt(allowDuplicates ? 1 : 0); out.writeInt(tokenClickStyle.ordinal()); out.writeInt(tokenDeleteStyle.ordinal()); out.writeSerializable(baseObjects); } @Override public String toString() { String str = "TokenCompleteTextView.SavedState{" + Integer.toHexString(System.identityHashCode(this)) + " tokens=" + baseObjects; return str + "}"; } @SuppressWarnings("unused") 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]; } }; } }