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