RecipientEditTextView.java revision cd61195b9be5614aefc4cda76c1732cc4840b18e
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.Canvas;
22import android.graphics.Paint;
23import android.graphics.Rect;
24import android.graphics.drawable.BitmapDrawable;
25import android.graphics.drawable.Drawable;
26import android.os.Handler;
27import android.text.Editable;
28import android.text.Layout;
29import android.text.Spannable;
30import android.text.SpannableString;
31import android.text.Spanned;
32import android.text.TextPaint;
33import android.text.TextUtils;
34import android.text.method.QwertyKeyListener;
35import android.text.style.ImageSpan;
36import android.util.AttributeSet;
37import android.util.Log;
38import android.view.KeyEvent;
39import android.view.MotionEvent;
40import android.view.View;
41import android.widget.AdapterView;
42import android.widget.AdapterView.OnItemClickListener;
43import android.widget.ListPopupWindow;
44import android.widget.MultiAutoCompleteTextView;
45import android.widget.PopupWindow.OnDismissListener;
46
47import java.util.Collection;
48import java.util.HashSet;
49import java.util.Set;
50
51import java.util.ArrayList;
52
53/**
54 * RecipientEditTextView is an auto complete text view for use with applications
55 * that use the new Chips UI for addressing a message to recipients.
56 */
57public class RecipientEditTextView extends MultiAutoCompleteTextView
58    implements OnItemClickListener {
59
60    private static final String TAG = "RecipientEditTextView";
61
62    private Drawable mChipBackground = null;
63
64    private Drawable mChipDelete = null;
65
66    private int mChipPadding;
67
68    private Tokenizer mTokenizer;
69
70    private final Handler mHandler;
71
72    private Runnable mDelayedSelectionMode = new Runnable() {
73        @Override
74        public void run() {
75            setSelection(getText().length());
76        }
77    };
78
79    private Drawable mChipBackgroundPressed;
80
81    private RecipientChip mSelectedChip;
82
83    private int mChipDeleteWidth;
84
85    private ArrayList<RecipientChip> mRecipients;
86
87    private int mAlternatesLayout;
88
89    private int mAlternatesSelectedLayout;
90
91    public RecipientEditTextView(Context context, AttributeSet attrs) {
92        super(context, attrs);
93        mHandler = new Handler();
94        setOnItemClickListener(this);
95        mRecipients = new ArrayList<RecipientChip>();
96    }
97
98    public RecipientChip constructChipSpan(RecipientEntry contact, int offset, boolean pressed)
99        throws NullPointerException {
100        if (mChipBackground == null) {
101            throw new NullPointerException
102                ("Unable to render any chips as setChipDimensions was not called.");
103        }
104        String text = contact.getDisplayName();
105        Layout layout = getLayout();
106        int line = layout.getLineForOffset(offset);
107        int lineTop = layout.getLineTop(line);
108
109        TextPaint paint = getPaint();
110        float defaultSize = paint.getTextSize();
111
112        // Reduce the size of the text slightly so that we can get the "look" of
113        // padding.
114        paint.setTextSize((float) (paint.getTextSize() * .9));
115
116        // Ellipsize the text so that it takes AT MOST the entire width of the
117        // autocomplete text entry area. Make sure to leave space for padding
118        // on the sides.
119        CharSequence ellipsizedText = TextUtils.ellipsize(text, paint,
120                calculateAvailableWidth(pressed), TextUtils.TruncateAt.END);
121
122        int height = getLineHeight();
123        int width = (int) Math.floor(paint.measureText(ellipsizedText, 0, ellipsizedText.length()))
124                + (mChipPadding * 2);
125        if (pressed) {
126            width += mChipDeleteWidth;
127        }
128
129        // Create the background of the chip.
130        Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
131        Canvas canvas = new Canvas(tmpBitmap);
132        if (pressed) {
133            if (mChipBackgroundPressed != null) {
134                mChipBackgroundPressed.setBounds(0, 0, width, height);
135                mChipBackgroundPressed.draw(canvas);
136
137                // Align the display text with where the user enters text.
138                canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height
139                        - layout.getLineDescent(line), paint);
140                mChipDelete.setBounds(width - mChipDeleteWidth, 0, width, height);
141                mChipDelete.draw(canvas);
142            } else {
143                Log.w(TAG,
144                        "Unable to draw a background for the chips as it was never set");
145            }
146        } else {
147            if (mChipBackground != null) {
148                mChipBackground.setBounds(0, 0, width, height);
149                mChipBackground.draw(canvas);
150
151                // Align the display text with where the user enters text.
152                canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height
153                        - layout.getLineDescent(line), paint);
154            } else {
155                Log.w(TAG,
156                        "Unable to draw a background for the chips as it was never set");
157            }
158        }
159
160
161        // Get the location of the widget so we can properly offset
162        // the anchor for each chip.
163        int[] xy = new int[2];
164        getLocationOnScreen(xy);
165        // Pass the full text, un-ellipsized, to the chip.
166        Drawable result = new BitmapDrawable(getResources(), tmpBitmap);
167        result.setBounds(0, 0, width, height);
168        Rect bounds = new Rect(xy[0] + offset, xy[1] + lineTop, xy[0] + width,
169                calculateLineBottom(xy[1], line));
170        RecipientChip recipientChip = new RecipientChip(result, contact, offset, bounds);
171
172        // Return text to the original size.
173        paint.setTextSize(defaultSize);
174
175        return recipientChip;
176    }
177
178    // The bottom of the line the chip will be located on is calculated by 4 factors:
179    // 1) which line the chip appears on
180    // 2) the height of a line in the autocomplete view
181    // 3) padding built into the edit text view will move the bottom position
182    // 4) the position of the autocomplete view on the screen, taking into account
183    // that any top padding will move this down visually
184    private int calculateLineBottom(int yOffset, int line) {
185        int bottomPadding = 0;
186        if (line == getLineCount() - 1) {
187            bottomPadding += getPaddingBottom();
188        }
189        return ((line + 1) * getLineHeight()) + (yOffset + getPaddingTop()) + bottomPadding;
190    }
191
192    // Get the max amount of space a chip can take up. The formula takes into
193    // account the width of the EditTextView, any view padding, and padding
194    // that will be added to the chip.
195    private float calculateAvailableWidth(boolean pressed) {
196        int paddingRight = 0;
197        if (pressed) {
198            paddingRight = mChipDeleteWidth;
199        }
200        return getWidth() - getPaddingLeft() - getPaddingRight() - (mChipPadding * 2)
201                - paddingRight;
202    }
203
204    /**
205     * Set all chip dimensions and resources. This has to be done from the application
206     * as this is a static library.
207     * @param chipBackground drawable
208     * @param padding Padding around the text in a chip
209     * @param offset Offset between the chip and the dropdown of alternates
210     */
211    public void setChipDimensions(Drawable chipBackground, Drawable chipBackgroundPressed,
212            Drawable chipDelete, int alternatesLayout, int alternatesSelectedLayout, float padding) {
213        mChipBackground = chipBackground;
214        mChipBackgroundPressed = chipBackgroundPressed;
215        mChipDelete = chipDelete;
216        mChipDeleteWidth = chipDelete.getIntrinsicWidth();
217        mChipPadding = (int) padding;
218        mAlternatesLayout = alternatesLayout;
219        mAlternatesSelectedLayout = alternatesSelectedLayout;
220    }
221
222    @Override
223    public void setTokenizer(Tokenizer tokenizer) {
224        mTokenizer = tokenizer;
225        super.setTokenizer(mTokenizer);
226    }
227
228    // We want to handle replacing text in the onItemClickListener
229    // so we can get all the associated contact information including
230    // display text, address, and id.
231    @Override
232    protected void replaceText(CharSequence text) {
233        return;
234    }
235
236    @Override
237    public boolean onKeyUp(int keyCode, KeyEvent event) {
238        switch (keyCode) {
239            case KeyEvent.KEYCODE_ENTER:
240            case KeyEvent.KEYCODE_DPAD_CENTER:
241            case KeyEvent.KEYCODE_TAB:
242                if (event.hasNoModifiers()) {
243                    if (isPopupShowing()) {
244                        // choose the first entry.
245                        submitItemAtPosition(0);
246                        dismissDropDown();
247                        return true;
248                    } else {
249                        int end = getSelectionEnd();
250                        int start = mTokenizer.findTokenStart(getText(), end);
251                        String text = getText().toString().substring(start, end);
252                        clearComposingText();
253
254                        Editable editable = getText();
255                        RecipientEntry entry = RecipientEntry.constructFakeEntry(text);
256                        QwertyKeyListener.markAsReplaced(editable, start, end, "");
257                        editable.replace(start, end, createChip(entry));
258                        dismissDropDown();
259                    }
260                }
261        }
262        return super.onKeyUp(keyCode, event);
263    }
264
265    public void onChipChanged() {
266        // Must be posted so that the previous span
267        // is correctly replaced with the previous selection points.
268        mHandler.post(mDelayedSelectionMode);
269    }
270
271    @Override
272    public boolean onKeyDown(int keyCode, KeyEvent event) {
273
274        if (mSelectedChip != null) {
275            mSelectedChip.onKeyDown(keyCode, event);
276        }
277
278        if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) {
279            return true;
280        }
281
282        return super.onKeyDown(keyCode, event);
283    }
284
285    private Spannable getSpannable() {
286        return (Spannable) getText();
287    }
288
289    /**
290     * Instead of filtering on the entire contents of the edit box,
291     * this subclass method filters on the range from
292     * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd}
293     * if the length of that range meets or exceeds {@link #getThreshold}
294     * and makes sure that the range is not already a Chip.
295     */
296    @Override
297    protected void performFiltering(CharSequence text, int keyCode) {
298        if (enoughToFilter()) {
299            int end = getSelectionEnd();
300            int start = mTokenizer.findTokenStart(text, end);
301            // If this is a RecipientChip, don't filter
302            // on its contents.
303            Spannable span = getSpannable();
304            RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
305            if (chips != null && chips.length > 0) {
306                return;
307            }
308        }
309        super.performFiltering(text, keyCode);
310    }
311
312    private void clearSelectedChip() {
313        if (mSelectedChip != null) {
314            mSelectedChip.unselectChip();
315            mSelectedChip = null;
316        }
317    }
318
319    @Override
320    public boolean onTouchEvent(MotionEvent event) {
321        int action = event.getAction();
322        boolean handled = super.onTouchEvent(event);
323        boolean chipWasSelected = false;
324
325        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
326            float x = event.getX();
327            float y = event.getY();
328            int offset = putOffsetInRange(getOffsetForPosition(x, y));
329            RecipientChip currentChip = findChip(offset);
330            if (currentChip != null) {
331                if (action == MotionEvent.ACTION_UP) {
332                    if (mSelectedChip != null && mSelectedChip != currentChip) {
333                        clearSelectedChip();
334                        mSelectedChip = currentChip.selectChip();
335                    } else if (mSelectedChip == null) {
336                        mSelectedChip = currentChip.selectChip();
337                    } else {
338                        mSelectedChip.onClick(this, offset, x, y);
339                    }
340                }
341                chipWasSelected = true;
342            }
343        }
344        if (action == MotionEvent.ACTION_UP && !chipWasSelected) {
345            clearSelectedChip();
346        }
347        return handled;
348    }
349
350    // TODO: This algorithm will need a lot of tweaking after more people have used
351    // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring
352    // what comes before the finger.
353    private int putOffsetInRange(int o) {
354        int offset = o;
355        Editable text = getText();
356        int length = text.length();
357        // Remove whitespace from end to find "real end"
358        int realLength = length;
359        for (int i = length - 1; i >= 0; i--) {
360            if (text.charAt(i) == ' ') {
361                realLength--;
362            } else {
363                break;
364            }
365        }
366
367        // If the offset is beyond where there was any visible text,
368        // then leave it should not be pulled into the range of a chip.
369        if (offset > realLength) {
370            return offset;
371        }
372        while (offset >= 0 && findChip(offset) == null) {
373            // Keep walking backward!
374            offset--;
375        }
376        return offset;
377    }
378
379    private RecipientChip findChip(int offset) {
380        RecipientChip[] chips = getSpannable().getSpans(0, offset, RecipientChip.class);
381        // Find the chip that contains this offset.
382        for (int i = 0; i < chips.length; i++) {
383            RecipientChip chip = chips[i];
384            if (chip.matchesChip(offset)) {
385                return chip;
386            }
387        }
388        return null;
389    }
390
391    private CharSequence createChip(RecipientEntry entry) {
392        CharSequence displayText = mTokenizer.terminateToken(entry.getDestination());
393        // Always leave a blank space at the end of a chip.
394        int textLength = displayText.length();
395        if (displayText.charAt(textLength - 1) == ' ') {
396            textLength--;
397        } else {
398            displayText = displayText.toString().concat(" ");
399            textLength = displayText.length();
400        }
401        SpannableString chipText = new SpannableString(displayText);
402        int end = getSelectionEnd();
403        int start = mTokenizer.findTokenStart(getText(), end);
404        try {
405            chipText.setSpan(constructChipSpan(entry, start, false), 0, textLength,
406                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
407        } catch (NullPointerException e) {
408            Log.e(TAG, e.getMessage(), e);
409            return null;
410        }
411
412        return chipText;
413    }
414
415    @Override
416    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
417        submitItemAtPosition(position);
418    }
419
420    private void submitItemAtPosition(int position) {
421        RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position);
422        clearComposingText();
423
424        int end = getSelectionEnd();
425        int start = mTokenizer.findTokenStart(getText(), end);
426
427        Editable editable = getText();
428        editable.replace(start, end, createChip(entry));
429        QwertyKeyListener.markAsReplaced(editable, start, end, "");
430    }
431
432    /** Returns a collection of contact Id for each chip inside this View. */
433    /* package */ Collection<Long> getContactIds() {
434        final Set<Long> result = new HashSet<Long>();
435        for (RecipientChip chip : mRecipients) {
436            result.add(chip.getContactId());
437        }
438        return result;
439    }
440
441    /** Returns a collection of data Id for each chip inside this View. May be null. */
442    /* package */ Collection<Long> getDataIds() {
443        final Set<Long> result = new HashSet<Long>();
444        for (RecipientChip chip : mRecipients) {
445            result.add(chip.getDataId());
446        }
447        return result;
448    }
449
450    /**
451     * RecipientChip defines an ImageSpan that contains information relevant to
452     * a particular recipient.
453     */
454    public class RecipientChip extends ImageSpan implements OnItemClickListener, OnDismissListener {
455        private final CharSequence mDisplay;
456
457        private final CharSequence mValue;
458
459        private final int mOffset;
460
461        private ListPopupWindow mPopup;
462
463        private View mAnchorView;
464
465        private int mLeft;
466
467        private final long mContactId;
468
469        private final long mDataId;
470
471        private RecipientEntry mEntry;
472
473        private boolean mSelected = false;
474
475        private RecipientAlternatesAdapter mAlternatesAdapter;
476
477        private Rect mBounds;
478
479        public RecipientChip(Drawable drawable, RecipientEntry entry, int offset, Rect bounds) {
480            super(drawable);
481            mDisplay = entry.getDisplayName();
482            mValue = entry.getDestination();
483            mContactId = entry.getContactId();
484            mDataId = entry.getDataId();
485            mOffset = offset;
486            mEntry = entry;
487            mBounds = bounds;
488
489            mAnchorView = new View(getContext());
490            mAnchorView.setLeft(bounds.left);
491            mAnchorView.setRight(bounds.left);
492            mAnchorView.setTop(bounds.bottom);
493            mAnchorView.setBottom(bounds.bottom);
494            mAnchorView.setVisibility(View.GONE);
495            mRecipients.add(this);
496        }
497
498        public void unselectChip() {
499            if (getChipStart() == -1 || getChipEnd() == -1) {
500                mSelectedChip = null;
501                return;
502            }
503            clearComposingText();
504            RecipientChip newChipSpan = null;
505            try {
506                newChipSpan = constructChipSpan(mEntry, mOffset, false);
507            } catch (NullPointerException e) {
508                Log.e(TAG, e.getMessage(), e);
509                return;
510            }
511            replace(newChipSpan);
512            if (mPopup != null && mPopup.isShowing()) {
513                mPopup.dismiss();
514            }
515            return;
516        }
517
518        public void onKeyDown(int keyCode, KeyEvent event) {
519            if (keyCode == KeyEvent.KEYCODE_DEL) {
520                if (mPopup != null && mPopup.isShowing()) {
521                    mPopup.dismiss();
522                }
523                removeChip();
524            }
525        }
526
527        public boolean isCompletedContact() {
528            return mContactId != -1;
529        }
530
531        private void replace(RecipientChip newChip) {
532            Spannable spannable = getSpannable();
533            int spanStart = getChipStart();
534            int spanEnd = getChipEnd();
535            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
536            spannable.removeSpan(this);
537            mRecipients.remove(this);
538            spannable.setSpan(newChip, spanStart, spanEnd, 0);
539        }
540
541        public void removeChip() {
542            Spannable spannable = getSpannable();
543            int spanStart = getChipStart();
544            int spanEnd = getChipEnd();
545            if (this == mSelectedChip) {
546                mSelectedChip = null;
547            }
548            Editable text = getText();
549            int toDelete = spanEnd;
550            // Always remove trailing spaces when removing a chip.
551            while (toDelete < text.length() - 1 && text.charAt(toDelete) == ' ') {
552                toDelete++;
553            }
554            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
555            spannable.removeSpan(this);
556            mRecipients.remove(this);
557            spannable.setSpan(null, spanStart, spanEnd, 0);
558            text.delete(spanStart, toDelete);
559        }
560
561        public int getChipStart() {
562            return getSpannable().getSpanStart(this);
563        }
564
565        public int getChipEnd() {
566            return getSpannable().getSpanEnd(this);
567        }
568
569        public void replaceChip(RecipientEntry entry) {
570            clearComposingText();
571
572            RecipientChip newChipSpan = null;
573            try {
574                newChipSpan = constructChipSpan(entry, mOffset, false);
575            } catch (NullPointerException e) {
576                Log.e(TAG, e.getMessage(), e);
577                return;
578            }
579            replace(newChipSpan);
580            if (mPopup != null && mPopup.isShowing()) {
581                mPopup.dismiss();
582            }
583            onChipChanged();
584        }
585
586        public RecipientChip selectChip() {
587            clearComposingText();
588            RecipientChip newChipSpan = null;
589            if (isCompletedContact()) {
590                try {
591                    newChipSpan = constructChipSpan(mEntry, mOffset, true);
592                    newChipSpan.setSelected(true);
593                } catch (NullPointerException e) {
594                    Log.e(TAG, e.getMessage(), e);
595                    return newChipSpan;
596                }
597                replace(newChipSpan);
598                if (mPopup != null && mPopup.isShowing()) {
599                    mPopup.dismiss();
600                }
601                mSelected = true;
602                // Make sure we call edit on the new chip span.
603                newChipSpan.showAlternates();
604            } else {
605                CharSequence text = getValue();
606                removeChip();
607                Editable editable = getText();
608                setSelection(editable.length());
609                editable.append(text);
610            }
611            return newChipSpan;
612        }
613
614        private void showAlternates() {
615            mPopup = new ListPopupWindow(RecipientEditTextView.this.getContext());
616
617            if (!mPopup.isShowing()) {
618                mAlternatesAdapter = new RecipientAlternatesAdapter(
619                        RecipientEditTextView.this.getContext(),
620                        mEntry.getContactId(), mEntry.getDataId(),
621                        mAlternatesLayout, mAlternatesSelectedLayout);
622                mAnchorView.setLeft(mLeft);
623                mAnchorView.setRight(mLeft);
624                mPopup.setAnchorView(mAnchorView);
625                mPopup.setAdapter(mAlternatesAdapter);
626                mPopup.setWidth(getWidth());
627                mPopup.setOnItemClickListener(this);
628                mPopup.setOnDismissListener(this);
629                mPopup.show();
630            }
631        }
632
633        private void setSelected(boolean selected) {
634            mSelected = selected;
635        }
636
637        public CharSequence getDisplay() {
638            return mDisplay;
639        }
640
641        public CharSequence getValue() {
642            return mValue;
643        }
644
645        private boolean isInDelete(int offset, float x, float y) {
646            // Figure out the bounds of this chip and whether or not
647            // the user clicked in the X portion.
648            return mSelected
649                    && (offset == getChipEnd()
650                            || (x > (mBounds.right - mChipDeleteWidth) && x < mBounds.right));
651        }
652
653        public boolean matchesChip(int offset) {
654            int start = getChipStart();
655            int end = getChipEnd();
656            return (offset >= start && offset <= end);
657        }
658
659        public void onClick(View widget, int offset, float x, float y) {
660            if (mSelected) {
661                if (isInDelete(offset, x, y)) {
662                    removeChip();
663                    return;
664                }
665            }
666        }
667
668        @Override
669        public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top,
670                int y, int bottom, Paint paint) {
671            // Shift the bounds of this span to where it is actually drawn on the screeen.
672            mLeft = (int) x;
673            super.draw(canvas, text, start, end, x, top, y, bottom, paint);
674        }
675
676        @Override
677        public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) {
678            mPopup.dismiss();
679            clearComposingText();
680            replaceChip(mAlternatesAdapter.getRecipientEntry(position));
681        }
682
683        // When the popup dialog is dismissed, return the cursor to the end.
684        @Override
685        public void onDismiss() {
686            mHandler.post(mDelayedSelectionMode);
687        }
688
689        public long getContactId() {
690            return mContactId;
691        }
692
693        public long getDataId() {
694            return mDataId;
695        }
696    }
697}
698
699