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