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