RecipientEditTextView.java revision f84d110bf541885d04cf261af1a337bd1b4e25db
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
270        if (mSelectedChip != null) {
271            mSelectedChip.onKeyDown(keyCode, event);
272        }
273
274        if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) {
275            return true;
276        }
277
278        return super.onKeyDown(keyCode, event);
279    }
280
281    private Spannable getSpannable() {
282        return (Spannable) getText();
283    }
284
285
286    @Override
287    public boolean onTouchEvent(MotionEvent event) {
288        int action = event.getAction();
289        boolean handled = super.onTouchEvent(event);
290        boolean chipWasSelected = false;
291
292        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
293            float x = event.getX();
294            float y = event.getY();
295            int offset = putOffsetInRange(getOffsetForPosition(x, y));
296            RecipientChip currentChip = findChip(offset);
297            if (currentChip != null) {
298                if (action == MotionEvent.ACTION_UP) {
299                    if (mSelectedChip != null && mSelectedChip != currentChip) {
300                        mSelectedChip.unselectChip();
301                        mSelectedChip = currentChip.selectChip();
302                    } else if (mSelectedChip == null) {
303                        mSelectedChip = currentChip.selectChip();
304                    } else {
305                        mSelectedChip.onClick(this, offset, x, y);
306                    }
307                }
308                chipWasSelected = true;
309            }
310        }
311        if (action == MotionEvent.ACTION_UP && !chipWasSelected) {
312            if (mSelectedChip != null) {
313                mSelectedChip.unselectChip();
314                mSelectedChip = null;
315            }
316        }
317        return handled;
318    }
319
320    // TODO: This algorithm will need a lot of tweaking after more people have used
321    // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring
322    // what comes before the finger.
323    private int putOffsetInRange(int o) {
324        int offset = o;
325        while (offset >= 0 && findChip(offset) == null) {
326            // Keep walking backward!
327            offset--;
328        }
329        return offset;
330    }
331
332    private RecipientChip findChip(int offset) {
333        RecipientChip[] chips = getSpannable().getSpans(0, offset, RecipientChip.class);
334        // Find the chip that contains this offset.
335        for (int i = 0; i < chips.length; i++) {
336            RecipientChip chip = chips[i];
337            if (chip.matchesChip(offset)) {
338                return chip;
339            }
340        }
341        return null;
342    }
343
344    private CharSequence createChip(RecipientEntry entry) {
345        // We want to override the tokenizer behavior with our own ending
346        // token, space.
347        SpannableString chipText = new SpannableString(mTokenizer.terminateToken(entry
348                .getDisplayName()));
349        int end = getSelectionEnd();
350        int start = mTokenizer.findTokenStart(getText(), end);
351        try {
352            chipText.setSpan(constructChipSpan(entry, start, false), 0, entry.getDisplayName()
353                    .length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
354        } catch (NullPointerException e) {
355            Log.e(TAG, e.getMessage(), e);
356            return null;
357        }
358
359        return chipText;
360    }
361
362    @Override
363    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
364        submitItemAtPosition(position);
365    }
366
367    private void submitItemAtPosition(int position) {
368        RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position);
369        clearComposingText();
370
371        int end = getSelectionEnd();
372        int start = mTokenizer.findTokenStart(getText(), end);
373
374        Editable editable = getText();
375        editable.replace(start, end, createChip(entry));
376        QwertyKeyListener.markAsReplaced(editable, start, end, "");
377    }
378
379    public Editable getRecipients() {
380        StringBuilder plainText = new StringBuilder();
381        int size = mRecipients.size();
382        for (int i = 0; i < size; i++) {
383            plainText.append(mRecipients.get(i).getValue());
384                if (i != size-1) {
385                    plainText.append(',');
386                }
387        }
388        return Editable.Factory.getInstance().newEditable(plainText);
389    }
390
391    /** Returns a collection of contact Id for each chip inside this View. */
392    /* package */ Collection<Integer> getContactIds() {
393        final Set<Integer> result = new HashSet<Integer>();
394        for (RecipientChip chip : mRecipients) {
395            result.add(chip.getContactId());
396        }
397        return result;
398    }
399
400    /** Returns a collection of data Id for each chip inside this View. May be null. */
401    /* package */ Collection<Integer> getDataIds() {
402        final Set<Integer> result = new HashSet<Integer>();
403        for (RecipientChip chip : mRecipients) {
404            result.add(chip.getDataId());
405        }
406        return result;
407    }
408
409    /**
410     * RecipientChip defines an ImageSpan that contains information relevant to
411     * a particular recipient.
412     */
413    public class RecipientChip extends ImageSpan implements OnItemClickListener, OnDismissListener,
414            FilterListener {
415        private final CharSequence mDisplay;
416
417        private final CharSequence mValue;
418
419        private final int mOffset;
420
421        private ListPopupWindow mPopup;
422
423        private View mAnchorView;
424
425        private int mLeft;
426
427        private final int mContactId;
428
429        private final int mDataId;
430
431        private RecipientEntry mEntry;
432
433        private boolean mSelected = false;
434
435        private Rect mBounds;
436        public RecipientChip(Drawable drawable, RecipientEntry entry, int offset, Rect bounds) {
437            super(drawable);
438            mDisplay = entry.getDisplayName();
439            mValue = entry.getDestination();
440            mContactId = entry.getContactId();
441            mDataId = entry.getDataId();
442            mOffset = offset;
443            mEntry = entry;
444            mBounds = bounds;
445
446            mAnchorView = new View(getContext());
447            mAnchorView.setLeft(bounds.left);
448            mAnchorView.setRight(bounds.left);
449            mAnchorView.setTop(bounds.bottom);
450            mAnchorView.setBottom(bounds.bottom);
451            mAnchorView.setVisibility(View.GONE);
452            mRecipients.add(this);
453        }
454
455        public void unselectChip() {
456            if (getChipStart() == -1 || getChipEnd() == -1) {
457                mSelectedChip = null;
458                return;
459            }
460            clearComposingText();
461            RecipientChip newChipSpan = null;
462            try {
463                newChipSpan = constructChipSpan(mEntry, mOffset, false);
464            } catch (NullPointerException e) {
465                Log.e(TAG, e.getMessage(), e);
466                return;
467            }
468            replace(newChipSpan);
469            if (mPopup != null && mPopup.isShowing()) {
470                mPopup.dismiss();
471            }
472            return;
473        }
474
475        public void onKeyDown(int keyCode, KeyEvent event) {
476            if (keyCode == KeyEvent.KEYCODE_DEL) {
477                if (mPopup != null && mPopup.isShowing()) {
478                    mPopup.dismiss();
479                }
480                removeChip();
481            }
482        }
483
484        public boolean isCompletedContact() {
485            return mContactId != -1;
486        }
487
488        private void replace(RecipientChip newChip) {
489            Spannable spannable = getSpannable();
490            int spanStart = getChipStart();
491            int spanEnd = getChipEnd();
492            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
493            spannable.removeSpan(this);
494            mRecipients.remove(this);
495            spannable.setSpan(newChip, spanStart, spanEnd, 0);
496        }
497
498        public void removeChip() {
499            Spannable spannable = getSpannable();
500            int spanStart = getChipStart();
501            int spanEnd = getChipEnd();
502            if (this == mSelectedChip) {
503                mSelectedChip = null;
504            }
505            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
506            spannable.removeSpan(this);
507            mRecipients.remove(this);
508            spannable.setSpan(null, spanStart, spanEnd, 0);
509            getText().delete(spanStart, spanEnd);
510        }
511
512        public int getChipStart() {
513            return getSpannable().getSpanStart(this);
514        }
515
516        public int getChipEnd() {
517            return getSpannable().getSpanEnd(this);
518        }
519
520        public void replaceChip(RecipientEntry entry) {
521            clearComposingText();
522
523            RecipientChip newChipSpan = null;
524            try {
525                newChipSpan = constructChipSpan(entry, mOffset, false);
526            } catch (NullPointerException e) {
527                Log.e(TAG, e.getMessage(), e);
528                return;
529            }
530            replace(newChipSpan);
531            if (mPopup != null && mPopup.isShowing()) {
532                mPopup.dismiss();
533            }
534            onChipChanged();
535        }
536
537        public RecipientChip selectChip() {
538            clearComposingText();
539            RecipientChip newChipSpan = null;
540            if (isCompletedContact()) {
541                try {
542                    newChipSpan = constructChipSpan(mEntry, mOffset, true);
543                    newChipSpan.setSelected(true);
544                } catch (NullPointerException e) {
545                    Log.e(TAG, e.getMessage(), e);
546                    return newChipSpan;
547                }
548                replace(newChipSpan);
549                if (mPopup != null && mPopup.isShowing()) {
550                    mPopup.dismiss();
551                }
552                mSelected = true;
553                // Make sure we call edit on the new chip span.
554                newChipSpan.showAlternates();
555            } else {
556                CharSequence text = getValue();
557                removeChip();
558                Editable editable = getText();
559                setSelection(editable.length());
560                editable.append(text);
561            }
562            return newChipSpan;
563        }
564
565        private void showAlternates() {
566            mPopup = new ListPopupWindow(RecipientEditTextView.this.getContext());
567
568            if (!mPopup.isShowing()) {
569                mAnchorView.setLeft(mLeft);
570                mAnchorView.setRight(mLeft);
571                mPopup.setAnchorView(mAnchorView);
572                BaseRecipientAdapter adapter = (BaseRecipientAdapter) getAdapter();
573                adapter.getFilter().filter(getValue(), this);
574                mPopup.setAdapter(adapter);
575                // TODO: get width from dimen.xml.
576                mPopup.setWidth(getWidth());
577                mPopup.setOnItemClickListener(this);
578                mPopup.setOnDismissListener(this);
579            }
580        }
581
582        private void setSelected(boolean selected) {
583            mSelected = selected;
584        }
585
586        public CharSequence getDisplay() {
587            return mDisplay;
588        }
589
590        public CharSequence getValue() {
591            return mValue;
592        }
593
594        private boolean isInDelete(int offset, float x, float y) {
595            // Figure out the bounds of this chip and whether or not
596            // the user clicked in the X portion.
597            return mSelected
598                    && (offset >= getChipEnd()
599                            || (x > (mBounds.right - mChipDeleteWidth) && x < mBounds.right));
600        }
601
602        public boolean matchesChip(int offset) {
603            int start = getChipStart();
604            int end = getChipEnd();
605            return (offset >= start && offset <= end);
606        }
607
608        public void onClick(View widget, int offset, float x, float y) {
609            if (mSelected) {
610                if (isInDelete(offset, x, y)) {
611                    removeChip();
612                    return;
613                }
614            }
615        }
616
617        @Override
618        public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top,
619                int y, int bottom, Paint paint) {
620            // Shift the bounds of this span to where it is actually drawn on the screeen.
621            mLeft = (int) x;
622            super.draw(canvas, text, start, end, x, top, y, bottom, paint);
623        }
624
625        @Override
626        public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) {
627            mPopup.dismiss();
628            clearComposingText();
629            replaceChip((RecipientEntry) adapterView.getItemAtPosition(position));
630        }
631
632        // When the popup dialog is dismissed, return the cursor to the end.
633        @Override
634        public void onDismiss() {
635            mHandler.post(mDelayedSelectionMode);
636        }
637
638        @Override
639        public void onFilterComplete(int count) {
640            if (count > 0 && mPopup != null) {
641                mPopup.show();
642            }
643        }
644
645        public int getContactId() {
646            return mContactId;
647        }
648
649        public int getDataId() {
650            return mDataId;
651        }
652    }
653}
654