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