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