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