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