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