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