RecipientEditTextView.java revision f621a601e1f966c89b7aadbcca384021e14d668d
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.Selection;
30import android.text.Spannable;
31import android.text.SpannableString;
32import android.text.Spanned;
33import android.text.TextPaint;
34import android.text.TextUtils;
35import android.text.method.QwertyKeyListener;
36import android.text.style.ImageSpan;
37import android.util.AttributeSet;
38import android.util.Log;
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.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 int mChipPadding;
60
61    private Tokenizer mTokenizer;
62
63    private final Handler mHandler;
64
65    private Runnable mDelayedSelectionMode = new Runnable() {
66        @Override
67        public void run() {
68            setSelection(getText().length());
69        }
70    };
71
72    public RecipientEditTextView(Context context, AttributeSet attrs) {
73        super(context, attrs);
74        mHandler = new Handler();
75        setOnItemClickListener(this);
76    }
77
78    public RecipientChip constructChipSpan(RecipientEntry contact, int offset, boolean pressed)
79        throws NullPointerException {
80        if (mChipBackground == null) {
81            throw new NullPointerException
82                ("Unable to render any chips as setChipDimensions was not called.");
83        }
84        String text = contact.getDisplayName();
85        Layout layout = getLayout();
86        int line = layout.getLineForOffset(offset);
87        int lineTop = layout.getLineTop(line);
88
89        TextPaint paint = getPaint();
90        float defaultSize = paint.getTextSize();
91
92        // Reduce the size of the text slightly so that we can get the "look" of
93        // padding.
94        paint.setTextSize((float) (paint.getTextSize() * .9));
95
96        // Ellipsize the text so that it takes AT MOST the entire width of the
97        // autocomplete text entry area. Make sure to leave space for padding
98        // on the sides.
99        CharSequence ellipsizedText = TextUtils.ellipsize(text, paint, calculateAvailableWidth(),
100                TextUtils.TruncateAt.END);
101
102        int height = getLineHeight();
103        int width = (int) Math.floor(paint.measureText(ellipsizedText, 0, ellipsizedText.length()))
104                + (mChipPadding * 2);
105
106        // Create the background of the chip.
107        Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
108        Canvas canvas = new Canvas(tmpBitmap);
109        if (mChipBackground != null) {
110            mChipBackground.setBounds(0, 0, width, height);
111            mChipBackground.draw(canvas);
112        } else {
113            Log.w(TAG,
114                    "Unable to draw a background for the chips as it was never set");
115        }
116
117        // Align the display text with where the user enters text.
118        canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height
119                - layout.getLineDescent(line), paint);
120
121        // Get the location of the widget so we can properly offset
122        // the anchor for each chip.
123        int[] xy = new int[2];
124        getLocationOnScreen(xy);
125        // Pass the full text, un-ellipsized, to the chip.
126        Drawable result = new BitmapDrawable(getResources(), tmpBitmap);
127        result.setBounds(0, 0, width, height);
128        Rect bounds = new Rect(xy[0] + offset, xy[1] + lineTop, xy[0] + width,
129                calculateLineBottom(xy[1], line));
130        RecipientChip recipientChip = new RecipientChip(result, contact, offset, bounds);
131
132        // Return text to the original size.
133        paint.setTextSize(defaultSize);
134
135        return recipientChip;
136    }
137
138    // The bottom of the line the chip will be located on is calculated by 4 factors:
139    // 1) which line the chip appears on
140    // 2) the height of a line in the autocomplete view
141    // 3) padding built into the edit text view will move the bottom position
142    // 4) the position of the autocomplete view on the screen, taking into account
143    // that any top padding will move this down visually
144    private int calculateLineBottom(int yOffset, int line) {
145        int bottomPadding = 0;
146        if (line == getLineCount() - 1) {
147            bottomPadding += getPaddingBottom();
148        }
149        return ((line + 1) * getLineHeight()) + (yOffset + getPaddingTop()) + bottomPadding;
150    }
151
152    // Get the max amount of space a chip can take up. The formula takes into
153    // account the width of the EditTextView, any view padding, and padding
154    // that will be added to the chip.
155    private float calculateAvailableWidth() {
156        return getWidth() - getPaddingLeft() - getPaddingRight() - (mChipPadding * 2);
157    }
158
159    /**
160     * Set all chip dimensions and resources. This has to be done from the application
161     * as this is a static library.
162     * @param chipBackground drawable
163     * @param padding Padding around the text in a chip
164     * @param offset Offset between the chip and the dropdown of alternates
165     */
166    public void setChipDimensions(Drawable chipBackground, float padding) {
167        mChipBackground = chipBackground;
168        mChipPadding = (int) padding;
169    }
170
171    @Override
172    public void setTokenizer(Tokenizer tokenizer) {
173        mTokenizer = tokenizer;
174        super.setTokenizer(mTokenizer);
175    }
176
177    // We want to handle replacing text in the onItemClickListener
178    // so we can get all the associated contact information including
179    // display text, address, and id.
180    @Override
181    protected void replaceText(CharSequence text) {
182        return;
183    }
184
185    @Override
186    public boolean onKeyUp(int keyCode, KeyEvent event) {
187        switch (keyCode) {
188            case KeyEvent.KEYCODE_ENTER:
189            case KeyEvent.KEYCODE_DPAD_CENTER:
190            case KeyEvent.KEYCODE_TAB:
191                if (event.hasNoModifiers()) {
192                    if (isPopupShowing()) {
193                        // choose the first entry.
194                        submitItemAtPosition(0);
195                        dismissDropDown();
196                        return true;
197                    } else {
198                        int end = getSelectionEnd();
199                        int start = mTokenizer.findTokenStart(getText(), end);
200                        String text = getText().toString().substring(start, end);
201                        clearComposingText();
202
203                        Editable editable = getText();
204                        RecipientEntry entry = RecipientEntry.constructFakeEntry(text);
205                        QwertyKeyListener.markAsReplaced(editable, start, end, "");
206                        editable.replace(start, end, createChip(entry));
207                        dismissDropDown();
208                    }
209                }
210        }
211        return super.onKeyUp(keyCode, event);
212    }
213
214    public void onChipChanged() {
215        // Must be posted so that the previous span
216        // is correctly replaced with the previous selection points.
217        mHandler.post(mDelayedSelectionMode);
218    }
219
220    @Override
221    public boolean onKeyDown(int keyCode, KeyEvent event) {
222        int start = getSelectionStart();
223        int end = getSelectionEnd();
224        Spannable span = getSpannable();
225
226        RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
227        if (chips != null) {
228            for (RecipientChip chip : chips) {
229                chip.onKeyDown(keyCode, event);
230            }
231        }
232
233        if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) {
234            return true;
235        }
236
237        return super.onKeyDown(keyCode, event);
238    }
239
240    private Spannable getSpannable() {
241        return (Spannable) getText();
242    }
243
244
245    @Override
246    public boolean onTouchEvent(MotionEvent event) {
247        int action = event.getAction();
248        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
249            Spannable span = getSpannable();
250            int offset = getOffsetForPosition(event.getX(), event.getY());
251            int start = offset;
252            int end = span.length();
253            RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
254            if (chips != null && chips.length > 0) {
255                // Get the first chip that matched.
256                final RecipientChip currentChip = chips[0];
257
258                if (action == MotionEvent.ACTION_UP) {
259                    currentChip.onClick(this);
260                } else if (action == MotionEvent.ACTION_DOWN) {
261                    Selection.setSelection(getSpannable(), currentChip.getChipStart(), currentChip
262                            .getChipEnd());
263                }
264                return true;
265            }
266        }
267
268        return super.onTouchEvent(event);
269    }
270
271    private CharSequence createChip(RecipientEntry entry) {
272        // We want to override the tokenizer behavior with our own ending
273        // token, space.
274        SpannableString chipText = new SpannableString(mTokenizer.terminateToken(entry
275                .getDisplayName()));
276        int end = getSelectionEnd();
277        int start = mTokenizer.findTokenStart(getText(), end);
278        try {
279            chipText.setSpan(constructChipSpan(entry, start, false), 0, entry.getDisplayName()
280                    .length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
281        } catch (NullPointerException e) {
282            Log.e(TAG, e.getMessage());
283            return null;
284        }
285
286        return chipText;
287    }
288
289    @Override
290    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
291        submitItemAtPosition(position);
292    }
293
294    private void submitItemAtPosition(int position) {
295        RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position);
296        clearComposingText();
297
298        int end = getSelectionEnd();
299        int start = mTokenizer.findTokenStart(getText(), end);
300
301        Editable editable = getText();
302        editable.replace(start, end, createChip(entry));
303        QwertyKeyListener.markAsReplaced(editable, start, end, "");
304    }
305
306    /**
307     * RecipientChip defines an ImageSpan that contains information relevant to
308     * a particular recipient.
309     */
310    public class RecipientChip extends ImageSpan implements OnItemClickListener, OnDismissListener {
311        private final CharSequence mDisplay;
312
313        private final CharSequence mValue;
314
315        private final int mOffset;
316
317        private ListPopupWindow mPopup;
318
319        private View mAnchorView;
320
321        private int mLeft;
322
323        private int mId = -1;
324
325        public RecipientChip(Drawable drawable, RecipientEntry entry, int offset, Rect bounds) {
326            super(drawable);
327            mDisplay = entry.getDisplayName();
328            mValue = entry.getDestination();
329            mId = entry.getContactId();
330            mOffset = offset;
331
332            mAnchorView = new View(getContext());
333            mAnchorView.setLeft(bounds.left);
334            mAnchorView.setRight(bounds.left);
335            mAnchorView.setTop(bounds.bottom);
336            mAnchorView.setBottom(bounds.bottom);
337            mAnchorView.setVisibility(View.GONE);
338        }
339
340        public void onKeyDown(int keyCode, KeyEvent event) {
341            if (keyCode == KeyEvent.KEYCODE_DEL) {
342                if (mPopup != null && mPopup.isShowing()) {
343                    mPopup.dismiss();
344                }
345                removeChip();
346            }
347        }
348
349        public boolean isCompletedContact() {
350            return mId != -1;
351        }
352
353        private void replace(RecipientChip newChip) {
354            Spannable spannable = getSpannable();
355            int spanStart = getChipStart();
356            int spanEnd = getChipEnd();
357            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
358            spannable.removeSpan(this);
359            spannable.setSpan(newChip, spanStart, spanEnd, 0);
360        }
361
362        public void removeChip() {
363            Spannable spannable = getSpannable();
364            int spanStart = getChipStart();
365            int spanEnd = getChipEnd();
366            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
367            spannable.removeSpan(this);
368            spannable.setSpan(null, spanStart, spanEnd, 0);
369            onChipChanged();
370        }
371
372        public int getChipStart() {
373            return getSpannable().getSpanStart(this);
374        }
375
376        public int getChipEnd() {
377            return getSpannable().getSpanEnd(this);
378        }
379
380        public void replaceChip(String text) {
381            clearComposingText();
382
383            RecipientChip newChipSpan = null;
384            try {
385                newChipSpan = constructChipSpan(RecipientEntry.constructFakeEntry(text),
386                        mOffset, false);
387            } catch (NullPointerException e) {
388                Log.e(TAG, e.getMessage());
389                return;
390            }
391            replace(newChipSpan);
392            if (mPopup != null && mPopup.isShowing()) {
393                mPopup.dismiss();
394            }
395            onChipChanged();
396        }
397
398        public CharSequence getDisplay() {
399            return mDisplay;
400        }
401
402        public CharSequence getValue() {
403            return mValue;
404        }
405
406        public void onClick(View widget) {
407            if (isCompletedContact()) {
408                mPopup = new ListPopupWindow(widget.getContext());
409
410                if (!mPopup.isShowing()) {
411                    mAnchorView.setLeft(mLeft);
412                    mAnchorView.setRight(mLeft);
413                    mPopup.setAnchorView(mAnchorView);
414                    mPopup.setAdapter(getAdapter());
415                    // TODO: get width from dimen.xml.
416                    mPopup.setWidth(getWidth());
417                    mPopup.setOnItemClickListener(this);
418                    mPopup.setOnDismissListener(this);
419                    mPopup.show();
420                }
421            } else {
422                // TODO: move the cursor to the end of the view. Add the text
423                // that was in this span to the end of the view as well.
424            }
425        }
426
427        @Override
428        public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top,
429                int y, int bottom, Paint paint) {
430            mLeft = (int) x;
431            super.draw(canvas, text, start, end, x, top, y, bottom, paint);
432        }
433
434        @Override
435        public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) {
436            mPopup.dismiss();
437            clearComposingText();
438            RecipientEntry entry = (RecipientEntry) adapterView.getItemAtPosition(position);
439            replaceChip(entry.getDisplayName());
440        }
441
442        // When the popup dialog is dismissed, return the cursor to the end.
443        @Override
444        public void onDismiss() {
445            mHandler.post(mDelayedSelectionMode);
446        }
447    }
448}
449