RecipientEditTextView.java revision f026dfb761c894942354060746a8ab7dd563386c
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.ColorDrawable;
26import android.graphics.drawable.Drawable;
27import android.os.Handler;
28import android.text.Editable;
29import android.text.Layout;
30import android.text.Selection;
31import android.text.Spannable;
32import android.text.SpannableString;
33import android.text.Spanned;
34import android.text.TextPaint;
35import android.text.TextUtils;
36import android.text.method.QwertyKeyListener;
37import android.text.style.ImageSpan;
38import android.util.AttributeSet;
39import android.view.KeyEvent;
40import android.view.MotionEvent;
41import android.view.View;
42import android.widget.AdapterView;
43import android.widget.AdapterView.OnItemClickListener;
44import android.widget.PopupWindow.OnDismissListener;
45import android.widget.ListPopupWindow;
46import android.widget.ListView;
47import android.widget.MultiAutoCompleteTextView;
48
49/**
50 * RecipientEditTextView is an auto complete text view for use with applications
51 * that use the new Chips UI for addressing a message to recipients.
52 */
53public class RecipientEditTextView extends MultiAutoCompleteTextView
54    implements OnItemClickListener {
55
56    private static final int DEFAULT_CHIP_BACKGROUND = 0x77CCCCCC;
57
58    private static final int CHIP_PADDING = 10;
59
60    public static String CHIP_BACKGROUND = "chipBackground";
61
62    // TODO: eliminate this and take the pressed state from the provided
63    // drawable.
64    public static String CHIP_BACKGROUND_PRESSED = "chipBackgroundPressed";
65
66    private Drawable mChipBackground = null;
67
68    private Tokenizer mTokenizer;
69
70    private final Handler mHandler;
71
72    private Runnable mDelayedSelectionMode = new Runnable() {
73        @Override
74        public void run() {
75            setSelection(getText().length());
76        }
77    };
78
79
80    public RecipientEditTextView(Context context, AttributeSet attrs) {
81        super(context, attrs);
82        mHandler = new Handler();
83        setOnItemClickListener(this);
84    }
85
86    public RecipientChip constructChipSpan(CharSequence text, int offset, boolean pressed) {
87        Layout layout = getLayout();
88        int line = layout.getLineForOffset(offset);
89        int lineTop = layout.getLineTop(line);
90        int lineBottom = layout.getLineBottom(line);
91
92        TextPaint paint = getPaint();
93        float defaultSize = paint.getTextSize();
94
95        // Reduce the size of the text slightly so that we can get the "look" of
96        // padding.
97        paint.setTextSize((float) (paint.getTextSize() * .9));
98
99        // Ellipsize the text so that it takes AT MOST the entire width of the
100        // autocomplete text entry area. Make sure to leave space for padding
101        // on the sides.
102        CharSequence ellipsizedText = TextUtils.ellipsize(text, paint, calculateAvailableWidth(),
103                TextUtils.TruncateAt.END);
104
105        int height = getLineHeight();
106        int width = (int) Math.floor(paint.measureText(ellipsizedText, 0, ellipsizedText.length()))
107                + (CHIP_PADDING * 2);
108
109        // Create the background of the chip.
110        Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
111        Canvas canvas = new Canvas(tmpBitmap);
112        if (mChipBackground != null) {
113            mChipBackground.setBounds(0, 0, width, height);
114            mChipBackground.draw(canvas);
115        } else {
116            ColorDrawable color = new ColorDrawable(DEFAULT_CHIP_BACKGROUND);
117            color.setBounds(0, 0, width, height);
118            color.draw(canvas);
119        }
120
121        // Align the display text with where the user enters text.
122        canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), CHIP_PADDING, height
123                - layout.getLineDescent(line), paint);
124
125        // Get the location of the widget so we can properly offset
126        // the anchor for each chip.
127        int[] xy = new int[2];
128        getLocationOnScreen(xy);
129        // Pass the full text, un-ellipsized, to the chip.
130        Drawable result = new BitmapDrawable(getResources(), tmpBitmap);
131        result.setBounds(0, 0, width, height);
132        Rect bounds = new Rect(xy[0] + offset, xy[1] + lineTop, xy[0] + width, xy[1] + lineBottom);
133        RecipientChip recipientChip = new RecipientChip(result, text, text, -1, offset, bounds);
134
135        // Return text to the original size.
136        paint.setTextSize(defaultSize);
137
138        return recipientChip;
139    }
140
141    // Get the max amount of space a chip can take up. The formula takes into
142    // account the width of the EditTextView, any view padding, and padding
143    // that will be added to the chip.
144    private float calculateAvailableWidth() {
145        return getWidth() - getPaddingLeft() - getPaddingRight() - (CHIP_PADDING * 2);
146    }
147
148    public void setChipBackgroundDrawable(Drawable d) {
149        mChipBackground = d;
150    }
151
152    @Override
153    public void setTokenizer(Tokenizer tokenizer) {
154        mTokenizer = tokenizer;
155        super.setTokenizer(mTokenizer);
156    }
157
158    // We want to handle replacing text in the onItemClickListener
159    // so we can get all the associated contact information including
160    // display text, address, and id.
161    @Override
162    protected void replaceText(CharSequence text) {
163        return;
164    }
165
166    // TODO: this should be handled by the framework directly; working with
167    // @debunne to figure out why it isn't being handled properly.
168    @Override
169    public boolean onKeyUp(int keyCode, KeyEvent event) {
170        switch (keyCode) {
171            case KeyEvent.KEYCODE_ENTER:
172            case KeyEvent.KEYCODE_DPAD_CENTER:
173            case KeyEvent.KEYCODE_TAB:
174                if (event.hasNoModifiers()) {
175                    if (getListSelection() != ListView.INVALID_POSITION) {
176                        performCompletion();
177                        return true;
178                    } else {
179                        int end = getSelectionEnd();
180                        int start = mTokenizer.findTokenStart(getText(), end);
181                        String text = getText().toString().substring(start, end);
182                        clearComposingText();
183
184                        Editable editable = getText();
185
186                        QwertyKeyListener.markAsReplaced(editable, start, end, "");
187                        editable.replace(start, end, createChip(text));
188                        dismissDropDown();
189                    }
190                }
191        }
192        return super.onKeyUp(keyCode, event);
193    }
194
195    public void onChipChanged() {
196        // Must be posted so that the previous span
197        // is correctly replaced with the previous selection points.
198        mHandler.post(mDelayedSelectionMode);
199    }
200
201    @Override
202    public boolean onKeyDown(int keyCode, KeyEvent event) {
203        int start = getSelectionStart();
204        int end = getSelectionEnd();
205        Spannable span = getSpannable();
206
207        RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
208        if (chips != null) {
209            for (RecipientChip chip : chips) {
210                chip.onKeyDown(keyCode, event);
211            }
212        }
213
214        if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) {
215            return true;
216        }
217
218        return super.onKeyDown(keyCode, event);
219    }
220
221    public Spannable getSpannable() {
222        return (Spannable) getText();
223    }
224
225    /**
226     * RecipientChip defines an ImageSpan that contains information relevant to
227     * a particular recipient.
228     */
229    public class RecipientChip extends ImageSpan implements OnItemClickListener, OnDismissListener {
230        private final CharSequence mDisplay;
231
232        private final CharSequence mValue;
233
234        private final int mOffset;
235
236        private ListPopupWindow mPopup;
237
238        private View mAnchorView;
239
240        private int mLeft;
241
242        private int mId = -1;
243
244        public RecipientChip(Drawable drawable, CharSequence text, CharSequence value, int id,
245                int offset, Rect bounds) {
246            super(drawable);
247            mDisplay = text;
248            mValue = value;
249            mOffset = offset;
250            mAnchorView = new View(getContext());
251            mAnchorView.setLeft(bounds.left);
252            mAnchorView.setRight(bounds.left);
253            mAnchorView.setTop(bounds.right + CHIP_PADDING);
254            mAnchorView.setBottom(bounds.right + CHIP_PADDING);
255            mAnchorView.setVisibility(View.GONE);
256
257            mId = id;
258        }
259
260        public void onKeyDown(int keyCode, KeyEvent event) {
261            if (keyCode == KeyEvent.KEYCODE_DEL) {
262                if (mPopup != null && mPopup.isShowing()) {
263                    mPopup.dismiss();
264                }
265                removeChip();
266            }
267        }
268
269        public boolean isCompletedContact() {
270            return mId != -1;
271        }
272
273        private void replace(RecipientChip newChip) {
274            Spannable spannable = getSpannable();
275            int spanStart = getChipStart();
276            int spanEnd = getChipEnd();
277            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
278            spannable.removeSpan(this);
279            spannable.setSpan(newChip, spanStart, spanEnd, 0);
280        }
281
282        public void removeChip() {
283            Spannable spannable = getSpannable();
284            int spanStart = getChipStart();
285            int spanEnd = getChipEnd();
286            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
287            spannable.removeSpan(this);
288            spannable.setSpan(null, spanStart, spanEnd, 0);
289            onChipChanged();
290        }
291
292        public int getChipStart() {
293            return getSpannable().getSpanStart(this);
294        }
295
296        public int getChipEnd() {
297            return getSpannable().getSpanEnd(this);
298        }
299
300        public void replaceChip(String text) {
301            clearComposingText();
302
303            RecipientChip newChipSpan = constructChipSpan(text, mOffset, false);
304            replace(newChipSpan);
305            if (mPopup != null && mPopup.isShowing()) {
306                mPopup.dismiss();
307            }
308            onChipChanged();
309        }
310
311        public CharSequence getDisplay() {
312            return mDisplay;
313        }
314
315        public CharSequence getValue() {
316            return mValue;
317        }
318
319        public void onClick(View widget) {
320            mPopup = new ListPopupWindow(widget.getContext());
321
322            if (!mPopup.isShowing()) {
323                mAnchorView.setLeft(mLeft);
324                mAnchorView.setRight(mLeft);
325                mPopup.setAnchorView(mAnchorView);
326                mPopup.setAdapter(getAdapter());
327                // TODO: get width from dimen.xml.
328                mPopup.setWidth(200);
329                mPopup.setOnItemClickListener(this);
330                mPopup.setOnDismissListener(this);
331                mPopup.show();
332            }
333        }
334
335        @Override
336        public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top,
337                int y, int bottom, Paint paint) {
338            mLeft = (int) x;
339            super.draw(canvas, text, start, end, x, top, y, bottom, paint);
340        }
341
342        @Override
343        public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) {
344            mPopup.dismiss();
345            clearComposingText();
346            RecipientListEntry entry = (RecipientListEntry) adapterView.getItemAtPosition(position);
347            replaceChip(entry.getDisplayName());
348        }
349
350        // When the popup dialog is dismissed, return the cursor to the end.
351        @Override
352        public void onDismiss() {
353            mHandler.post(mDelayedSelectionMode);
354        }
355    }
356
357    @Override
358    public boolean onTouchEvent(MotionEvent event) {
359        int action = event.getAction();
360        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
361            Spannable span = getSpannable();
362            int offset = getOffsetForPosition(event.getX(), event.getY());
363            int start = offset;
364            int end = span.length();
365            RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
366            if (chips != null && chips.length > 0) {
367                // Get the first chip that matched.
368                final RecipientChip currentChip = chips[0];
369
370                if (action == MotionEvent.ACTION_UP) {
371                    currentChip.onClick(this);
372                } else if (action == MotionEvent.ACTION_DOWN) {
373                    Selection.setSelection(getSpannable(), currentChip.getChipStart(), currentChip
374                            .getChipEnd());
375                }
376                return true;
377            }
378        }
379
380        return super.onTouchEvent(event);
381    }
382
383    private CharSequence createChip(String text) {
384        // We want to override the tokenizer behavior with our own ending
385        // token, space.
386        SpannableString chipText = new SpannableString(mTokenizer.terminateToken(text));
387        int end = getSelectionEnd();
388        int start = mTokenizer.findTokenStart(getText(), end);
389        chipText.setSpan(constructChipSpan(text, start, false), 0, text.length(),
390                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
391        return chipText;
392    }
393
394    @Override
395    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
396        // Figure out what got clicked!
397        RecipientListEntry entry = (RecipientListEntry) parent.getItemAtPosition(position);
398        clearComposingText();
399
400        int end = getSelectionEnd();
401        int start = mTokenizer.findTokenStart(getText(), end);
402
403        Editable editable = getText();
404        editable.replace(start, end, createChip(entry.getDisplayName()));
405        QwertyKeyListener.markAsReplaced(editable, start, end, "");
406    }
407}
408