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