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