RecipientEditTextView.java revision 5e7af11f172ccee5d427802cf634c78fee5595b2
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 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        boolean handled = super.onTouchEvent(event);
249        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
250            Spannable span = getSpannable();
251            int offset = getOffsetForPosition(event.getX(), event.getY());
252            int start = offset;
253            int end = span.length();
254            RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
255            if (chips != null && chips.length > 0) {
256                // Get the first chip that matched.
257                final RecipientChip currentChip = chips[0];
258
259                if (action == MotionEvent.ACTION_UP) {
260                    currentChip.onClick(this);
261                } else if (action == MotionEvent.ACTION_DOWN) {
262
263                }
264            }
265        }
266        return handled;
267    }
268
269    private CharSequence createChip(RecipientEntry entry) {
270        // We want to override the tokenizer behavior with our own ending
271        // token, space.
272        SpannableString chipText = new SpannableString(mTokenizer.terminateToken(entry
273                .getDisplayName()));
274        int end = getSelectionEnd();
275        int start = mTokenizer.findTokenStart(getText(), end);
276        try {
277            chipText.setSpan(constructChipSpan(entry, start, false), 0, entry.getDisplayName()
278                    .length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
279        } catch (NullPointerException e) {
280            Log.e(TAG, e.getMessage());
281            return null;
282        }
283
284        return chipText;
285    }
286
287    @Override
288    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
289        submitItemAtPosition(position);
290    }
291
292    private void submitItemAtPosition(int position) {
293        RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position);
294        clearComposingText();
295
296        int end = getSelectionEnd();
297        int start = mTokenizer.findTokenStart(getText(), end);
298
299        Editable editable = getText();
300        editable.replace(start, end, createChip(entry));
301        QwertyKeyListener.markAsReplaced(editable, start, end, "");
302    }
303
304    /**
305     * RecipientChip defines an ImageSpan that contains information relevant to
306     * a particular recipient.
307     */
308    public class RecipientChip extends ImageSpan implements OnItemClickListener, OnDismissListener,
309            FilterListener {
310        private final CharSequence mDisplay;
311
312        private final CharSequence mValue;
313
314        private final int mOffset;
315
316        private ListPopupWindow mPopup;
317
318        private View mAnchorView;
319
320        private int mLeft;
321
322        private int mId = -1;
323
324        public RecipientChip(Drawable drawable, RecipientEntry entry, int offset, Rect bounds) {
325            super(drawable);
326            mDisplay = entry.getDisplayName();
327            mValue = entry.getDestination();
328            mId = entry.getContactId();
329            mOffset = offset;
330
331            mAnchorView = new View(getContext());
332            mAnchorView.setLeft(bounds.left);
333            mAnchorView.setRight(bounds.left);
334            mAnchorView.setTop(bounds.bottom);
335            mAnchorView.setBottom(bounds.bottom);
336            mAnchorView.setVisibility(View.GONE);
337        }
338
339        public void onKeyDown(int keyCode, KeyEvent event) {
340            if (keyCode == KeyEvent.KEYCODE_DEL) {
341                if (mPopup != null && mPopup.isShowing()) {
342                    mPopup.dismiss();
343                }
344                removeChip();
345            }
346        }
347
348        public boolean isCompletedContact() {
349            return mId != -1;
350        }
351
352        private void replace(RecipientChip newChip) {
353            Spannable spannable = getSpannable();
354            int spanStart = getChipStart();
355            int spanEnd = getChipEnd();
356            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
357            spannable.removeSpan(this);
358            spannable.setSpan(newChip, spanStart, spanEnd, 0);
359        }
360
361        public void removeChip() {
362            Spannable spannable = getSpannable();
363            int spanStart = getChipStart();
364            int spanEnd = getChipEnd();
365            QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, "");
366            spannable.removeSpan(this);
367            getText().delete(spanStart, spanEnd);
368        }
369
370        public int getChipStart() {
371            return getSpannable().getSpanStart(this);
372        }
373
374        public int getChipEnd() {
375            return getSpannable().getSpanEnd(this);
376        }
377
378        public void replaceChip(RecipientEntry entry) {
379            clearComposingText();
380
381            RecipientChip newChipSpan = null;
382            try {
383                newChipSpan = constructChipSpan(entry, mOffset, false);
384            } catch (NullPointerException e) {
385                Log.e(TAG, e.getMessage());
386                return;
387            }
388            replace(newChipSpan);
389            if (mPopup != null && mPopup.isShowing()) {
390                mPopup.dismiss();
391            }
392            onChipChanged();
393        }
394
395        public CharSequence getDisplay() {
396            return mDisplay;
397        }
398
399        public CharSequence getValue() {
400            return mValue;
401        }
402
403        public void onClick(View widget) {
404            if (isCompletedContact()) {
405                mPopup = new ListPopupWindow(widget.getContext());
406
407                if (!mPopup.isShowing()) {
408                    mAnchorView.setLeft(mLeft);
409                    mAnchorView.setRight(mLeft);
410                    mPopup.setAnchorView(mAnchorView);
411                    BaseRecipientAdapter adapter = (BaseRecipientAdapter)getAdapter();
412                    adapter.getFilter().filter(getValue(), this);
413                    mPopup.setAdapter(adapter);
414                    // TODO: get width from dimen.xml.
415                    mPopup.setWidth(getWidth());
416                    mPopup.setOnItemClickListener(this);
417                    mPopup.setOnDismissListener(this);
418                }
419            } else {
420                CharSequence text = getValue();
421                removeChip();
422                Editable editable = getText();
423                setSelection(editable.length());
424                editable.append(text);
425            }
426        }
427
428        @Override
429        public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top,
430                int y, int bottom, Paint paint) {
431            mLeft = (int) x;
432            super.draw(canvas, text, start, end, x, top, y, bottom, paint);
433        }
434
435        @Override
436        public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) {
437            mPopup.dismiss();
438            clearComposingText();
439            replaceChip((RecipientEntry) adapterView.getItemAtPosition(position));
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        @Override
449        public void onFilterComplete(int count) {
450            if (count > 0 && mPopup != null) {
451                mPopup.show();
452            }
453        }
454    }
455}
456