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