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