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