RecipientEditTextView.java revision 9ff623bbf3b5bc790e40dd51ebbb13fc99fbf875
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.Filter.FilterListener;
44import android.widget.PopupWindow.OnDismissListener;
45import android.widget.ListPopupWindow;
46import android.widget.MultiAutoCompleteTextView;
47
48import java.util.ArrayList;
49
50/**
51 * RecipientEditTextView is an auto complete text view for use with applications
52 * that use the new Chips UI for addressing a message to recipients.
53 */
54public class RecipientEditTextView extends MultiAutoCompleteTextView
55    implements OnItemClickListener {
56
57    private static final String TAG = "RecipientEditTextView";
58
59    private Drawable mChipBackground = null;
60
61    private Drawable mChipDelete = null;
62
63    private int mChipPadding;
64
65    private Tokenizer mTokenizer;
66
67    private final Handler mHandler;
68
69    private Runnable mDelayedSelectionMode = new Runnable() {
70        @Override
71        public void run() {
72            setSelection(getText().length());
73        }
74    };
75
76    private Drawable mChipBackgroundPressed;
77
78    private RecipientChip mSelectedChip;
79
80    private int mChipDeleteWidth;
81
82    private ArrayList<RecipientChip> mRecipients;
83
84    public RecipientEditTextView(Context context, AttributeSet attrs) {
85        super(context, attrs);
86        mHandler = new Handler();
87        setOnItemClickListener(this);
88        mRecipients = new ArrayList<RecipientChip>();
89    }
90
91    public RecipientChip constructChipSpan(RecipientEntry contact, int offset, boolean pressed)
92        throws NullPointerException {
93        if (mChipBackground == null) {
94            throw new NullPointerException
95                ("Unable to render any chips as setChipDimensions was not called.");
96        }
97        String text = contact.getDisplayName();
98        Layout layout = getLayout();
99        int line = layout.getLineForOffset(offset);
100        int lineTop = layout.getLineTop(line);
101
102        TextPaint paint = getPaint();
103        float defaultSize = paint.getTextSize();
104
105        // Reduce the size of the text slightly so that we can get the "look" of
106        // padding.
107        paint.setTextSize((float) (paint.getTextSize() * .9));
108
109        // Ellipsize the text so that it takes AT MOST the entire width of the
110        // autocomplete text entry area. Make sure to leave space for padding
111        // on the sides.
112        CharSequence ellipsizedText = TextUtils.ellipsize(text, paint,
113                calculateAvailableWidth(pressed), TextUtils.TruncateAt.END);
114
115        int height = getLineHeight();
116        int width = (int) Math.floor(paint.measureText(ellipsizedText, 0, ellipsizedText.length()))
117                + (mChipPadding * 2);
118        if (pressed) {
119            width += mChipDeleteWidth;
120        }
121
122        // Create the background of the chip.
123        Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
124        Canvas canvas = new Canvas(tmpBitmap);
125        if (pressed) {
126            if (mChipBackgroundPressed != null) {
127                mChipBackgroundPressed.setBounds(0, 0, width, height);
128                mChipBackgroundPressed.draw(canvas);
129
130                // Align the display text with where the user enters text.
131                canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height
132                        - layout.getLineDescent(line), paint);
133                mChipDelete.setBounds(width - mChipDeleteWidth, 0, width, height);
134                mChipDelete.draw(canvas);
135            } else {
136                Log.w(TAG,
137                        "Unable to draw a background for the chips as it was never set");
138            }
139        } else {
140            if (mChipBackground != null) {
141                mChipBackground.setBounds(0, 0, width, height);
142                mChipBackground.draw(canvas);
143
144                // Align the display text with where the user enters text.
145                canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height
146                        - layout.getLineDescent(line), paint);
147            } else {
148                Log.w(TAG,
149                        "Unable to draw a background for the chips as it was never set");
150            }
151        }
152
153
154        // Get the location of the widget so we can properly offset
155        // the anchor for each chip.
156        int[] xy = new int[2];
157        getLocationOnScreen(xy);
158        // Pass the full text, un-ellipsized, to the chip.
159        Drawable result = new BitmapDrawable(getResources(), tmpBitmap);
160        result.setBounds(0, 0, width, height);
161        Rect bounds = new Rect(xy[0] + offset, xy[1] + lineTop, xy[0] + width,
162                calculateLineBottom(xy[1], line));
163        RecipientChip recipientChip = new RecipientChip(result, contact, offset, bounds);
164
165        // Return text to the original size.
166        paint.setTextSize(defaultSize);
167
168        return recipientChip;
169    }
170
171    // The bottom of the line the chip will be located on is calculated by 4 factors:
172    // 1) which line the chip appears on
173    // 2) the height of a line in the autocomplete view
174    // 3) padding built into the edit text view will move the bottom position
175    // 4) the position of the autocomplete view on the screen, taking into account
176    // that any top padding will move this down visually
177    private int calculateLineBottom(int yOffset, int line) {
178        int bottomPadding = 0;
179        if (line == getLineCount() - 1) {
180            bottomPadding += getPaddingBottom();
181        }
182        return ((line + 1) * getLineHeight()) + (yOffset + getPaddingTop()) + bottomPadding;
183    }
184
185    // Get the max amount of space a chip can take up. The formula takes into
186    // account the width of the EditTextView, any view padding, and padding
187    // that will be added to the chip.
188    private float calculateAvailableWidth(boolean pressed) {
189        int paddingRight = 0;
190        if (pressed) {
191            paddingRight = mChipDeleteWidth;
192        }
193        return getWidth() - getPaddingLeft() - getPaddingRight() - (mChipPadding * 2)
194                - paddingRight;
195    }
196
197    /**
198     * Set all chip dimensions and resources. This has to be done from the application
199     * as this is a static library.
200     * @param chipBackground drawable
201     * @param padding Padding around the text in a chip
202     * @param offset Offset between the chip and the dropdown of alternates
203     */
204    public void setChipDimensions(Drawable chipBackground, Drawable chipBackgroundPressed,
205            Drawable chipDelete, float padding) {
206        mChipBackground = chipBackground;
207        mChipBackgroundPressed = chipBackgroundPressed;
208        mChipDelete = chipDelete;
209        mChipDeleteWidth = chipDelete.getIntrinsicWidth();
210        mChipPadding = (int) padding;
211    }
212
213    @Override
214    public void setTokenizer(Tokenizer tokenizer) {
215        mTokenizer = tokenizer;
216        super.setTokenizer(mTokenizer);
217    }
218
219    // We want to handle replacing text in the onItemClickListener
220    // so we can get all the associated contact information including
221    // display text, address, and id.
222    @Override
223    protected void replaceText(CharSequence text) {
224        return;
225    }
226
227    @Override
228    public boolean onKeyUp(int keyCode, KeyEvent event) {
229        switch (keyCode) {
230            case KeyEvent.KEYCODE_ENTER:
231            case KeyEvent.KEYCODE_DPAD_CENTER:
232            case KeyEvent.KEYCODE_TAB:
233                if (event.hasNoModifiers()) {
234                    if (isPopupShowing()) {
235                        // choose the first entry.
236                        submitItemAtPosition(0);
237                        dismissDropDown();
238                        return true;
239                    } else {
240                        int end = getSelectionEnd();
241                        int start = mTokenizer.findTokenStart(getText(), end);
242                        String text = getText().toString().substring(start, end);
243                        clearComposingText();
244
245                        Editable editable = getText();
246                        RecipientEntry entry = RecipientEntry.constructFakeEntry(text);
247                        QwertyKeyListener.markAsReplaced(editable, start, end, "");
248                        editable.replace(start, end, createChip(entry));
249                        dismissDropDown();
250                    }
251                }
252        }
253        return super.onKeyUp(keyCode, event);
254    }
255
256    public void onChipChanged() {
257        // Must be posted so that the previous span
258        // is correctly replaced with the previous selection points.
259        mHandler.post(mDelayedSelectionMode);
260    }
261
262    @Override
263    public boolean onKeyDown(int keyCode, KeyEvent event) {
264        int start = getSelectionStart();
265        int end = getSelectionEnd();
266        Spannable span = getSpannable();
267
268        RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
269        if (chips != null) {
270            for (RecipientChip chip : chips) {
271                chip.onKeyDown(keyCode, event);
272            }
273        }
274
275        if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) {
276            return true;
277        }
278
279        return super.onKeyDown(keyCode, event);
280    }
281
282    private Spannable getSpannable() {
283        return (Spannable) getText();
284    }
285
286
287    @Override
288    public boolean onTouchEvent(MotionEvent event) {
289        int action = event.getAction();
290        boolean handled = super.onTouchEvent(event);
291        boolean chipWasSelected = false;
292
293        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
294            Spannable span = getSpannable();
295            int offset = getOffsetForPosition(event.getX(), event.getY());
296            int end = span.nextSpanTransition(offset, span.length(), RecipientChip.class);
297            int start = mTokenizer.findTokenStart(span, end);
298            RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
299            if (chips != null && chips.length > 0) {
300                // Get the first chip that matched.
301                final RecipientChip currentChip = chips[0];
302
303                if (action == MotionEvent.ACTION_UP) {
304                    if (mSelectedChip != null && mSelectedChip != currentChip) {
305                        mSelectedChip.unselectChip();
306                        mSelectedChip = currentChip.selectChip();
307                    } else if (mSelectedChip == null) {
308                        mSelectedChip = currentChip.selectChip();
309                    } else {
310                        mSelectedChip.onClick(this, event.getX(), event.getY());
311                    }
312                }
313                chipWasSelected = true;
314            }
315        }
316        if (!chipWasSelected) {
317            if (mSelectedChip != null) {
318                mSelectedChip.unselectChip();
319                mSelectedChip = null;
320            }
321        }
322        return handled;
323    }
324
325    private CharSequence createChip(RecipientEntry entry) {
326        // We want to override the tokenizer behavior with our own ending
327        // token, space.
328        SpannableString chipText = new SpannableString(mTokenizer.terminateToken(entry
329                .getDisplayName()));
330        int end = getSelectionEnd();
331        int start = mTokenizer.findTokenStart(getText(), end);
332        try {
333            chipText.setSpan(constructChipSpan(entry, start, false), 0, entry.getDisplayName()
334                    .length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
335        } catch (NullPointerException e) {
336            Log.e(TAG, e.getMessage(), e);
337            return null;
338        }
339
340        return chipText;
341    }
342
343    @Override
344    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
345        submitItemAtPosition(position);
346    }
347
348    private void submitItemAtPosition(int position) {
349        RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position);
350        clearComposingText();
351
352        int end = getSelectionEnd();
353        int start = mTokenizer.findTokenStart(getText(), end);
354
355        Editable editable = getText();
356        editable.replace(start, end, createChip(entry));
357        QwertyKeyListener.markAsReplaced(editable, start, end, "");
358    }
359
360    public Editable getRecipients() {
361        StringBuilder plainText = new StringBuilder();
362        int size = mRecipients.size();
363        for (int i = 0; i < size; i++) {
364            plainText.append(mRecipients.get(i).getValue());
365                if (i != size-1) {
366                    plainText.append(',');
367                }
368        }
369        return Editable.Factory.getInstance().newEditable(plainText);
370    }
371
372    /**
373     * RecipientChip defines an ImageSpan that contains information relevant to
374     * a particular recipient.
375     */
376    public class RecipientChip extends ImageSpan implements OnItemClickListener, OnDismissListener,
377            FilterListener {
378        private final CharSequence mDisplay;
379
380        private final CharSequence mValue;
381
382        private final int mOffset;
383
384        private ListPopupWindow mPopup;
385
386        private View mAnchorView;
387
388        private int mLeft;
389
390        private int mId = -1;
391
392        private RecipientEntry mEntry;
393
394        private boolean mSelected = false;
395
396        private Rect mBounds;
397        public RecipientChip(Drawable drawable, RecipientEntry entry, int offset, Rect bounds) {
398            super(drawable);
399            mDisplay = entry.getDisplayName();
400            mValue = entry.getDestination();
401            mId = entry.getContactId();
402            mOffset = offset;
403            mEntry = entry;
404            mBounds = bounds;
405
406            mAnchorView = new View(getContext());
407            mAnchorView.setLeft(bounds.left);
408            mAnchorView.setRight(bounds.left);
409            mAnchorView.setTop(bounds.bottom);
410            mAnchorView.setBottom(bounds.bottom);
411            mAnchorView.setVisibility(View.GONE);
412            mRecipients.add(this);
413        }
414
415        public void unselectChip() {
416            if (getChipStart() == -1 || getChipEnd() == -1) {
417                mSelectedChip = null;
418                return;
419            }
420            clearComposingText();
421            RecipientChip newChipSpan = null;
422            try {
423                newChipSpan = constructChipSpan(mEntry, mOffset, false);
424            } catch (NullPointerException e) {
425                Log.e(TAG, e.getMessage(), e);
426                return;
427            }
428            replace(newChipSpan);
429            if (mPopup != null && mPopup.isShowing()) {
430                mPopup.dismiss();
431            }
432            return;
433        }
434
435        public void onKeyDown(int keyCode, KeyEvent event) {
436            if (keyCode == KeyEvent.KEYCODE_DEL) {
437                if (mPopup != null && mPopup.isShowing()) {
438                    mPopup.dismiss();
439                }
440                removeChip();
441            }
442        }
443
444        public boolean isCompletedContact() {
445            return mId != -1;
446        }
447
448        private void replace(RecipientChip newChip) {
449            Spannable spannable = getSpannable();
450            int spanStart = getChipStart();
451            int spanEnd = getChipEnd();
452            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
453            spannable.removeSpan(this);
454            mRecipients.remove(this);
455            spannable.setSpan(newChip, spanStart, spanEnd, 0);
456        }
457
458        public void removeChip() {
459            Spannable spannable = getSpannable();
460            int spanStart = getChipStart();
461            int spanEnd = getChipEnd();
462
463            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
464            spannable.removeSpan(this);
465            mRecipients.remove(this);
466            spannable.setSpan(null, spanStart, spanEnd, 0);
467            getText().delete(spanStart, spanEnd);
468        }
469
470        public int getChipStart() {
471            return getSpannable().getSpanStart(this);
472        }
473
474        public int getChipEnd() {
475            return getSpannable().getSpanEnd(this);
476        }
477
478        public void replaceChip(RecipientEntry entry) {
479            clearComposingText();
480
481            RecipientChip newChipSpan = null;
482            try {
483                newChipSpan = constructChipSpan(entry, mOffset, false);
484            } catch (NullPointerException e) {
485                Log.e(TAG, e.getMessage(), e);
486                return;
487            }
488            replace(newChipSpan);
489            if (mPopup != null && mPopup.isShowing()) {
490                mPopup.dismiss();
491            }
492            onChipChanged();
493        }
494
495        public RecipientChip selectChip() {
496            clearComposingText();
497            RecipientChip newChipSpan = null;
498            if (isCompletedContact()) {
499                try {
500                    newChipSpan = constructChipSpan(mEntry, mOffset, true);
501                    newChipSpan.setSelected(true);
502                } catch (NullPointerException e) {
503                    Log.e(TAG, e.getMessage(), e);
504                    return newChipSpan;
505                }
506                replace(newChipSpan);
507                if (mPopup != null && mPopup.isShowing()) {
508                    mPopup.dismiss();
509                }
510                mSelected = true;
511                // Make sure we call edit on the new chip span.
512                newChipSpan.showAlternates();
513            } else {
514                CharSequence text = getValue();
515                removeChip();
516                Editable editable = getText();
517                setSelection(editable.length());
518                editable.append(text);
519            }
520            return newChipSpan;
521        }
522
523        private void showAlternates() {
524            mPopup = new ListPopupWindow(RecipientEditTextView.this.getContext());
525
526            if (!mPopup.isShowing()) {
527                mAnchorView.setLeft(mLeft);
528                mAnchorView.setRight(mLeft);
529                mPopup.setAnchorView(mAnchorView);
530                BaseRecipientAdapter adapter = (BaseRecipientAdapter) getAdapter();
531                adapter.getFilter().filter(getValue(), this);
532                mPopup.setAdapter(adapter);
533                // TODO: get width from dimen.xml.
534                mPopup.setWidth(getWidth());
535                mPopup.setOnItemClickListener(this);
536                mPopup.setOnDismissListener(this);
537            }
538        }
539
540        private void setSelected(boolean selected) {
541            mSelected = selected;
542        }
543
544        public CharSequence getDisplay() {
545            return mDisplay;
546        }
547
548        public CharSequence getValue() {
549            return mValue;
550        }
551
552        private boolean isInDelete(float x, float y) {
553            // Figure out the bounds of this chip and whether or not
554            // the user clicked in the X portion.
555            return x > (mBounds.right - mChipDeleteWidth) && x < mBounds.right;
556        }
557
558        public void onClick(View widget, float x, float y) {
559            if (mSelected) {
560                if (isInDelete(x, y)) {
561                    removeChip();
562                    return;
563                }
564            }
565        }
566
567        @Override
568        public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top,
569                int y, int bottom, Paint paint) {
570            mLeft = (int) x;
571            super.draw(canvas, text, start, end, x, top, y, bottom, paint);
572        }
573
574        @Override
575        public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) {
576            mPopup.dismiss();
577            clearComposingText();
578            replaceChip((RecipientEntry) adapterView.getItemAtPosition(position));
579        }
580
581        // When the popup dialog is dismissed, return the cursor to the end.
582        @Override
583        public void onDismiss() {
584            mHandler.post(mDelayedSelectionMode);
585        }
586
587        @Override
588        public void onFilterComplete(int count) {
589            if (count > 0 && mPopup != null) {
590                mPopup.show();
591            }
592        }
593    }
594}
595