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