RecipientEditTextView.java revision 892eda7e02632b498880bbc26025792405fb6a9a
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 moreResource
421     * @param alternatesLayout
422     * @param chipHeight
423     * @param padding Padding around the text in a chip
424     */
425    public void setChipDimensions(Drawable chipBackground, Drawable chipBackgroundPressed,
426            Drawable invalidChip, Drawable chipDelete, Bitmap defaultContact, int moreResource,
427            int alternatesLayout, 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    @Override
442    public void onSizeChanged(int width, int height, int oldw, int oldh) {
443        super.onSizeChanged(width, height, oldw, oldh);
444        // Check for any pending tokens created before layout had been completed on the view.
445        if (width != 0 && height != 0 && mPendingChipsCount > 0) {
446            Editable editable = getText();
447            // Tokenize!
448            int startingPos = 0;
449            while (startingPos < editable.length() && mPendingChipsCount > 0) {
450                int tokenEnd = mTokenizer.findTokenEnd(editable, startingPos);
451                int tokenStart = mTokenizer.findTokenStart(editable, tokenEnd);
452                // Always include seperators with the token to the left.
453                if (tokenEnd < editable.length()-1 && editable.charAt(tokenEnd) == SEPERATOR) {
454                    tokenEnd++;
455                }
456                startingPos = tokenEnd;
457                String token = (String) editable.toString().substring(tokenStart, tokenEnd);
458                editable.replace(tokenStart, tokenEnd, createChip(RecipientEntry
459                        .constructFakeEntry(token), false));
460                mPendingChipsCount--;
461            }
462            mPendingChipsCount = 0;
463        }
464    }
465
466    @Override
467    public void setTokenizer(Tokenizer tokenizer) {
468        mTokenizer = tokenizer;
469        super.setTokenizer(mTokenizer);
470    }
471
472    @Override
473    public void setValidator(Validator validator) {
474        mValidator = validator;
475        super.setValidator(validator);
476    }
477
478    /**
479     * We cannot use the default mechanism for replaceText. Instead,
480     * we override onItemClickListener so we can get all the associated
481     * contact information including display text, address, and id.
482     */
483    @Override
484    protected void replaceText(CharSequence text) {
485        return;
486    }
487
488    /**
489     * Dismiss any selected chips when the back key is pressed.
490     */
491    @Override
492    public boolean onKeyPreIme(int keyCode, KeyEvent event) {
493        if (keyCode == KeyEvent.KEYCODE_BACK) {
494            clearSelectedChip();
495        }
496        return super.onKeyPreIme(keyCode, event);
497    }
498
499    /**
500     * Monitor key presses in this view to see if the user types
501     * any commit keys, which consist of ENTER, TAB, or DPAD_CENTER.
502     * If the user has entered text that has contact matches and types
503     * a commit key, create a chip from the topmost matching contact.
504     * If the user has entered text that has no contact matches and types
505     * a commit key, then create a chip from the text they have entered.
506     */
507    @Override
508    public boolean onKeyUp(int keyCode, KeyEvent event) {
509        switch (keyCode) {
510            case KeyEvent.KEYCODE_ENTER:
511            case KeyEvent.KEYCODE_DPAD_CENTER:
512            case KeyEvent.KEYCODE_TAB:
513                if (event.hasNoModifiers()) {
514                    if (commitDefault()) {
515                        return true;
516                    }
517                }
518                break;
519        }
520        return super.onKeyUp(keyCode, event);
521    }
522
523    /**
524     * Create a chip from the default selection. If the popup is showing, the
525     * default is the first item in the popup suggestions list. Otherwise, it is
526     * whatever the user had typed in. End represents where the the tokenizer
527     * should search for a token to turn into a chip.
528     * @return If a chip was created from a real contact.
529     */
530    private boolean commitDefault() {
531        Editable editable = getText();
532        boolean enough = enoughToFilter();
533        boolean shouldSubmitAtPosition = false;
534        int end = getSelectionEnd();
535        int start = mTokenizer.findTokenStart(editable, end);
536        if (enough) {
537            RecipientChip[] chips = getSpannable().getSpans(start, end, RecipientChip.class);
538            if ((chips == null || chips.length == 0)) {
539                // There's something being filtered or typed that has not been
540                // completed yet.
541                shouldSubmitAtPosition = true;
542            }
543        }
544
545        if (shouldSubmitAtPosition) {
546            if (getAdapter().getCount() > 0) {
547                // choose the first entry.
548                submitItemAtPosition(0);
549                dismissDropDown();
550                return true;
551            } else {
552                String text = editable.toString().substring(start, end);
553                clearComposingText();
554                if (text != null && text.length() > 0 && !text.equals(" ")) {
555                    RecipientEntry entry = RecipientEntry.constructFakeEntry(text);
556                    QwertyKeyListener.markAsReplaced(editable, start, end, "");
557                    editable.replace(start, end, createChip(entry, false));
558                    dismissDropDown();
559                }
560                return false;
561            }
562        }
563        return false;
564    }
565
566    /**
567     * If there is a selected chip, delegate the key events
568     * to the selected chip.
569     */
570    @Override
571    public boolean onKeyDown(int keyCode, KeyEvent event) {
572        if (mSelectedChip != null) {
573            mSelectedChip.onKeyDown(keyCode, event);
574        }
575
576        if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) {
577            return true;
578        }
579
580        return super.onKeyDown(keyCode, event);
581    }
582
583    private Spannable getSpannable() {
584        return (Spannable) getText();
585    }
586
587    /**
588     * Instead of filtering on the entire contents of the edit box,
589     * this subclass method filters on the range from
590     * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd}
591     * if the length of that range meets or exceeds {@link #getThreshold}
592     * and makes sure that the range is not already a Chip.
593     */
594    @Override
595    protected void performFiltering(CharSequence text, int keyCode) {
596        if (enoughToFilter()) {
597            int end = getSelectionEnd();
598            int start = mTokenizer.findTokenStart(text, end);
599            // If this is a RecipientChip, don't filter
600            // on its contents.
601            Spannable span = getSpannable();
602            RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
603            if (chips != null && chips.length > 0) {
604                return;
605            }
606        }
607        super.performFiltering(text, keyCode);
608    }
609
610    private void clearSelectedChip() {
611        if (mSelectedChip != null) {
612            mSelectedChip.unselectChip();
613            mSelectedChip = null;
614        }
615        setCursorVisible(true);
616    }
617
618    /**
619     * Monitor touch events in the RecipientEditTextView.
620     * If the view does not have focus, any tap on the view
621     * will just focus the view. If the view has focus, determine
622     * if the touch target is a recipient chip. If it is and the chip
623     * is not selected, select it and clear any other selected chips.
624     * If it isn't, then select that chip.
625     */
626    @Override
627    public boolean onTouchEvent(MotionEvent event) {
628        if (!isFocused()) {
629            // Ignore any chip taps until this view is focused.
630            return super.onTouchEvent(event);
631        }
632
633        boolean handled = super.onTouchEvent(event);
634        int action = event.getAction();
635        boolean chipWasSelected = false;
636
637        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
638            float x = event.getX();
639            float y = event.getY();
640            int offset = putOffsetInRange(getOffsetForPosition(x, y));
641            RecipientChip currentChip = findChip(offset);
642            if (currentChip != null) {
643                if (action == MotionEvent.ACTION_UP) {
644                    if (mSelectedChip != null && mSelectedChip != currentChip) {
645                        clearSelectedChip();
646                        mSelectedChip = currentChip.selectChip();
647                    } else if (mSelectedChip == null) {
648                        // Selection may have moved due to the tap event,
649                        // but make sure we correctly reset selection to the
650                        // end so that any unfinished chips are committed.
651                        setSelection(getText().length());
652                        commitDefault();
653                        mSelectedChip = currentChip.selectChip();
654                    } else {
655                        mSelectedChip.onClick(this, offset, x, y);
656                    }
657                }
658                chipWasSelected = true;
659            }
660        }
661        if (action == MotionEvent.ACTION_UP && !chipWasSelected) {
662            clearSelectedChip();
663        }
664        return handled;
665    }
666
667    // TODO: This algorithm will need a lot of tweaking after more people have used
668    // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring
669    // what comes before the finger.
670    private int putOffsetInRange(int o) {
671        int offset = o;
672        Editable text = getText();
673        int length = text.length();
674        // Remove whitespace from end to find "real end"
675        int realLength = length;
676        for (int i = length - 1; i >= 0; i--) {
677            if (text.charAt(i) == ' ') {
678                realLength--;
679            } else {
680                break;
681            }
682        }
683
684        // If the offset is beyond or at the end of the text,
685        // leave it alone.
686        if (offset >= realLength) {
687            return offset;
688        }
689        Editable editable = getText();
690        while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) {
691            // Keep walking backward!
692            offset--;
693        }
694        return offset;
695    }
696
697    private int findText(Editable text, int offset) {
698        if (text.charAt(offset) != ' ') {
699            return offset;
700        }
701        return -1;
702    }
703
704    private RecipientChip findChip(int offset) {
705        RecipientChip[] chips = getSpannable().getSpans(0, getText().length(), RecipientChip.class);
706        // Find the chip that contains this offset.
707        for (int i = 0; i < chips.length; i++) {
708            RecipientChip chip = chips[i];
709            if (chip.matchesChip(offset)) {
710                return chip;
711            }
712        }
713        return null;
714    }
715
716    private CharSequence createChip(RecipientEntry entry, boolean pressed) {
717        String displayText = entry.getDestination();
718        if (!TextUtils.isEmpty(displayText)
719                && displayText.charAt(displayText.length() - 1) != SEPERATOR) {
720            displayText = (String) mTokenizer.terminateToken(entry.getDestination());
721        }
722        // Always leave a blank space at the end of a chip.
723        int textLength = displayText.length() - 1;
724        SpannableString chipText = new SpannableString(displayText);
725        int end = getSelectionEnd();
726        int start = mTokenizer.findTokenStart(getText(), end);
727        try {
728            chipText.setSpan(constructChipSpan(entry, start, pressed), 0, textLength,
729                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
730        } catch (NullPointerException e) {
731            Log.e(TAG, e.getMessage(), e);
732            return null;
733        }
734
735        return chipText;
736    }
737
738    /**
739     * When an item in the suggestions list has been clicked, create a chip from the
740     * contact information of the selected item.
741     */
742    @Override
743    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
744        submitItemAtPosition(position);
745    }
746
747    private void submitItemAtPosition(int position) {
748        RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position);
749        // If the display name and the address are the same, then make this
750        // a fake recipient that is editable.
751        if (TextUtils.equals(entry.getDisplayName(), entry.getDestination())) {
752            entry = RecipientEntry.constructFakeEntry(entry.getDestination());
753        }
754        clearComposingText();
755
756        int end = getSelectionEnd();
757        int start = mTokenizer.findTokenStart(getText(), end);
758
759        Editable editable = getText();
760        QwertyKeyListener.markAsReplaced(editable, start, end, "");
761        editable.replace(start, end, createChip(entry, false));
762    }
763
764    /** Returns a collection of contact Id for each chip inside this View. */
765    /* package */ Collection<Long> getContactIds() {
766        final Set<Long> result = new HashSet<Long>();
767        RecipientChip[] chips = getRecipients();
768        if (chips != null) {
769            for (RecipientChip chip : chips) {
770                result.add(chip.getContactId());
771            }
772        }
773        return result;
774    }
775
776    private RecipientChip[] getRecipients() {
777        return getSpannable().getSpans(0, getText().length(), RecipientChip.class);
778    }
779
780    /** Returns a collection of data Id for each chip inside this View. May be null. */
781    /* package */ Collection<Long> getDataIds() {
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.getDataId());
787            }
788        }
789        return result;
790    }
791
792
793    @Override
794    public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
795        return false;
796    }
797
798    @Override
799    public void onDestroyActionMode(ActionMode mode) {
800    }
801
802    @Override
803    public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
804        return false;
805    }
806
807    /**
808     * No chips are selectable.
809     */
810    @Override
811    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
812        return false;
813    }
814
815    /**
816     * Create the more chip. The more chip is text that replaces any chips that
817     * do not fit in the pre-defined available space when the
818     * RecipientEditTextView loses focus.
819     */
820    private ImageSpan createMoreChip() {
821        RecipientChip[] recipients = getRecipients();
822        if (recipients == null || recipients.length <= CHIP_LIMIT) {
823            return null;
824        }
825        int numRecipients = recipients.length;
826        int overage = numRecipients - CHIP_LIMIT;
827        Editable text = getText();
828        // TODO: get the correct size from visual design.
829        int width = (int) Math.floor(getWidth() * MORE_WIDTH_FACTOR);
830        int height = getLineHeight();
831        Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
832        Canvas canvas = new Canvas(drawable);
833        String moreText = getResources().getString(mMoreString, overage);
834        canvas.drawText(moreText, 0, moreText.length(), 0, height - getLayout().getLineDescent(0),
835                getPaint());
836
837        Drawable result = new BitmapDrawable(getResources(), drawable);
838        result.setBounds(0, 0, width, height);
839        ImageSpan moreSpan = new ImageSpan(result);
840        Spannable spannable = getSpannable();
841        // Remove the overage chips.
842        RecipientChip[] chips = spannable.getSpans(0, text.length(), RecipientChip.class);
843        if (chips == null || chips.length == 0) {
844            Log.w(TAG,
845                "We have recipients. Tt should not be possible to have zero RecipientChips.");
846            return null;
847        }
848        mRemovedSpans = new ArrayList<RecipientChip>(chips.length);
849        int totalReplaceStart = 0;
850        int totalReplaceEnd = 0;
851        for (int i = numRecipients - overage; i < chips.length; i++) {
852            mRemovedSpans.add(chips[i]);
853            if (i == numRecipients - overage) {
854                totalReplaceStart = chips[i].getChipStart();
855            }
856            if (i == chips.length - 1) {
857                totalReplaceEnd = chips[i].getChipEnd();
858            }
859            chips[i].setPreviousChipStart(chips[i].getChipStart());
860            chips[i].setPreviousChipEnd(chips[i].getChipEnd());
861            spannable.removeSpan(chips[i]);
862        }
863        SpannableString chipText = new SpannableString(text.subSequence(totalReplaceStart,
864                totalReplaceEnd));
865        chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
866        text.replace(totalReplaceStart, totalReplaceEnd, chipText);
867        return moreSpan;
868    }
869
870    /**
871     * Replace the more chip, if it exists, with all of the recipient chips it had
872     * replaced when the RecipientEditTextView gains focus.
873     */
874    private void removeMoreChip() {
875        if (mMoreChip != null) {
876            Spannable span = getSpannable();
877            span.removeSpan(mMoreChip);
878            mMoreChip = null;
879            // Re-add the spans that were removed.
880            if (mRemovedSpans != null && mRemovedSpans.size() > 0) {
881                // Recreate each removed span.
882                Editable editable = getText();
883                SpannableString associatedText;
884                for (RecipientChip chip : mRemovedSpans) {
885                    int chipStart = chip.getPreviousChipStart();
886                    int chipEnd = Math.min(editable.length(), chip.getPreviousChipEnd());
887                    if (Log.isLoggable(TAG, Log.DEBUG) && chipEnd != chip.getPreviousChipEnd()) {
888                        Log.d(TAG,
889                                "Unexpectedly, the chip ended after the end of the editable text. "
890                                        + "Chip End " + chip.getPreviousChipEnd()
891                                        + "Editable length " + editable.length());
892                    }
893                    associatedText = new SpannableString(editable.subSequence(chipStart, chipEnd));
894                    associatedText.setSpan(chip, 0, associatedText.length(),
895                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
896                    editable.replace(chipStart, chipEnd, associatedText);
897                }
898                mRemovedSpans.clear();
899            }
900        }
901    }
902
903    /**
904     * RecipientChip defines an ImageSpan that contains information relevant to
905     * a particular recipient.
906     */
907    public class RecipientChip extends ImageSpan implements OnItemClickListener {
908        private final CharSequence mDisplay;
909
910        private final CharSequence mValue;
911
912        private View mAnchorView;
913
914        private int mLeft;
915
916        private final long mContactId;
917
918        private final long mDataId;
919
920        private RecipientEntry mEntry;
921
922        private boolean mSelected = false;
923
924        private RecipientAlternatesAdapter mAlternatesAdapter;
925
926        private Rect mBounds;
927
928        private int mStart = -1;
929
930        private int mEnd = -1;
931
932        private ListPopupWindow mAlternatesPopup;
933
934        public RecipientChip(Drawable drawable, RecipientEntry entry, int offset, Rect bounds) {
935            super(drawable);
936            mDisplay = entry.getDisplayName();
937            mValue = entry.getDestination();
938            mContactId = entry.getContactId();
939            mDataId = entry.getDataId();
940            mEntry = entry;
941            mBounds = bounds;
942
943            mAnchorView = new View(getContext());
944            mAnchorView.setLeft(bounds.left);
945            mAnchorView.setRight(bounds.left);
946            mAnchorView.setTop(bounds.bottom);
947            mAnchorView.setBottom(bounds.bottom);
948            mAnchorView.setVisibility(View.GONE);
949        }
950
951        /**
952         * Store the offset in the spannable where this RecipientChip
953         * is currently being displayed.
954         */
955        public void setPreviousChipStart(int start) {
956            mStart = start;
957        }
958
959        /**
960         * Get the offset in the spannable where this RecipientChip
961         * was currently being displayed. Use this to determine where
962         * to place a RecipientChip that has been hidden when the
963         * RecipientEditTextView loses focus.
964         */
965        public int getPreviousChipStart() {
966            return mStart;
967        }
968
969        /**
970         * Store the end offset in the spannable where this RecipientChip
971         * is currently being displayed.
972         */
973        public void setPreviousChipEnd(int end) {
974            mEnd = end;
975        }
976
977        /**
978         * Get the end offset in the spannable where this RecipientChip
979         * was currently being displayed. Use this to determine where
980         * to place a RecipientChip that has been hidden when the
981         * RecipientEditTextView loses focus.
982         */
983        public int getPreviousChipEnd() {
984            return mEnd;
985        }
986
987        /**
988         * Remove selection from this chip. Unselecting a RecipientChip will render
989         * the chip without a delete icon and with an unfocused background. This
990         * is called when the RecipientChip not longer has focus.
991         */
992        public void unselectChip() {
993            int start = getChipStart();
994            int end = getChipEnd();
995            Editable editable = getText();
996            if (start == -1 || end == -1) {
997                Log.e(TAG, "The chip being unselected no longer exists but should.");
998            } else {
999                getSpannable().removeSpan(this);
1000                QwertyKeyListener.markAsReplaced(editable, start, end, "");
1001                editable.replace(start, end, createChip(mEntry, false));
1002            }
1003            mSelectedChip = null;
1004            clearSelectedChip();
1005            setCursorVisible(true);
1006            setSelection(editable.length());
1007        }
1008
1009        /**
1010         * Show this chip as selected. If the RecipientChip is just an email address,
1011         * selecting the chip will take the contents of the chip and place it at
1012         * the end of the RecipientEditTextView for inline editing. If the
1013         * RecipientChip is a complete contact, then selecting the chip
1014         * will change the background color of the chip, show the delete icon,
1015         * and a popup window with the address in use highlighted and any other
1016         * alternate addresses for the contact.
1017         * @return A RecipientChip in the selected state or null if the chip
1018         * just contained an email address.
1019         */
1020        public RecipientChip selectChip() {
1021            if (mEntry.getContactId() != INVALID_CONTACT) {
1022                int start = getChipStart();
1023                int end = getChipEnd();
1024                getSpannable().removeSpan(this);
1025                RecipientChip newChip;
1026                CharSequence displayText = mTokenizer.terminateToken(mEntry.getDestination());
1027                // Always leave a blank space at the end of a chip.
1028                int textLength = displayText.length() - 1;
1029                SpannableString chipText = new SpannableString(displayText);
1030                try {
1031                    newChip = constructChipSpan(mEntry, start, true);
1032                    chipText.setSpan(newChip, 0, textLength,
1033                            Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1034                } catch (NullPointerException e) {
1035                    Log.e(TAG, e.getMessage(), e);
1036                    return null;
1037                }
1038                Editable editable = getText();
1039                QwertyKeyListener.markAsReplaced(editable, start, end, "");
1040                if (start == -1 || end == -1) {
1041                    Log.d(TAG, "The chip being selected no longer exists but should.");
1042                } else {
1043                    editable.replace(start, end, chipText);
1044                }
1045                setCursorVisible(false);
1046                newChip.setSelected(true);
1047                newChip.showAlternates();
1048                setCursorVisible(false);
1049                return newChip;
1050            } else {
1051                CharSequence text = getValue();
1052                Editable editable = getText();
1053                removeChip();
1054                editable.append(text);
1055                setCursorVisible(true);
1056                setSelection(editable.length());
1057                return null;
1058            }
1059        }
1060
1061        /**
1062         * Handle key events for a chip. When the keyCode equals
1063         * KeyEvent.KEYCODE_DEL, this deletes the currently selected chip.
1064         */
1065        public void onKeyDown(int keyCode, KeyEvent event) {
1066            if (keyCode == KeyEvent.KEYCODE_DEL) {
1067                if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) {
1068                    mAlternatesPopup.dismiss();
1069                }
1070                removeChip();
1071            }
1072        }
1073
1074        /**
1075         * Remove this chip and any text associated with it from the RecipientEditTextView.
1076         */
1077        private void removeChip() {
1078            Spannable spannable = getSpannable();
1079            int spanStart = spannable.getSpanStart(this);
1080            int spanEnd = spannable.getSpanEnd(this);
1081            Editable text = getText();
1082            int toDelete = spanEnd;
1083            boolean wasSelected = this == mSelectedChip;
1084            // Clear that there is a selected chip before updating any text.
1085            if (wasSelected) {
1086                mSelectedChip = null;
1087            }
1088            // Always remove trailing spaces when removing a chip.
1089            while (toDelete >= 0 && toDelete < text.length() - 1 && text.charAt(toDelete) == ' ') {
1090                toDelete++;
1091            }
1092            spannable.removeSpan(this);
1093            text.delete(spanStart, toDelete);
1094            if (wasSelected) {
1095                clearSelectedChip();
1096            }
1097        }
1098
1099        /**
1100         * Get the start offset of this chip in the view.
1101         */
1102        public int getChipStart() {
1103            return getSpannable().getSpanStart(this);
1104        }
1105
1106        /**
1107         * Get the end offset of this chip in the view.
1108         */
1109        public int getChipEnd() {
1110            return getSpannable().getSpanEnd(this);
1111        }
1112
1113        /**
1114         * Replace this currently selected chip with a new chip
1115         * that uses the contact data provided.
1116         */
1117        public void replaceChip(RecipientEntry entry) {
1118            boolean wasSelected = this == mSelectedChip;
1119            if (wasSelected) {
1120                mSelectedChip = null;
1121            }
1122            int start = getSpannable().getSpanStart(this);
1123            int end = getSpannable().getSpanEnd(this);
1124            getSpannable().removeSpan(this);
1125            Editable editable = getText();
1126            CharSequence chipText = createChip(entry, false);
1127            if (start == -1 || end == -1) {
1128                Log.e(TAG, "The chip to replace does not exist but should.");
1129                editable.insert(0, chipText);
1130            } else {
1131                editable.replace(start, end, chipText);
1132            }
1133            setCursorVisible(true);
1134            if (wasSelected) {
1135                clearSelectedChip();
1136            }
1137        }
1138
1139        /**
1140         * Show all addresses associated with a contact.
1141         */
1142        private void showAlternates() {
1143            mAlternatesPopup = new ListPopupWindow(getContext());
1144
1145            if (!mAlternatesPopup.isShowing()) {
1146                mAlternatesAdapter = new RecipientAlternatesAdapter(getContext(),
1147                        mEntry.getContactId(), mEntry.getDataId(), mAlternatesLayout);
1148                mAnchorView.setLeft(mLeft);
1149                mAnchorView.setRight(mLeft);
1150                mAlternatesPopup.setAnchorView(mAnchorView);
1151                mAlternatesPopup.setAdapter(mAlternatesAdapter);
1152                mAlternatesPopup.setWidth(getWidth());
1153                mAlternatesPopup.setOnItemClickListener(this);
1154                mAlternatesPopup.show();
1155                ListView listView = mAlternatesPopup.getListView();
1156                listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
1157                listView.setItemChecked(mAlternatesAdapter.getCheckedItemPosition(), true);
1158            }
1159        }
1160
1161        private void setSelected(boolean selected) {
1162            mSelected = selected;
1163        }
1164
1165        /**
1166         * Get the text displayed in the chip.
1167         */
1168        public CharSequence getDisplay() {
1169            return mDisplay;
1170        }
1171
1172        /**
1173         * Get the text value this chip represents.
1174         */
1175        public CharSequence getValue() {
1176            return mValue;
1177        }
1178
1179        /**
1180         * See if a touch event was inside the delete target of
1181         * a selected chip. It is in the delete target if:
1182         * 1) the x and y points of the event are within the
1183         * delete assset.
1184         * 2) the point tapped would have caused a cursor to appear
1185         * right after the selected chip.
1186         */
1187        private boolean isInDelete(int offset, float x, float y) {
1188            // Figure out the bounds of this chip and whether or not
1189            // the user clicked in the X portion.
1190            return mSelected
1191                    && (offset == getChipEnd()
1192                            || (x > (mBounds.right - mChipDeleteWidth) && x < mBounds.right));
1193        }
1194
1195        /**
1196         * Return whether this chip contains the position passed in.
1197         */
1198        public boolean matchesChip(int offset) {
1199            int start = getChipStart();
1200            int end = getChipEnd();
1201            return (offset >= start && offset <= end);
1202        }
1203
1204        /**
1205         * Handle click events for a chip. When a selected chip receives a click
1206         * event, see if that event was in the delete icon. If so, delete it.
1207         * Otherwise, unselect the chip.
1208         */
1209        public void onClick(View widget, int offset, float x, float y) {
1210            if (mSelected) {
1211                if (isInDelete(offset, x, y)) {
1212                    removeChip();
1213                } else {
1214                    clearSelectedChip();
1215                }
1216            }
1217        }
1218
1219        @Override
1220        public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top,
1221                int y, int bottom, Paint paint) {
1222            // Shift the bounds of this span to where it is actually drawn on the screeen.
1223            mLeft = (int) x;
1224            super.draw(canvas, text, start, end, x, top, y, bottom, paint);
1225        }
1226
1227        /**
1228         * Handle clicks to alternate addresses for a selected chip. If the user
1229         * selects an alternate, the chip is replaced with a new contact with the
1230         * new contact address information.
1231         */
1232        @Override
1233        public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) {
1234            Message delayed = Message.obtain(mHandler, DISMISS);
1235            delayed.obj = mAlternatesPopup;
1236            mHandler.sendMessageDelayed(delayed, DISMISS_DELAY);
1237            clearComposingText();
1238            replaceChip(mAlternatesAdapter.getRecipientEntry(position));
1239        }
1240
1241        /**
1242         * Get the id of the contact associated with this chip.
1243         */
1244        public long getContactId() {
1245            return mContactId;
1246        }
1247
1248        /**
1249         * Get the id of the data associated with this chip.
1250         */
1251        public long getDataId() {
1252            return mDataId;
1253        }
1254    }
1255}
1256