RecipientEditTextView.java revision e98f079e157d2872100174ba67beef8ebbc0cec0
1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.ex.chips;
18
19import android.content.Context;
20import android.graphics.Bitmap;
21import android.graphics.BitmapFactory;
22import android.graphics.Canvas;
23import android.graphics.Matrix;
24import android.graphics.Paint;
25import android.graphics.Rect;
26import android.graphics.RectF;
27import android.graphics.drawable.BitmapDrawable;
28import android.graphics.drawable.Drawable;
29import android.os.Handler;
30import android.os.Message;
31import android.text.Editable;
32import android.text.Layout;
33import android.text.Spannable;
34import android.text.SpannableString;
35import android.text.Spanned;
36import android.text.TextPaint;
37import android.text.TextUtils;
38import android.text.TextWatcher;
39import android.text.method.QwertyKeyListener;
40import android.text.style.ImageSpan;
41import android.util.AttributeSet;
42import android.util.Log;
43import android.view.ActionMode;
44import android.view.KeyEvent;
45import android.view.Menu;
46import android.view.MenuItem;
47import android.view.MotionEvent;
48import android.view.View;
49import android.view.ActionMode.Callback;
50import android.widget.AdapterView;
51import android.widget.AdapterView.OnItemClickListener;
52import android.widget.ListPopupWindow;
53import android.widget.ListView;
54import android.widget.MultiAutoCompleteTextView;
55
56import java.util.Collection;
57import java.util.HashSet;
58import java.util.Set;
59
60import java.util.ArrayList;
61
62/**
63 * RecipientEditTextView is an auto complete text view for use with applications
64 * that use the new Chips UI for addressing a message to recipients.
65 */
66public class RecipientEditTextView extends MultiAutoCompleteTextView
67    implements OnItemClickListener, Callback {
68
69    private static final String TAG = "RecipientEditTextView";
70
71    // TODO: get correct number/ algorithm from with UX.
72    private static final int CHIP_LIMIT = 2;
73
74    private static final int INVALID_CONTACT = -1;
75
76    // TODO: get correct size from UX.
77    private static final float MORE_WIDTH_FACTOR = 0.25f;
78
79    private Drawable mChipBackground = null;
80
81    private Drawable mChipDelete = null;
82
83    private int mChipPadding;
84
85    private Tokenizer mTokenizer;
86
87    private Drawable mChipBackgroundPressed;
88
89    private RecipientChip mSelectedChip;
90
91    private int mAlternatesLayout;
92
93    private Bitmap mDefaultContactPhoto;
94
95    private ImageSpan mMoreChip;
96
97    private int mMoreString;
98
99    private ArrayList<RecipientChip> mRemovedSpans;
100
101    private float mChipHeight;
102
103    private float mChipFontSize;
104
105    private Validator mValidator;
106
107    private Drawable mInvalidChipBackground;
108
109    private Handler mHandler;
110
111    private static int DISMISS = "dismiss".hashCode();
112
113    private static final long DISMISS_DELAY = 300;
114
115    private int mPendingChipsCount = 0;
116
117    private static int sSelectedTextColor = -1;
118
119    private static final char COMMIT_CHAR_COMMA = ',';
120
121    private static final char COMMIT_CHAR_SEMICOLON = ';';
122
123    public RecipientEditTextView(Context context, AttributeSet attrs) {
124        super(context, attrs);
125        if (sSelectedTextColor == -1) {
126            sSelectedTextColor = context.getResources().getColor(android.R.color.white);
127        }
128        setSuggestionsEnabled(false);
129        setOnItemClickListener(this);
130        setCustomSelectionActionModeCallback(this);
131        // When the user starts typing, make sure we unselect any selected
132        // chips.
133        addTextChangedListener(new TextWatcher() {
134            @Override
135            public void afterTextChanged(Editable s) {
136                if (mSelectedChip != null) {
137                    setCursorVisible(true);
138                    setSelection(getText().length());
139                    clearSelectedChip();
140                }
141            }
142
143            @Override
144            public void onTextChanged(CharSequence s, int start, int before, int count) {
145                int length = s.length();
146                // Make sure there is content there to parse and that it is not just
147                // the commit character.
148                if (length > 1) {
149                    char last = s.charAt(length() - 1);
150                    if (last == COMMIT_CHAR_SEMICOLON || last == COMMIT_CHAR_COMMA) {
151                        commitDefault();
152                    }
153                }
154            }
155
156            @Override
157            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
158                // Do nothing.
159            }
160        });
161        mHandler = new Handler() {
162            @Override
163            public void handleMessage(Message msg) {
164                if (msg.what == DISMISS) {
165                    ((ListPopupWindow)msg.obj).dismiss();
166                    return;
167                }
168                super.handleMessage(msg);
169            }
170        };
171    }
172
173    @Override
174    public void onSelectionChanged(int start, int end) {
175        // When selection changes, see if it is inside the chips area.
176        // If so, move the cursor back after the chips again.
177        Spannable span = getSpannable();
178        int textLength = getText().length();
179        RecipientChip[] chips = span.getSpans(start, textLength, RecipientChip.class);
180        if (chips != null && chips.length > 0) {
181            if (chips != null && chips.length > 0) {
182                // Grab the last chip and set the cursor to after it.
183                setSelection(Math.min(chips[chips.length - 1].getChipEnd() + 1, textLength));
184            }
185        }
186        super.onSelectionChanged(start, end);
187    }
188
189    /**
190     * Convenience method: Append the specified text slice to the TextView's
191     * display buffer, upgrading it to BufferType.EDITABLE if it was
192     * not already editable. Commas are excluded as they are added automatically
193     * by the view.
194     */
195    @Override
196    public void append(CharSequence text, int start, int end) {
197        super.append(text, start, end);
198        if (!TextUtils.isEmpty(text) && TextUtils.getTrimmedLength(text) > 0) {
199            final String displayString = (String) text;
200            int seperatorPos = displayString.indexOf(COMMIT_CHAR_COMMA);
201            if (seperatorPos != 0 && !TextUtils.isEmpty(displayString)
202                    && TextUtils.getTrimmedLength(displayString) > 0) {
203                mPendingChipsCount++;
204            }
205        }
206    }
207
208    @Override
209    public void onFocusChanged(boolean hasFocus, int direction, Rect previous) {
210        if (!hasFocus) {
211            shrink();
212            // Reset any pending chips as they would have been handled
213            // when the field lost focus.
214            mPendingChipsCount = 0;
215        } else {
216            expand();
217        }
218        super.onFocusChanged(hasFocus, direction, previous);
219    }
220
221    private void shrink() {
222        if (mSelectedChip != null) {
223            clearSelectedChip();
224        } else {
225            commitDefault();
226        }
227        mMoreChip = createMoreChip();
228    }
229
230    private void expand() {
231        removeMoreChip();
232        setCursorVisible(true);
233        Editable text = getText();
234        setSelection(text != null && text.length() > 0 ? text.length() : 0);
235    }
236
237    private CharSequence ellipsizeText(CharSequence text, TextPaint paint, float maxWidth) {
238        paint.setTextSize(mChipFontSize);
239        if (maxWidth <= 0 && Log.isLoggable(TAG, Log.DEBUG)) {
240            Log.d(TAG, "Max width is negative: " + maxWidth);
241        }
242        return TextUtils.ellipsize(text, paint, maxWidth,
243                TextUtils.TruncateAt.END);
244    }
245
246    private Bitmap createSelectedChip(RecipientEntry contact, TextPaint paint, Layout layout) {
247        // Ellipsize the text so that it takes AT MOST the entire width of the
248        // autocomplete text entry area. Make sure to leave space for padding
249        // on the sides.
250        int height = (int) mChipHeight;
251        int deleteWidth = height;
252        CharSequence ellipsizedText = ellipsizeText(contact.getDisplayName(), paint,
253                calculateAvailableWidth(true) - deleteWidth);
254
255        // Make sure there is a minimum chip width so the user can ALWAYS
256        // tap a chip without difficulty.
257        int width = Math.max(deleteWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0,
258                ellipsizedText.length()))
259                + (mChipPadding * 2) + deleteWidth);
260
261        // Create the background of the chip.
262        Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
263        Canvas canvas = new Canvas(tmpBitmap);
264        if (mChipBackgroundPressed != null) {
265            mChipBackgroundPressed.setBounds(0, 0, width, height);
266            mChipBackgroundPressed.draw(canvas);
267            paint.setColor(sSelectedTextColor);
268            // Align the display text with where the user enters text.
269            canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height
270                    - Math.abs(height - mChipFontSize)/2, paint);
271            // Make the delete a square.
272            mChipDelete.setBounds(width - deleteWidth, 0, width, height);
273            mChipDelete.draw(canvas);
274        } else {
275            Log.w(TAG, "Unable to draw a background for the chips as it was never set");
276        }
277        return tmpBitmap;
278    }
279
280
281    /**
282     * Get the background drawable for a RecipientChip.
283     */
284    public Drawable getChipBackground(RecipientEntry contact) {
285        return mValidator != null && mValidator.isValid(contact.getDestination()) ?
286                mChipBackground : mInvalidChipBackground;
287    }
288
289    private Bitmap createUnselectedChip(RecipientEntry contact, TextPaint paint, Layout layout) {
290        // Ellipsize the text so that it takes AT MOST the entire width of the
291        // autocomplete text entry area. Make sure to leave space for padding
292        // on the sides.
293        int height = (int) mChipHeight;
294        int iconWidth = height;
295        CharSequence ellipsizedText = ellipsizeText(contact.getDisplayName(), paint,
296                calculateAvailableWidth(false) - iconWidth);
297        // Make sure there is a minimum chip width so the user can ALWAYS
298        // tap a chip without difficulty.
299        int width = Math.max(iconWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0,
300                ellipsizedText.length()))
301                + (mChipPadding * 2) + iconWidth);
302
303        // Create the background of the chip.
304        Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
305        Canvas canvas = new Canvas(tmpBitmap);
306        Drawable background = getChipBackground(contact);
307        if (background != null) {
308            background.setBounds(0, 0, width, height);
309            background.draw(canvas);
310
311            // Don't draw photos for recipients that have been typed in.
312            if (contact.getContactId() != INVALID_CONTACT) {
313                byte[] photoBytes = contact.getPhotoBytes();
314                // There may not be a photo yet if anything but the first contact address
315                // was selected.
316                if (photoBytes == null && contact.getPhotoThumbnailUri() != null) {
317                    // TODO: cache this in the recipient entry?
318                    ((BaseRecipientAdapter) getAdapter()).fetchPhoto(contact, contact
319                            .getPhotoThumbnailUri());
320                    photoBytes = contact.getPhotoBytes();
321                }
322
323                Bitmap photo;
324                if (photoBytes != null) {
325                    photo = BitmapFactory.decodeByteArray(photoBytes, 0, photoBytes.length);
326                } else {
327                    // TODO: can the scaled down default photo be cached?
328                    photo = mDefaultContactPhoto;
329                }
330                // Draw the photo on the left side.
331                Matrix matrix = new Matrix();
332                RectF src = new RectF(0, 0, photo.getWidth(), photo.getHeight());
333                RectF dst = new RectF(0, 0, iconWidth, height);
334                matrix.setRectToRect(src, dst, Matrix.ScaleToFit.CENTER);
335                canvas.drawBitmap(photo, matrix, paint);
336            } else {
337                // Don't leave any space for the icon. It isn't being drawn.
338                iconWidth = 0;
339            }
340
341            // Align the display text with where the user enters text.
342            canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding + iconWidth,
343                    height - Math.abs(height - mChipFontSize) / 2, paint);
344        } else {
345            Log.w(TAG, "Unable to draw a background for the chips as it was never set");
346        }
347        return tmpBitmap;
348    }
349
350    public RecipientChip constructChipSpan(RecipientEntry contact, int offset, boolean pressed)
351            throws NullPointerException {
352        if (mChipBackground == null) {
353            throw new NullPointerException(
354                    "Unable to render any chips as setChipDimensions was not called.");
355        }
356        Layout layout = getLayout();
357
358        TextPaint paint = getPaint();
359        float defaultSize = paint.getTextSize();
360        int defaultColor = paint.getColor();
361
362        Bitmap tmpBitmap;
363        if (pressed) {
364            tmpBitmap = createSelectedChip(contact, paint, layout);
365
366        } else {
367            tmpBitmap = createUnselectedChip(contact, paint, layout);
368        }
369
370        // Get the location of the widget so we can properly offset
371        // the anchor for each chip.
372        int[] xy = new int[2];
373        getLocationOnScreen(xy);
374        // Pass the full text, un-ellipsized, to the chip.
375        Drawable result = new BitmapDrawable(getResources(), tmpBitmap);
376        result.setBounds(0, 0, tmpBitmap.getWidth(), tmpBitmap.getHeight());
377        RecipientChip recipientChip = new RecipientChip(result, contact, offset);
378        // Return text to the original size.
379        paint.setTextSize(defaultSize);
380        paint.setColor(defaultColor);
381        return recipientChip;
382    }
383
384    /**
385     * Calculate the bottom of the line the chip will be located on using:
386     * 1) which line the chip appears on
387     * 2) the height of a chip
388     * 3) padding built into the edit text view
389     * 4) the position of the autocomplete view on the screen, taking into account
390     * that any top padding will move this down visually
391     */
392    private int calculateLineBottom(int yOffset, int line, int chipHeight) {
393        // Line offsets start at zero.
394        int actualLine = line + 1;
395        return yOffset + (actualLine * (chipHeight + getPaddingBottom())) + getPaddingTop();
396    }
397
398    /**
399     * Get the max amount of space a chip can take up. The formula takes into
400     * account the width of the EditTextView, any view padding, and padding
401     * that will be added to the chip.
402     */
403    private float calculateAvailableWidth(boolean pressed) {
404        return getWidth() - getPaddingLeft() - getPaddingRight() - (mChipPadding * 2);
405    }
406
407    /**
408     * Set all chip dimensions and resources. This has to be done from the
409     * application as this is a static library.
410     * @param chipBackground
411     * @param chipBackgroundPressed
412     * @param invalidChip
413     * @param chipDelete
414     * @param defaultContact
415     * @param moreResource
416     * @param alternatesLayout
417     * @param chipHeight
418     * @param padding Padding around the text in a chip
419     */
420    public void setChipDimensions(Drawable chipBackground, Drawable chipBackgroundPressed,
421            Drawable invalidChip, Drawable chipDelete, Bitmap defaultContact, int moreResource,
422            int alternatesLayout, float chipHeight, float padding,
423            float chipFontSize) {
424        mChipBackground = chipBackground;
425        mChipBackgroundPressed = chipBackgroundPressed;
426        mChipDelete = chipDelete;
427        mChipPadding = (int) padding;
428        mAlternatesLayout = alternatesLayout;
429        mDefaultContactPhoto = defaultContact;
430        mMoreString = moreResource;
431        mChipHeight = chipHeight;
432        mChipFontSize = chipFontSize;
433        mInvalidChipBackground = invalidChip;
434    }
435
436    @Override
437    public void onSizeChanged(int width, int height, int oldw, int oldh) {
438        super.onSizeChanged(width, height, oldw, oldh);
439        // Check for any pending tokens created before layout had been completed
440        // on the view.
441        if (width != 0 && height != 0 && mPendingChipsCount > 0) {
442            Editable editable = getText();
443            // Tokenize!
444            int startingPos = 0;
445            while (startingPos < editable.length() && mPendingChipsCount > 0) {
446                int tokenEnd = mTokenizer.findTokenEnd(editable, startingPos);
447                int tokenStart = mTokenizer.findTokenStart(editable, tokenEnd);
448                if (findChip(tokenStart) == null) {
449                    // Always include seperators with the token to the left.
450                    if (tokenEnd < editable.length() - 1
451                            && editable.charAt(tokenEnd) == COMMIT_CHAR_COMMA) {
452                        tokenEnd++;
453                    }
454                    startingPos = tokenEnd;
455                    String token = (String) editable.toString().substring(tokenStart, tokenEnd);
456                    int seperatorPos = token.indexOf(COMMIT_CHAR_COMMA);
457                    if (seperatorPos != -1) {
458                        token = token.substring(0, seperatorPos);
459                    }
460                    editable.replace(tokenStart, tokenEnd, createChip(RecipientEntry
461                            .constructFakeEntry(token), false));
462                }
463                mPendingChipsCount--;
464            }
465            mPendingChipsCount = 0;
466        }
467    }
468
469    @Override
470    public void setTokenizer(Tokenizer tokenizer) {
471        mTokenizer = tokenizer;
472        super.setTokenizer(mTokenizer);
473    }
474
475    @Override
476    public void setValidator(Validator validator) {
477        mValidator = validator;
478        super.setValidator(validator);
479    }
480
481    /**
482     * We cannot use the default mechanism for replaceText. Instead,
483     * we override onItemClickListener so we can get all the associated
484     * contact information including display text, address, and id.
485     */
486    @Override
487    protected void replaceText(CharSequence text) {
488        return;
489    }
490
491    /**
492     * Dismiss any selected chips when the back key is pressed.
493     */
494    @Override
495    public boolean onKeyPreIme(int keyCode, KeyEvent event) {
496        if (keyCode == KeyEvent.KEYCODE_BACK) {
497            clearSelectedChip();
498        }
499        return super.onKeyPreIme(keyCode, event);
500    }
501
502    /**
503     * Monitor key presses in this view to see if the user types
504     * any commit keys, which consist of ENTER, TAB, or DPAD_CENTER.
505     * If the user has entered text that has contact matches and types
506     * a commit key, create a chip from the topmost matching contact.
507     * If the user has entered text that has no contact matches and types
508     * a commit key, then create a chip from the text they have entered.
509     */
510    @Override
511    public boolean onKeyUp(int keyCode, KeyEvent event) {
512        switch (keyCode) {
513            case KeyEvent.KEYCODE_ENTER:
514            case KeyEvent.KEYCODE_DPAD_CENTER:
515                if (event.hasNoModifiers()) {
516                    if (commitDefault()) {
517                        return true;
518                    }
519                    if (mSelectedChip != null) {
520                        clearSelectedChip();
521                        return true;
522                    } else if (focusNext()) {
523                        return true;
524                    }
525                }
526                break;
527            case KeyEvent.KEYCODE_TAB:
528                if (event.hasNoModifiers()) {
529                    if (mSelectedChip != null) {
530                        clearSelectedChip();
531                    } else {
532                        commitDefault();
533                    }
534                    if (focusNext()) {
535                        return true;
536                    }
537                }
538        }
539        return super.onKeyUp(keyCode, event);
540    }
541
542    private boolean focusNext() {
543        View next = focusSearch(View.FOCUS_DOWN);
544        if (next != null) {
545            next.requestFocus();
546            return true;
547        }
548        return false;
549    }
550
551    /**
552     * Create a chip from the default selection. If the popup is showing, the
553     * default is the first item in the popup suggestions list. Otherwise, it is
554     * whatever the user had typed in. End represents where the the tokenizer
555     * should search for a token to turn into a chip.
556     * @return If a chip was created from a real contact.
557     */
558    private boolean commitDefault() {
559        Editable editable = getText();
560        boolean enough = enoughToFilter();
561        boolean shouldSubmitAtPosition = false;
562        int end = getSelectionEnd();
563        int start = mTokenizer.findTokenStart(editable, end);
564        boolean submitText = false;
565        if (enough) {
566            RecipientChip[] chips = getSpannable().getSpans(start, end, RecipientChip.class);
567            if ((chips == null || chips.length == 0)) {
568                // There's something being filtered or typed that has not been
569                // completed yet.
570                // Check for the end of the token.
571                end = mTokenizer.findTokenEnd(editable, start);
572                // The user has tapped somewhere in the middle of the text
573                // and started editing. In this case, we always want to
574                // submit the full text token and not what may be in the
575                // suggestions popup.
576                if (end != getSelectionEnd()) {
577                    submitText = true;
578                    setSelection(end);
579                }
580                shouldSubmitAtPosition = true;
581            }
582        }
583
584        if (shouldSubmitAtPosition) {
585            if (!submitText && getAdapter().getCount() > 0) {
586                // choose the first entry.
587                submitItemAtPosition(0);
588                dismissDropDown();
589                return true;
590            } else {
591                String text = editable.toString().substring(start, end);
592                clearComposingText();
593                if (text != null && text.length() > 0 && !text.equals(" ")) {
594                    text = removeCommitChars(text);
595                    RecipientEntry entry = RecipientEntry.constructFakeEntry(text);
596                    QwertyKeyListener.markAsReplaced(editable, start, end, "");
597                    editable.replace(start, end, createChip(entry, false));
598                    dismissDropDown();
599                }
600                return true;
601            }
602        }
603        return false;
604    }
605
606    private String removeCommitChars(String text) {
607        int commitCharPosition = text.indexOf(COMMIT_CHAR_COMMA);
608        if (commitCharPosition != -1) {
609            text = text.substring(0, commitCharPosition);
610        }
611        commitCharPosition = text.indexOf(COMMIT_CHAR_SEMICOLON);
612        if (commitCharPosition != -1) {
613            text = text.substring(0, commitCharPosition);
614        }
615        return text;
616    }
617
618    /**
619     * If there is a selected chip, delegate the key events
620     * to the selected chip.
621     */
622    @Override
623    public boolean onKeyDown(int keyCode, KeyEvent event) {
624        if (mSelectedChip != null) {
625            mSelectedChip.onKeyDown(keyCode, event);
626        }
627
628        if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) {
629            return true;
630        }
631
632        return super.onKeyDown(keyCode, event);
633    }
634
635    private Spannable getSpannable() {
636        return (Spannable) getText();
637    }
638
639    /**
640     * Instead of filtering on the entire contents of the edit box,
641     * this subclass method filters on the range from
642     * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd}
643     * if the length of that range meets or exceeds {@link #getThreshold}
644     * and makes sure that the range is not already a Chip.
645     */
646    @Override
647    protected void performFiltering(CharSequence text, int keyCode) {
648        if (enoughToFilter()) {
649            int end = getSelectionEnd();
650            int start = mTokenizer.findTokenStart(text, end);
651            // If this is a RecipientChip, don't filter
652            // on its contents.
653            Spannable span = getSpannable();
654            RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
655            if (chips != null && chips.length > 0) {
656                return;
657            }
658        }
659        super.performFiltering(text, keyCode);
660    }
661
662    private void clearSelectedChip() {
663        if (mSelectedChip != null) {
664            mSelectedChip.unselectChip();
665            mSelectedChip = null;
666        }
667        setCursorVisible(true);
668    }
669
670    /**
671     * Monitor touch events in the RecipientEditTextView.
672     * If the view does not have focus, any tap on the view
673     * will just focus the view. If the view has focus, determine
674     * if the touch target is a recipient chip. If it is and the chip
675     * is not selected, select it and clear any other selected chips.
676     * If it isn't, then select that chip.
677     */
678    @Override
679    public boolean onTouchEvent(MotionEvent event) {
680        if (!isFocused()) {
681            // Ignore any chip taps until this view is focused.
682            return super.onTouchEvent(event);
683        }
684
685        boolean handled = super.onTouchEvent(event);
686        int action = event.getAction();
687        boolean chipWasSelected = false;
688
689        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
690            float x = event.getX();
691            float y = event.getY();
692            int offset = putOffsetInRange(getOffsetForPosition(x, y));
693            RecipientChip currentChip = findChip(offset);
694            if (currentChip != null) {
695                if (action == MotionEvent.ACTION_UP) {
696                    if (mSelectedChip != null && mSelectedChip != currentChip) {
697                        clearSelectedChip();
698                        mSelectedChip = currentChip.selectChip();
699                    } else if (mSelectedChip == null) {
700                        // Selection may have moved due to the tap event,
701                        // but make sure we correctly reset selection to the
702                        // end so that any unfinished chips are committed.
703                        setSelection(getText().length());
704                        commitDefault();
705                        mSelectedChip = currentChip.selectChip();
706                    } else {
707                        mSelectedChip.onClick(this, offset, x, y);
708                    }
709                }
710                chipWasSelected = true;
711            }
712        }
713        if (action == MotionEvent.ACTION_UP && !chipWasSelected) {
714            clearSelectedChip();
715        }
716        return handled;
717    }
718
719    // TODO: This algorithm will need a lot of tweaking after more people have used
720    // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring
721    // what comes before the finger.
722    private int putOffsetInRange(int o) {
723        int offset = o;
724        Editable text = getText();
725        int length = text.length();
726        // Remove whitespace from end to find "real end"
727        int realLength = length;
728        for (int i = length - 1; i >= 0; i--) {
729            if (text.charAt(i) == ' ') {
730                realLength--;
731            } else {
732                break;
733            }
734        }
735
736        // If the offset is beyond or at the end of the text,
737        // leave it alone.
738        if (offset >= realLength) {
739            return offset;
740        }
741        Editable editable = getText();
742        while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) {
743            // Keep walking backward!
744            offset--;
745        }
746        return offset;
747    }
748
749    private int findText(Editable text, int offset) {
750        if (text.charAt(offset) != ' ') {
751            return offset;
752        }
753        return -1;
754    }
755
756    private RecipientChip findChip(int offset) {
757        RecipientChip[] chips = getSpannable().getSpans(0, getText().length(), RecipientChip.class);
758        // Find the chip that contains this offset.
759        for (int i = 0; i < chips.length; i++) {
760            RecipientChip chip = chips[i];
761            if (chip.matchesChip(offset)) {
762                return chip;
763            }
764        }
765        return null;
766    }
767
768    private CharSequence createChip(RecipientEntry entry, boolean pressed) {
769        String displayText = entry.getDestination();
770        displayText = (String) mTokenizer.terminateToken(displayText);
771        // Always leave a blank space at the end of a chip.
772        int textLength = displayText.length() - 1;
773        SpannableString chipText = new SpannableString(displayText);
774        int end = getSelectionEnd();
775        int start = mTokenizer.findTokenStart(getText(), end);
776        try {
777            chipText.setSpan(constructChipSpan(entry, start, pressed), 0, textLength,
778                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
779        } catch (NullPointerException e) {
780            Log.e(TAG, e.getMessage(), e);
781            return null;
782        }
783
784        return chipText;
785    }
786
787    /**
788     * When an item in the suggestions list has been clicked, create a chip from the
789     * contact information of the selected item.
790     */
791    @Override
792    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
793        submitItemAtPosition(position);
794    }
795
796    private void submitItemAtPosition(int position) {
797        RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position);
798        // If the display name and the address are the same, then make this
799        // a fake recipient that is editable.
800        if (TextUtils.equals(entry.getDisplayName(), entry.getDestination())) {
801            entry = RecipientEntry.constructFakeEntry(entry.getDestination());
802        }
803        clearComposingText();
804
805        int end = getSelectionEnd();
806        int start = mTokenizer.findTokenStart(getText(), end);
807
808        Editable editable = getText();
809        QwertyKeyListener.markAsReplaced(editable, start, end, "");
810        editable.replace(start, end, createChip(entry, false));
811    }
812
813    /** Returns a collection of contact Id for each chip inside this View. */
814    /* package */ Collection<Long> getContactIds() {
815        final Set<Long> result = new HashSet<Long>();
816        RecipientChip[] chips = getRecipients();
817        if (chips != null) {
818            for (RecipientChip chip : chips) {
819                result.add(chip.getContactId());
820            }
821        }
822        return result;
823    }
824
825    private RecipientChip[] getRecipients() {
826        return getSpannable().getSpans(0, getText().length(), RecipientChip.class);
827    }
828
829    /** Returns a collection of data Id for each chip inside this View. May be null. */
830    /* package */ Collection<Long> getDataIds() {
831        final Set<Long> result = new HashSet<Long>();
832        RecipientChip [] chips = getRecipients();
833        if (chips != null) {
834            for (RecipientChip chip : chips) {
835                result.add(chip.getDataId());
836            }
837        }
838        return result;
839    }
840
841
842    @Override
843    public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
844        return false;
845    }
846
847    @Override
848    public void onDestroyActionMode(ActionMode mode) {
849    }
850
851    @Override
852    public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
853        return false;
854    }
855
856    /**
857     * No chips are selectable.
858     */
859    @Override
860    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
861        return false;
862    }
863
864    /**
865     * Create the more chip. The more chip is text that replaces any chips that
866     * do not fit in the pre-defined available space when the
867     * RecipientEditTextView loses focus.
868     */
869    private ImageSpan createMoreChip() {
870        RecipientChip[] recipients = getRecipients();
871        if (recipients == null || recipients.length <= CHIP_LIMIT) {
872            return null;
873        }
874        int numRecipients = recipients.length;
875        int overage = numRecipients - CHIP_LIMIT;
876        Editable text = getText();
877        // TODO: get the correct size from visual design.
878        int width = (int) Math.floor(getWidth() * MORE_WIDTH_FACTOR);
879        int height = getLineHeight();
880        Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
881        Canvas canvas = new Canvas(drawable);
882        String moreText = getResources().getString(mMoreString, overage);
883        canvas.drawText(moreText, 0, moreText.length(), 0, height - getLayout().getLineDescent(0),
884                getPaint());
885
886        Drawable result = new BitmapDrawable(getResources(), drawable);
887        result.setBounds(0, 0, width, height);
888        ImageSpan moreSpan = new ImageSpan(result);
889        Spannable spannable = getSpannable();
890        // Remove the overage chips.
891        RecipientChip[] chips = spannable.getSpans(0, text.length(), RecipientChip.class);
892        if (chips == null || chips.length == 0) {
893            Log.w(TAG,
894                "We have recipients. Tt should not be possible to have zero RecipientChips.");
895            return null;
896        }
897        mRemovedSpans = new ArrayList<RecipientChip>(chips.length);
898        int totalReplaceStart = 0;
899        int totalReplaceEnd = 0;
900        for (int i = numRecipients - overage; i < chips.length; i++) {
901            mRemovedSpans.add(chips[i]);
902            if (i == numRecipients - overage) {
903                totalReplaceStart = chips[i].getChipStart();
904            }
905            if (i == chips.length - 1) {
906                totalReplaceEnd = chips[i].getChipEnd();
907            }
908            chips[i].setPreviousChipStart(chips[i].getChipStart());
909            chips[i].setPreviousChipEnd(chips[i].getChipEnd());
910            spannable.removeSpan(chips[i]);
911        }
912        SpannableString chipText = new SpannableString(text.subSequence(totalReplaceStart,
913                totalReplaceEnd));
914        chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
915        text.replace(totalReplaceStart, totalReplaceEnd, chipText);
916        return moreSpan;
917    }
918
919    /**
920     * Replace the more chip, if it exists, with all of the recipient chips it had
921     * replaced when the RecipientEditTextView gains focus.
922     */
923    private void removeMoreChip() {
924        if (mMoreChip != null) {
925            Spannable span = getSpannable();
926            span.removeSpan(mMoreChip);
927            mMoreChip = null;
928            // Re-add the spans that were removed.
929            if (mRemovedSpans != null && mRemovedSpans.size() > 0) {
930                // Recreate each removed span.
931                Editable editable = getText();
932                SpannableString associatedText;
933                for (RecipientChip chip : mRemovedSpans) {
934                    int chipStart = chip.getPreviousChipStart();
935                    int chipEnd = Math.min(editable.length(), chip.getPreviousChipEnd());
936                    if (Log.isLoggable(TAG, Log.DEBUG) && chipEnd != chip.getPreviousChipEnd()) {
937                        Log.d(TAG,
938                                "Unexpectedly, the chip ended after the end of the editable text. "
939                                        + "Chip End " + chip.getPreviousChipEnd()
940                                        + "Editable length " + editable.length());
941                    }
942                    associatedText = new SpannableString(editable.subSequence(chipStart, chipEnd));
943                    associatedText.setSpan(chip, 0, associatedText.length(),
944                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
945                    editable.replace(chipStart, chipEnd, associatedText);
946                }
947                mRemovedSpans.clear();
948            }
949        }
950    }
951
952    /**
953     * RecipientChip defines an ImageSpan that contains information relevant to
954     * a particular recipient.
955     */
956    public class RecipientChip extends ImageSpan implements OnItemClickListener,
957            RecipientAlternatesAdapter.OnCheckedItemChangedListener {
958        private final CharSequence mDisplay;
959
960        private final CharSequence mValue;
961
962        private View mAnchorView;
963
964        private final long mContactId;
965
966        private final long mDataId;
967
968        private RecipientEntry mEntry;
969
970        private boolean mSelected = false;
971
972        private RecipientAlternatesAdapter mAlternatesAdapter;
973
974        private int mStart = -1;
975
976        private int mEnd = -1;
977
978        private ListPopupWindow mAlternatesPopup;
979
980        private int mCheckedItem;
981
982        public RecipientChip(Drawable drawable, RecipientEntry entry, int offset) {
983            super(drawable);
984            mDisplay = entry.getDisplayName();
985            mValue = entry.getDestination();
986            mContactId = entry.getContactId();
987            mDataId = entry.getDataId();
988            mEntry = entry;
989            mAnchorView = new View(getContext());
990            mAnchorView.setVisibility(View.GONE);
991        }
992
993        /**
994         * Store the offset in the spannable where this RecipientChip
995         * is currently being displayed.
996         */
997        public void setPreviousChipStart(int start) {
998            mStart = start;
999        }
1000
1001        /**
1002         * Get the offset in the spannable where this RecipientChip
1003         * was currently being displayed. Use this to determine where
1004         * to place a RecipientChip that has been hidden when the
1005         * RecipientEditTextView loses focus.
1006         */
1007        public int getPreviousChipStart() {
1008            return mStart;
1009        }
1010
1011        /**
1012         * Store the end offset in the spannable where this RecipientChip
1013         * is currently being displayed.
1014         */
1015        public void setPreviousChipEnd(int end) {
1016            mEnd = end;
1017        }
1018
1019        /**
1020         * Get the end offset in the spannable where this RecipientChip
1021         * was currently being displayed. Use this to determine where
1022         * to place a RecipientChip that has been hidden when the
1023         * RecipientEditTextView loses focus.
1024         */
1025        public int getPreviousChipEnd() {
1026            return mEnd;
1027        }
1028
1029        /**
1030         * Remove selection from this chip. Unselecting a RecipientChip will render
1031         * the chip without a delete icon and with an unfocused background. This
1032         * is called when the RecipientChip no longer has focus.
1033         */
1034        public void unselectChip() {
1035            int start = getChipStart();
1036            int end = getChipEnd();
1037            Editable editable = getText();
1038            if (start == -1 || end == -1) {
1039                Log.e(TAG, "The chip being unselected no longer exists but should.");
1040            } else {
1041                getSpannable().removeSpan(this);
1042                QwertyKeyListener.markAsReplaced(editable, start, end, "");
1043                editable.replace(start, end, createChip(mEntry, false));
1044            }
1045            mSelectedChip = null;
1046            clearSelectedChip();
1047            setCursorVisible(true);
1048            setSelection(editable.length());
1049            if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) {
1050                mAlternatesPopup.dismiss();
1051            }
1052        }
1053
1054        /**
1055         * Show this chip as selected. If the RecipientChip is just an email address,
1056         * selecting the chip will take the contents of the chip and place it at
1057         * the end of the RecipientEditTextView for inline editing. If the
1058         * RecipientChip is a complete contact, then selecting the chip
1059         * will change the background color of the chip, show the delete icon,
1060         * and a popup window with the address in use highlighted and any other
1061         * alternate addresses for the contact.
1062         * @return A RecipientChip in the selected state or null if the chip
1063         * just contained an email address.
1064         */
1065        public RecipientChip selectChip() {
1066            if (mEntry.getContactId() != INVALID_CONTACT) {
1067                int start = getChipStart();
1068                int end = getChipEnd();
1069                getSpannable().removeSpan(this);
1070                RecipientChip newChip;
1071                CharSequence displayText = mTokenizer.terminateToken(mEntry.getDestination());
1072                // Always leave a blank space at the end of a chip.
1073                int textLength = displayText.length() - 1;
1074                SpannableString chipText = new SpannableString(displayText);
1075                try {
1076                    newChip = constructChipSpan(mEntry, start, true);
1077                    chipText.setSpan(newChip, 0, textLength,
1078                            Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1079                } catch (NullPointerException e) {
1080                    Log.e(TAG, e.getMessage(), e);
1081                    return null;
1082                }
1083                Editable editable = getText();
1084                QwertyKeyListener.markAsReplaced(editable, start, end, "");
1085                if (start == -1 || end == -1) {
1086                    Log.d(TAG, "The chip being selected no longer exists but should.");
1087                } else {
1088                    editable.replace(start, end, chipText);
1089                }
1090                setCursorVisible(false);
1091                newChip.setSelected(true);
1092                newChip.showAlternates();
1093                setCursorVisible(false);
1094                return newChip;
1095            } else {
1096                CharSequence text = getValue();
1097                Editable editable = getText();
1098                removeChip();
1099                editable.append(text);
1100                setCursorVisible(true);
1101                setSelection(editable.length());
1102                return null;
1103            }
1104        }
1105
1106        /**
1107         * Handle key events for a chip. When the keyCode equals
1108         * KeyEvent.KEYCODE_DEL, this deletes the currently selected chip.
1109         */
1110        public void onKeyDown(int keyCode, KeyEvent event) {
1111            if (keyCode == KeyEvent.KEYCODE_DEL) {
1112                if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) {
1113                    mAlternatesPopup.dismiss();
1114                }
1115                removeChip();
1116            }
1117        }
1118
1119        /**
1120         * Remove this chip and any text associated with it from the RecipientEditTextView.
1121         */
1122        private void removeChip() {
1123            Spannable spannable = getSpannable();
1124            int spanStart = spannable.getSpanStart(this);
1125            int spanEnd = spannable.getSpanEnd(this);
1126            Editable text = getText();
1127            int toDelete = spanEnd;
1128            boolean wasSelected = this == mSelectedChip;
1129            // Clear that there is a selected chip before updating any text.
1130            if (wasSelected) {
1131                mSelectedChip = null;
1132            }
1133            // Always remove trailing spaces when removing a chip.
1134            while (toDelete >= 0 && toDelete < text.length() - 1 && text.charAt(toDelete) == ' ') {
1135                toDelete++;
1136            }
1137            spannable.removeSpan(this);
1138            text.delete(spanStart, toDelete);
1139            if (wasSelected) {
1140                clearSelectedChip();
1141            }
1142        }
1143
1144        /**
1145         * Get the start offset of this chip in the view.
1146         */
1147        public int getChipStart() {
1148            return getSpannable().getSpanStart(this);
1149        }
1150
1151        /**
1152         * Get the end offset of this chip in the view.
1153         */
1154        public int getChipEnd() {
1155            return getSpannable().getSpanEnd(this);
1156        }
1157
1158        /**
1159         * Replace this currently selected chip with a new chip
1160         * that uses the contact data provided.
1161         */
1162        public void replaceChip(RecipientEntry entry) {
1163            boolean wasSelected = this == mSelectedChip;
1164            if (wasSelected) {
1165                mSelectedChip = null;
1166            }
1167            int start = getSpannable().getSpanStart(this);
1168            int end = getSpannable().getSpanEnd(this);
1169            getSpannable().removeSpan(this);
1170            Editable editable = getText();
1171            CharSequence chipText = createChip(entry, false);
1172            if (start == -1 || end == -1) {
1173                Log.e(TAG, "The chip to replace does not exist but should.");
1174                editable.insert(0, chipText);
1175            } else {
1176                editable.replace(start, end, chipText);
1177            }
1178            setCursorVisible(true);
1179            if (wasSelected) {
1180                clearSelectedChip();
1181            }
1182        }
1183
1184        /**
1185         * Show all addresses associated with a contact.
1186         */
1187        private void showAlternates() {
1188            mAlternatesPopup = new ListPopupWindow(getContext());
1189
1190            if (!mAlternatesPopup.isShowing()) {
1191                int line = getLayout().getLineForOffset(getChipStart());
1192                int[] xy = new int[2];
1193                getLocationOnScreen(xy);
1194                int bottom = calculateLineBottom(xy[1], line, (int) mChipHeight);
1195                mAnchorView.setBottom(bottom);
1196                mAnchorView.setTop(bottom);
1197                mCheckedItem = -1;
1198                mAlternatesAdapter = new RecipientAlternatesAdapter(getContext(), mEntry
1199                        .getContactId(), mEntry.getDataId(), mAlternatesLayout, this);
1200                // Align the alternates popup with the left side of the View, regardless
1201                // of the position of the chip tapped.
1202                mAlternatesPopup.setAnchorView(mAnchorView);
1203                mAlternatesPopup.setAdapter(mAlternatesAdapter);
1204                mAlternatesPopup.setWidth(getWidth());
1205                mAlternatesPopup.setOnItemClickListener(this);
1206                mAlternatesPopup.show();
1207                ListView listView = mAlternatesPopup.getListView();
1208                listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
1209                // Checked item would be -1 if the adapter has not
1210                // loaded the view that should be checked yet. The
1211                // variable will be set correctly when onCheckedItemChanged
1212                // is called in a separate thread.
1213                if (mCheckedItem != -1) {
1214                    listView.setItemChecked(mCheckedItem, true);
1215                }
1216            }
1217        }
1218
1219        @Override
1220        public void onCheckedItemChanged(int position) {
1221            ListView listView = mAlternatesPopup.getListView();
1222            if (listView != null && listView.getCheckedItemCount() == 0) {
1223                listView.setItemChecked(position, true);
1224            } else {
1225                mCheckedItem = position;
1226            }
1227        }
1228
1229        private void setSelected(boolean selected) {
1230            mSelected = selected;
1231        }
1232
1233        /**
1234         * Get the text displayed in the chip.
1235         */
1236        public CharSequence getDisplay() {
1237            return mDisplay;
1238        }
1239
1240        /**
1241         * Get the text value this chip represents.
1242         */
1243        public CharSequence getValue() {
1244            return mValue;
1245        }
1246
1247        /**
1248         * See if a touch event was inside the delete target of
1249         * a selected chip. It is in the delete target if:
1250         * 1) the x and y points of the event are within the
1251         * delete assset.
1252         * 2) the point tapped would have caused a cursor to appear
1253         * right after the selected chip.
1254         */
1255        private boolean isInDelete(int offset, float x, float y) {
1256            // Figure out the bounds of this chip and whether or not
1257            // the user clicked in the X portion.
1258            return mSelected && offset == getChipEnd();
1259        }
1260
1261        /**
1262         * Return whether this chip contains the position passed in.
1263         */
1264        public boolean matchesChip(int offset) {
1265            int start = getChipStart();
1266            int end = getChipEnd();
1267            return (offset >= start && offset <= end);
1268        }
1269
1270        /**
1271         * Handle click events for a chip. When a selected chip receives a click
1272         * event, see if that event was in the delete icon. If so, delete it.
1273         * Otherwise, unselect the chip.
1274         */
1275        public void onClick(View widget, int offset, float x, float y) {
1276            if (mSelected) {
1277                if (isInDelete(offset, x, y)) {
1278                    removeChip();
1279                } else {
1280                    clearSelectedChip();
1281                }
1282            }
1283        }
1284
1285        @Override
1286        public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top,
1287                int y, int bottom, Paint paint) {
1288            // Shift the bounds of this span to where it is actually drawn on the screeen.
1289            super.draw(canvas, text, start, end, x, top, y, bottom, paint);
1290        }
1291
1292        /**
1293         * Handle clicks to alternate addresses for a selected chip. If the user
1294         * selects an alternate, the chip is replaced with a new contact with the
1295         * new contact address information.
1296         */
1297        @Override
1298        public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) {
1299            Message delayed = Message.obtain(mHandler, DISMISS);
1300            delayed.obj = mAlternatesPopup;
1301            mHandler.sendMessageDelayed(delayed, DISMISS_DELAY);
1302            clearComposingText();
1303            replaceChip(mAlternatesAdapter.getRecipientEntry(position));
1304        }
1305
1306        /**
1307         * Get the id of the contact associated with this chip.
1308         */
1309        public long getContactId() {
1310            return mContactId;
1311        }
1312
1313        /**
1314         * Get the id of the data associated with this chip.
1315         */
1316        public long getDataId() {
1317            return mDataId;
1318        }
1319    }
1320}
1321