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