RecipientEditTextView.java revision 12cf3fc6e24ffeb3b84ec5fbbaa7add81b25db09
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 onKeyUp(int keyCode, KeyEvent event) {
353        switch (keyCode) {
354            case KeyEvent.KEYCODE_ENTER:
355            case KeyEvent.KEYCODE_DPAD_CENTER:
356            case KeyEvent.KEYCODE_TAB:
357                if (event.hasNoModifiers()) {
358                    if (commitDefault()) {
359                        return true;
360                    }
361                }
362        }
363        return super.onKeyUp(keyCode, event);
364    }
365
366    // If the popup is showing, the default is the first item in the popup
367    // suggestions list. Otherwise, it is whatever the user had typed in.
368    private boolean commitDefault() {
369        Editable editable = getText();
370        boolean enough = enoughToFilter();
371        boolean shouldSubmitAtPosition = false;
372        int end = getSelectionEnd();
373        int start = mTokenizer.findTokenStart(editable, end);
374        if (enough) {
375            RecipientChip[] chips = getSpannable().getSpans(start, end, RecipientChip.class);
376            if ((chips == null || chips.length == 0)) {
377                // There's something being filtered or typed that has not been
378                // completed yet.
379                shouldSubmitAtPosition = true;
380            }
381        }
382
383        if (shouldSubmitAtPosition) {
384            if (getAdapter().getCount() > 0) {
385                // choose the first entry.
386                submitItemAtPosition(0);
387                dismissDropDown();
388                return true;
389            } else {
390                String text = editable.toString().substring(start, end);
391                clearComposingText();
392                if (text != null && text.length() > 0
393                        && (text.length() != 1 && text.charAt(0) != ' ')) {
394                    RecipientEntry entry = RecipientEntry.constructFakeEntry(text);
395                    QwertyKeyListener.markAsReplaced(editable, start, end, "");
396                    editable.replace(start, end, createChip(entry));
397                    dismissDropDown();
398                }
399                return false;
400            }
401        }
402        return false;
403    }
404
405    @Override
406    public boolean onKeyDown(int keyCode, KeyEvent event) {
407        if (mSelectedChip != null) {
408            mSelectedChip.onKeyDown(keyCode, event);
409        }
410
411        if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) {
412            return true;
413        }
414
415        return super.onKeyDown(keyCode, event);
416    }
417
418    private Spannable getSpannable() {
419        return (Spannable) getText();
420    }
421
422    /**
423     * Instead of filtering on the entire contents of the edit box,
424     * this subclass method filters on the range from
425     * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd}
426     * if the length of that range meets or exceeds {@link #getThreshold}
427     * and makes sure that the range is not already a Chip.
428     */
429    @Override
430    protected void performFiltering(CharSequence text, int keyCode) {
431        if (enoughToFilter()) {
432            int end = getSelectionEnd();
433            int start = mTokenizer.findTokenStart(text, end);
434            // If this is a RecipientChip, don't filter
435            // on its contents.
436            Spannable span = getSpannable();
437            RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
438            if (chips != null && chips.length > 0) {
439                return;
440            }
441        }
442        super.performFiltering(text, keyCode);
443    }
444
445    private void clearSelectedChip() {
446        if (mSelectedChip != null) {
447            mSelectedChip.unselectChip();
448            mSelectedChip = null;
449        }
450        setCursorVisible(true);
451    }
452
453    @Override
454    public boolean onTouchEvent(MotionEvent event) {
455        if (!isFocused()) {
456            // Ignore any chip taps until this view is focused.
457            return super.onTouchEvent(event);
458        }
459
460        boolean handled = super.onTouchEvent(event);
461        int action = event.getAction();
462        boolean chipWasSelected = false;
463
464        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
465            float x = event.getX();
466            float y = event.getY();
467            int offset = putOffsetInRange(getOffsetForPosition(x, y));
468            RecipientChip currentChip = findChip(offset);
469            if (currentChip != null) {
470                if (action == MotionEvent.ACTION_UP) {
471                    if (mSelectedChip != null && mSelectedChip != currentChip) {
472                        clearSelectedChip();
473                        mSelectedChip = currentChip.selectChip();
474                    } else if (mSelectedChip == null) {
475                        mSelectedChip = currentChip.selectChip();
476                    } else {
477                        mSelectedChip.onClick(this, offset, x, y);
478                    }
479                }
480                chipWasSelected = true;
481            }
482        }
483        if (action == MotionEvent.ACTION_UP && !chipWasSelected) {
484            clearSelectedChip();
485        }
486        return handled;
487    }
488
489    // TODO: This algorithm will need a lot of tweaking after more people have used
490    // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring
491    // what comes before the finger.
492    private int putOffsetInRange(int o) {
493        int offset = o;
494        Editable text = getText();
495        int length = text.length();
496        // Remove whitespace from end to find "real end"
497        int realLength = length;
498        for (int i = length - 1; i >= 0; i--) {
499            if (text.charAt(i) == ' ') {
500                realLength--;
501            } else {
502                break;
503            }
504        }
505
506        // If the offset is beyond or at the end of the text,
507        // leave it alone.
508        if (offset >= realLength) {
509            return offset;
510        }
511        Editable editable = getText();
512        while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) {
513            // Keep walking backward!
514            offset--;
515        }
516        return offset;
517    }
518
519    private int findText(Editable text, int offset) {
520        if (text.charAt(offset) != ' ') {
521            return offset;
522        }
523        return -1;
524    }
525
526    private RecipientChip findChip(int offset) {
527        RecipientChip[] chips = getSpannable().getSpans(0, getText().length(), RecipientChip.class);
528        // Find the chip that contains this offset.
529        for (int i = 0; i < chips.length; i++) {
530            RecipientChip chip = chips[i];
531            if (chip.matchesChip(offset)) {
532                return chip;
533            }
534        }
535        return null;
536    }
537
538    private CharSequence createChip(RecipientEntry entry) {
539        CharSequence displayText = mTokenizer.terminateToken(entry.getDestination());
540        // Always leave a blank space at the end of a chip.
541        int textLength = displayText.length();
542        if (displayText.charAt(textLength - 1) == ' ') {
543            textLength--;
544        } else {
545            displayText = displayText.toString().concat(" ");
546            textLength = displayText.length();
547        }
548        SpannableString chipText = new SpannableString(displayText);
549        int end = getSelectionEnd();
550        int start = mTokenizer.findTokenStart(getText(), end);
551        try {
552            chipText.setSpan(constructChipSpan(entry, start, false), 0, textLength,
553                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
554        } catch (NullPointerException e) {
555            Log.e(TAG, e.getMessage(), e);
556            return null;
557        }
558
559        return chipText;
560    }
561
562    @Override
563    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
564        submitItemAtPosition(position);
565    }
566
567    private void submitItemAtPosition(int position) {
568        RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position);
569        clearComposingText();
570
571        int end = getSelectionEnd();
572        int start = mTokenizer.findTokenStart(getText(), end);
573
574        Editable editable = getText();
575        editable.replace(start, end, createChip(entry));
576        QwertyKeyListener.markAsReplaced(editable, start, end, "");
577    }
578
579    /** Returns a collection of contact Id for each chip inside this View. */
580    /* package */ Collection<Long> getContactIds() {
581        final Set<Long> result = new HashSet<Long>();
582        for (RecipientChip chip : mRecipients) {
583            result.add(chip.getContactId());
584        }
585        return result;
586    }
587
588    /** Returns a collection of data Id for each chip inside this View. May be null. */
589    /* package */ Collection<Long> getDataIds() {
590        final Set<Long> result = new HashSet<Long>();
591        for (RecipientChip chip : mRecipients) {
592            result.add(chip.getDataId());
593        }
594        return result;
595    }
596
597
598    @Override
599    public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
600        return false;
601    }
602
603    @Override
604    public void onDestroyActionMode(ActionMode mode) {
605    }
606
607    @Override
608    public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
609        return false;
610    }
611
612    // Prevent selection of chips.
613    @Override
614    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
615        return false;
616    }
617
618    // The more chip is text that replaces any chips that do not fit in the pre-defined
619    // available space when the RecipientEditTextView loses focus and is drawn in a
620    // collapsed fashion.
621    private ImageSpan createMoreChip() {
622        if (mRecipients == null || mRecipients.size() <= CHIP_LIMIT) {
623            return null;
624        }
625        int numRecipients = mRecipients.size();
626        int overage = numRecipients - CHIP_LIMIT;
627        Editable text = getText();
628        // TODO: get the correct size from visual design.
629        int width = (int) Math.floor(getWidth() * MORE_WIDTH_FACTOR);
630        int height = getLineHeight();
631        Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
632        Canvas canvas = new Canvas(drawable);
633        String moreText = getResources().getString(mMoreString, overage);
634        canvas.drawText(moreText, 0, moreText.length(), 0, height - getLayout().getLineDescent(0),
635                getPaint());
636
637        Drawable result = new BitmapDrawable(getResources(), drawable);
638        result.setBounds(0, 0, width, height);
639        ImageSpan moreSpan = new ImageSpan(result);
640        Spannable spannable = getSpannable();
641        // Remove the overage chips.
642        RecipientChip[] chips = spannable.getSpans(0, text.length(), RecipientChip.class);
643        if (chips == null || chips.length == 0) {
644            Log.w(TAG,
645                "We have recipients. Tt should not be possible to have zero RecipientChips.");
646            return null;
647        }
648        mRemovedSpans = new ArrayList<RecipientChip>();
649        int totalReplaceStart = 0;
650        int totalReplaceEnd = 0;
651        for (int i = numRecipients - overage; i < chips.length; i++) {
652            mRemovedSpans.add(chips[i]);
653            spannable.removeSpan(chips[i]);
654        }
655        totalReplaceEnd = chips[chips.length - 1].getChipEnd();
656        totalReplaceStart = chips[numRecipients - overage].getChipStart();
657        for (int i = chips.length - 1; i >= numRecipients - overage; i--) {
658            mRecipients.remove(i);
659        }
660        SpannableString chipText = new SpannableString(text.subSequence(totalReplaceStart,
661                totalReplaceEnd));
662        chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
663        text.replace(totalReplaceStart, totalReplaceEnd, chipText);
664        return moreSpan;
665    }
666
667    // Replace the more chip, if it exists, with all of the recipient chips it had
668    // replaced when the RecipientEditTextView gains focus.
669    private void removeMoreChip() {
670        if (mMoreChip != null) {
671            Spannable span = getSpannable();
672            span.removeSpan(mMoreChip);
673            mMoreChip = null;
674            // Re-add the spans that were removed.
675            if (mRemovedSpans != null && mRemovedSpans.size() > 0) {
676                // Recreate each removed span.
677                Editable editable = getText();
678                SpannableString associatedText;
679                for (RecipientChip chip : mRemovedSpans) {
680                    int chipStart = chip.getChipStart();
681                    int chipEnd = chip.getChipEnd();
682                    associatedText = new SpannableString(editable.subSequence(chipStart, chipEnd));
683                    associatedText.setSpan(chip, 0, associatedText.length(),
684                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
685                    editable.replace(chipStart, chipEnd, associatedText);
686                    mRecipients.add(chip);
687                }
688                mRemovedSpans.clear();
689            }
690        }
691    }
692
693    /**
694     * RecipientChip defines an ImageSpan that contains information relevant to
695     * a particular recipient.
696     */
697    public class RecipientChip extends ImageSpan implements OnItemClickListener {
698        private final CharSequence mDisplay;
699
700        private final CharSequence mValue;
701
702        private final int mOffset;
703
704        private ListPopupWindow mPopup;
705
706        private View mAnchorView;
707
708        private int mLeft;
709
710        private final long mContactId;
711
712        private final long mDataId;
713
714        private RecipientEntry mEntry;
715
716        private boolean mSelected = false;
717
718        private RecipientAlternatesAdapter mAlternatesAdapter;
719
720        private Rect mBounds;
721
722        private int mStart = -1;
723        private int mEnd = -1;
724
725        public RecipientChip(Drawable drawable, RecipientEntry entry, int offset, Rect bounds) {
726            super(drawable);
727            mDisplay = entry.getDisplayName();
728            mValue = entry.getDestination();
729            mContactId = entry.getContactId();
730            mDataId = entry.getDataId();
731            mOffset = offset;
732            mEntry = entry;
733            mBounds = bounds;
734
735            mAnchorView = new View(getContext());
736            mAnchorView.setLeft(bounds.left);
737            mAnchorView.setRight(bounds.left);
738            mAnchorView.setTop(bounds.bottom);
739            mAnchorView.setBottom(bounds.bottom);
740            mAnchorView.setVisibility(View.GONE);
741            mRecipients.add(this);
742            mStart = offset;
743            // Add +1 for comma (?)
744            mEnd = offset + mValue.length() + 1;
745        }
746
747        public void unselectChip() {
748            if (getChipStart() == -1 || getChipEnd() == -1) {
749                mSelectedChip = null;
750                return;
751            }
752            clearComposingText();
753            RecipientChip newChipSpan = null;
754            try {
755                newChipSpan = constructChipSpan(mEntry, mOffset, false);
756            } catch (NullPointerException e) {
757                Log.e(TAG, e.getMessage(), e);
758                return;
759            }
760            replace(newChipSpan);
761            if (mPopup != null && mPopup.isShowing()) {
762                mPopup.dismiss();
763            }
764            return;
765        }
766
767        public void onKeyDown(int keyCode, KeyEvent event) {
768            if (keyCode == KeyEvent.KEYCODE_DEL) {
769                if (mPopup != null && mPopup.isShowing()) {
770                    mPopup.dismiss();
771                }
772                removeChip();
773            }
774        }
775
776        public boolean isCompletedContact() {
777            return mContactId != -1;
778        }
779
780        private void replace(RecipientChip newChip) {
781            Spannable spannable = getSpannable();
782            int spanStart = getChipStart();
783            int spanEnd = getChipEnd();
784            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
785            spannable.removeSpan(this);
786            mRecipients.remove(this);
787            spannable.setSpan(newChip, spanStart, spanEnd, 0);
788        }
789
790        public void removeChip() {
791            Spannable spannable = getSpannable();
792            int spanStart = getChipStart();
793            int spanEnd = getChipEnd();
794            Editable text = getText();
795            int toDelete = spanEnd;
796            // Always remove trailing spaces when removing a chip.
797            while (toDelete >= 0 && toDelete < text.length() - 1 && text.charAt(toDelete) == ' ') {
798                toDelete++;
799            }
800            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
801            spannable.removeSpan(this);
802            mRecipients.remove(this);
803            spannable.setSpan(null, spanStart, spanEnd, 0);
804            text.delete(spanStart, toDelete);
805            if (this == mSelectedChip) {
806                mSelectedChip = null;
807                clearSelectedChip();
808            }
809        }
810
811        public int getChipStart() {
812            return mStart;
813        }
814
815        public int getChipEnd() {
816            return mEnd;
817        }
818
819        public void replaceChip(RecipientEntry entry) {
820            clearComposingText();
821
822            RecipientChip newChipSpan = null;
823            try {
824                newChipSpan = constructChipSpan(entry, mOffset, false);
825            } catch (NullPointerException e) {
826                Log.e(TAG, e.getMessage(), e);
827                return;
828            }
829            replace(newChipSpan);
830            if (mPopup != null && mPopup.isShowing()) {
831                mPopup.dismiss();
832            }
833        }
834
835        public RecipientChip selectChip() {
836            clearComposingText();
837            RecipientChip newChipSpan = null;
838            if (isCompletedContact()) {
839                try {
840                    newChipSpan = constructChipSpan(mEntry, mOffset, true);
841                    newChipSpan.setSelected(true);
842                } catch (NullPointerException e) {
843                    Log.e(TAG, e.getMessage(), e);
844                    return newChipSpan;
845                }
846                replace(newChipSpan);
847                if (mPopup != null && mPopup.isShowing()) {
848                    mPopup.dismiss();
849                }
850                mSelected = true;
851                // Make sure we call edit on the new chip span.
852                newChipSpan.showAlternates();
853                setCursorVisible(false);
854            } else {
855                CharSequence text = getValue();
856                removeChip();
857                Editable editable = getText();
858                editable.append(text);
859                setCursorVisible(true);
860                setSelection(editable.length());
861            }
862            return newChipSpan;
863        }
864
865        private void showAlternates() {
866            mPopup = new ListPopupWindow(RecipientEditTextView.this.getContext());
867
868            if (!mPopup.isShowing()) {
869                mAlternatesAdapter = new RecipientAlternatesAdapter(
870                        RecipientEditTextView.this.getContext(),
871                        mEntry.getContactId(), mEntry.getDataId(),
872                        mAlternatesLayout, mAlternatesSelectedLayout);
873                mAnchorView.setLeft(mLeft);
874                mAnchorView.setRight(mLeft);
875                mPopup.setAnchorView(mAnchorView);
876                mPopup.setAdapter(mAlternatesAdapter);
877                mPopup.setWidth(getWidth());
878                mPopup.setOnItemClickListener(this);
879                mPopup.show();
880            }
881        }
882
883        private void setSelected(boolean selected) {
884            mSelected = selected;
885        }
886
887        public CharSequence getDisplay() {
888            return mDisplay;
889        }
890
891        public CharSequence getValue() {
892            return mValue;
893        }
894
895        private boolean isInDelete(int offset, float x, float y) {
896            // Figure out the bounds of this chip and whether or not
897            // the user clicked in the X portion.
898            return mSelected
899                    && (offset == getChipEnd()
900                            || (x > (mBounds.right - mChipDeleteWidth) && x < mBounds.right));
901        }
902
903        public boolean matchesChip(int offset) {
904            int start = getChipStart();
905            int end = getChipEnd();
906            return (offset >= start && offset <= end);
907        }
908
909        public void onClick(View widget, int offset, float x, float y) {
910            if (mSelected) {
911                if (isInDelete(offset, x, y)) {
912                    removeChip();
913                } else {
914                    clearSelectedChip();
915                }
916            }
917        }
918
919        @Override
920        public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top,
921                int y, int bottom, Paint paint) {
922            // Shift the bounds of this span to where it is actually drawn on the screeen.
923            mLeft = (int) x;
924            super.draw(canvas, text, start, end, x, top, y, bottom, paint);
925        }
926
927        @Override
928        public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) {
929            mPopup.dismiss();
930            clearComposingText();
931            replaceChip(mAlternatesAdapter.getRecipientEntry(position));
932        }
933
934        public long getContactId() {
935            return mContactId;
936        }
937
938        public long getDataId() {
939            return mDataId;
940        }
941    }
942}
943
944