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