RecipientEditTextView.java revision 4f5f0328efbd5f72e30adf08ba7d89a66b4659ce
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.BitmapFactory; 22import android.graphics.Canvas; 23import android.graphics.Matrix; 24import android.graphics.Paint; 25import android.graphics.Rect; 26import android.graphics.RectF; 27import android.graphics.drawable.BitmapDrawable; 28import android.graphics.drawable.Drawable; 29import android.os.Handler; 30import android.os.Message; 31import android.text.Editable; 32import android.text.Layout; 33import android.text.Spannable; 34import android.text.SpannableString; 35import android.text.Spanned; 36import android.text.TextPaint; 37import android.text.TextUtils; 38import android.text.TextWatcher; 39import android.text.method.QwertyKeyListener; 40import android.text.style.ImageSpan; 41import android.util.AttributeSet; 42import android.util.Log; 43import android.view.ActionMode; 44import android.view.KeyEvent; 45import android.view.Menu; 46import android.view.MenuItem; 47import android.view.MotionEvent; 48import android.view.View; 49import android.view.ActionMode.Callback; 50import android.widget.AdapterView; 51import android.widget.AdapterView.OnItemClickListener; 52import android.widget.ListPopupWindow; 53import android.widget.ListView; 54import android.widget.MultiAutoCompleteTextView; 55 56import java.util.Collection; 57import java.util.HashSet; 58import java.util.Set; 59 60import java.util.ArrayList; 61 62/** 63 * RecipientEditTextView is an auto complete text view for use with applications 64 * that use the new Chips UI for addressing a message to recipients. 65 */ 66public class RecipientEditTextView extends MultiAutoCompleteTextView 67 implements OnItemClickListener, Callback { 68 69 private static final String TAG = "RecipientEditTextView"; 70 71 // TODO: get correct number/ algorithm from with UX. 72 private static final int CHIP_LIMIT = 2; 73 74 private static final int INVALID_CONTACT = -1; 75 76 // TODO: get correct size from UX. 77 private static final float MORE_WIDTH_FACTOR = 0.25f; 78 79 private Drawable mChipBackground = null; 80 81 private Drawable mChipDelete = null; 82 83 private int mChipPadding; 84 85 private Tokenizer mTokenizer; 86 87 private Drawable mChipBackgroundPressed; 88 89 private RecipientChip mSelectedChip; 90 91 private int mChipDeleteWidth; 92 93 private int mAlternatesLayout; 94 95 private Bitmap mDefaultContactPhoto; 96 97 private ImageSpan mMoreChip; 98 99 private int mMoreString; 100 101 private ArrayList<RecipientChip> mRemovedSpans; 102 103 private float mChipHeight; 104 105 private float mChipFontSize; 106 107 private Validator mValidator; 108 109 private Drawable mInvalidChipBackground; 110 111 private Handler mHandler; 112 113 private static int DISMISS = "dismiss".hashCode(); 114 115 private static final long DISMISS_DELAY = 300; 116 117 private int mPendingChipsCount = 0; 118 119 private static final char SEPERATOR = ','; 120 121 public RecipientEditTextView(Context context, AttributeSet attrs) { 122 super(context, attrs); 123 setSuggestionsEnabled(false); 124 setOnItemClickListener(this); 125 setCustomSelectionActionModeCallback(this); 126 // When the user starts typing, make sure we unselect any selected 127 // chips. 128 addTextChangedListener(new TextWatcher() { 129 @Override 130 public void afterTextChanged(Editable s) { 131 // Do nothing. 132 } 133 @Override 134 public void onTextChanged(CharSequence s, int start, int before, int count) { 135 // Do nothing. 136 } 137 @Override 138 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 139 // TODO: find a better way to unfocus a chip when a user starts typing. 140 } 141 }); 142 mHandler = new Handler() { 143 @Override 144 public void handleMessage(Message msg) { 145 if (msg.what == DISMISS) { 146 ((ListPopupWindow)msg.obj).dismiss(); 147 return; 148 } 149 super.handleMessage(msg); 150 } 151 }; 152 } 153 154 @Override 155 public void onSelectionChanged(int start, int end) { 156 // When selection changes, see if it is inside the chips area. 157 // If so, move the cursor back after the chips again. 158 Spannable span = getSpannable(); 159 int textLength = getText().length(); 160 RecipientChip[] chips = span.getSpans(start, textLength, RecipientChip.class); 161 if (chips != null && chips.length > 0) { 162 if (chips != null && chips.length > 0) { 163 // Grab the last chip and set the cursor to after it. 164 setSelection(Math.min(chips[chips.length - 1].getChipEnd() + 1, textLength)); 165 } 166 } 167 super.onSelectionChanged(start, end); 168 } 169 170 /** 171 * Convenience method: Append the specified text slice to the TextView's 172 * display buffer, upgrading it to BufferType.EDITABLE if it was 173 * not already editable. Commas are excluded as they are added automatically 174 * by the view. 175 */ 176 @Override 177 public void append(CharSequence text, int start, int end) { 178 super.append(text, start, end); 179 if (!TextUtils.isEmpty(text) && TextUtils.getTrimmedLength(text) > 0) { 180 final String displayString = (String) text; 181 int seperatorPos = displayString.indexOf(SEPERATOR); 182 if (seperatorPos != 0 && !TextUtils.isEmpty(displayString) 183 && TextUtils.getTrimmedLength(displayString) > 0) { 184 mPendingChipsCount++; 185 } 186 } 187 } 188 189 @Override 190 public void onFocusChanged(boolean hasFocus, int direction, Rect previous) { 191 if (!hasFocus) { 192 shrink(); 193 } else { 194 expand(); 195 } 196 super.onFocusChanged(hasFocus, direction, previous); 197 } 198 199 private void shrink() { 200 if (mSelectedChip != null) { 201 clearSelectedChip(); 202 } else { 203 commitDefault(); 204 } 205 mMoreChip = createMoreChip(); 206 } 207 208 private void expand() { 209 removeMoreChip(); 210 setCursorVisible(true); 211 Editable text = getText(); 212 setSelection(text != null && text.length() > 0 ? text.length() : 0); 213 } 214 215 private CharSequence ellipsizeText(CharSequence text, TextPaint paint, float maxWidth) { 216 paint.setTextSize(mChipFontSize); 217 if (maxWidth <= 0 && Log.isLoggable(TAG, Log.DEBUG)) { 218 Log.d(TAG, "Max width is negative: " + maxWidth); 219 } 220 return TextUtils.ellipsize(text, paint, maxWidth, 221 TextUtils.TruncateAt.END); 222 } 223 224 private Bitmap createSelectedChip(RecipientEntry contact, TextPaint paint, Layout layout) { 225 // Ellipsize the text so that it takes AT MOST the entire width of the 226 // autocomplete text entry area. Make sure to leave space for padding 227 // on the sides. 228 int height = (int) mChipHeight; 229 int deleteWidth = height; 230 CharSequence ellipsizedText = ellipsizeText(contact.getDisplayName(), paint, 231 calculateAvailableWidth(true) - deleteWidth); 232 233 // Make sure there is a minimum chip width so the user can ALWAYS 234 // tap a chip without difficulty. 235 int width = Math.max(deleteWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0, 236 ellipsizedText.length())) 237 + (mChipPadding * 2) + deleteWidth); 238 239 // Create the background of the chip. 240 Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 241 Canvas canvas = new Canvas(tmpBitmap); 242 if (mChipBackgroundPressed != null) { 243 mChipBackgroundPressed.setBounds(0, 0, width, height); 244 mChipBackgroundPressed.draw(canvas); 245 246 // Align the display text with where the user enters text. 247 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height 248 - Math.abs(height - mChipFontSize)/2, paint); 249 // Make the delete a square. 250 mChipDelete.setBounds(width - deleteWidth, 0, width, height); 251 mChipDelete.draw(canvas); 252 } else { 253 Log.w(TAG, "Unable to draw a background for the chips as it was never set"); 254 } 255 return tmpBitmap; 256 } 257 258 259 /** 260 * Get the background drawable for a RecipientChip. 261 */ 262 public Drawable getChipBackground(RecipientEntry contact) { 263 return mValidator != null && mValidator.isValid(contact.getDestination()) ? 264 mChipBackground : mInvalidChipBackground; 265 } 266 267 private Bitmap createUnselectedChip(RecipientEntry contact, TextPaint paint, Layout layout) { 268 // Ellipsize the text so that it takes AT MOST the entire width of the 269 // autocomplete text entry area. Make sure to leave space for padding 270 // on the sides. 271 int height = (int) mChipHeight; 272 int iconWidth = height; 273 CharSequence ellipsizedText = ellipsizeText(contact.getDisplayName(), paint, 274 calculateAvailableWidth(false) - iconWidth); 275 // Make sure there is a minimum chip width so the user can ALWAYS 276 // tap a chip without difficulty. 277 int width = Math.max(iconWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0, 278 ellipsizedText.length())) 279 + (mChipPadding * 2) + iconWidth); 280 281 // Create the background of the chip. 282 Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 283 Canvas canvas = new Canvas(tmpBitmap); 284 Drawable background = getChipBackground(contact); 285 if (background != null) { 286 background.setBounds(0, 0, width, height); 287 background.draw(canvas); 288 289 // Don't draw photos for recipients that have been typed in. 290 if (contact.getContactId() != INVALID_CONTACT) { 291 byte[] photoBytes = contact.getPhotoBytes(); 292 // There may not be a photo yet if anything but the first contact address 293 // was selected. 294 if (photoBytes == null && contact.getPhotoThumbnailUri() != null) { 295 // TODO: cache this in the recipient entry? 296 ((BaseRecipientAdapter) getAdapter()).fetchPhoto(contact, contact 297 .getPhotoThumbnailUri()); 298 photoBytes = contact.getPhotoBytes(); 299 } 300 301 Bitmap photo; 302 if (photoBytes != null) { 303 photo = BitmapFactory.decodeByteArray(photoBytes, 0, photoBytes.length); 304 } else { 305 // TODO: can the scaled down default photo be cached? 306 photo = mDefaultContactPhoto; 307 } 308 // Draw the photo on the left side. 309 Matrix matrix = new Matrix(); 310 RectF src = new RectF(0, 0, photo.getWidth(), photo.getHeight()); 311 RectF dst = new RectF(0, 0, iconWidth, height); 312 matrix.setRectToRect(src, dst, Matrix.ScaleToFit.CENTER); 313 canvas.drawBitmap(photo, matrix, paint); 314 } else { 315 // Don't leave any space for the icon. It isn't being drawn. 316 iconWidth = 0; 317 } 318 319 // Align the display text with where the user enters text. 320 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding + iconWidth, 321 height - Math.abs(height - mChipFontSize) / 2, paint); 322 } else { 323 Log.w(TAG, "Unable to draw a background for the chips as it was never set"); 324 } 325 return tmpBitmap; 326 } 327 328 public RecipientChip constructChipSpan(RecipientEntry contact, int offset, boolean pressed) 329 throws NullPointerException { 330 if (mChipBackground == null) { 331 throw new NullPointerException( 332 "Unable to render any chips as setChipDimensions was not called."); 333 } 334 Layout layout = getLayout(); 335 int line = 0; 336 int lineTop = getTop(); 337 if (layout != null) { 338 line = layout.getLineForOffset(offset); 339 lineTop = layout.getLineTop(line); 340 } 341 342 TextPaint paint = getPaint(); 343 float defaultSize = paint.getTextSize(); 344 345 Bitmap tmpBitmap; 346 if (pressed) { 347 tmpBitmap = createSelectedChip(contact, paint, layout); 348 349 } else { 350 tmpBitmap = createUnselectedChip(contact, paint, layout); 351 } 352 353 // Get the location of the widget so we can properly offset 354 // the anchor for each chip. 355 int[] xy = new int[2]; 356 getLocationOnScreen(xy); 357 // Pass the full text, un-ellipsized, to the chip. 358 Drawable result = new BitmapDrawable(getResources(), tmpBitmap); 359 result.setBounds(0, 0, tmpBitmap.getWidth(), tmpBitmap.getHeight()); 360 Rect bounds = new Rect(xy[0] + offset, xy[1] + lineTop, xy[0] + tmpBitmap.getWidth(), 361 calculateLineBottom(xy[1], line, tmpBitmap.getHeight())); 362 RecipientChip recipientChip = new RecipientChip(result, contact, offset, bounds); 363 // Return text to the original size. 364 paint.setTextSize(defaultSize); 365 366 return recipientChip; 367 } 368 369 /** 370 * Calculate the bottom of the line the chip will be located on using: 371 * 1) which line the chip appears on 372 * 2) the height of a line in the autocomplete view vs the heigt of a chip 373 * 3) padding built into the edit text view will move the bottom position 374 * 4) the position of the autocomplete view on the screen, taking into account 375 * that any top padding will move this down visually 376 */ 377 private int calculateLineBottom(int yOffset, int line, int chipHeight) { 378 int bottomPadding = 0; 379 if (line == getLineCount() - 1) { 380 bottomPadding += getPaddingBottom(); 381 } 382 return ((line + 1) * getLineHeight()) + yOffset + getPaddingTop() 383 + (chipHeight - getLineHeight()) + bottomPadding; 384 } 385 386 /** 387 * Get the max amount of space a chip can take up. The formula takes into 388 * account the width of the EditTextView, any view padding, and padding 389 * that will be added to the chip. 390 */ 391 private float calculateAvailableWidth(boolean pressed) { 392 return getWidth() - getPaddingLeft() - getPaddingRight() - (mChipPadding * 2); 393 } 394 395 /** 396 * Set all chip dimensions and resources. This has to be done from the 397 * application as this is a static library. 398 * @param chipBackground 399 * @param chipBackgroundPressed 400 * @param invalidChip 401 * @param chipDelete 402 * @param defaultContact 403 * @param moreResource 404 * @param alternatesLayout 405 * @param chipHeight 406 * @param padding Padding around the text in a chip 407 */ 408 public void setChipDimensions(Drawable chipBackground, Drawable chipBackgroundPressed, 409 Drawable invalidChip, Drawable chipDelete, Bitmap defaultContact, int moreResource, 410 int alternatesLayout, float chipHeight, float padding, 411 float chipFontSize) { 412 mChipBackground = chipBackground; 413 mChipBackgroundPressed = chipBackgroundPressed; 414 mChipDelete = chipDelete; 415 mChipPadding = (int) padding; 416 mAlternatesLayout = alternatesLayout; 417 mDefaultContactPhoto = defaultContact; 418 mMoreString = moreResource; 419 mChipHeight = chipHeight; 420 mChipFontSize = chipFontSize; 421 mInvalidChipBackground = invalidChip; 422 } 423 424 @Override 425 public void onSizeChanged(int width, int height, int oldw, int oldh) { 426 super.onSizeChanged(width, height, oldw, oldh); 427 // Check for any pending tokens created before layout had been completed on the view. 428 if (width != 0 && height != 0 && mPendingChipsCount > 0) { 429 Editable editable = getText(); 430 // Tokenize! 431 int startingPos = 0; 432 while (startingPos < editable.length() && mPendingChipsCount > 0) { 433 int tokenEnd = mTokenizer.findTokenEnd(editable, startingPos); 434 int tokenStart = mTokenizer.findTokenStart(editable, tokenEnd); 435 // Always include seperators with the token to the left. 436 if (tokenEnd < editable.length()-1 && editable.charAt(tokenEnd) == SEPERATOR) { 437 tokenEnd++; 438 } 439 startingPos = tokenEnd; 440 String token = (String) editable.toString().substring(tokenStart, tokenEnd); 441 int seperatorPos = token.indexOf(SEPERATOR); 442 if (seperatorPos != -1) { 443 token = token.substring(0, seperatorPos); 444 } 445 editable.replace(tokenStart, tokenEnd, createChip(RecipientEntry 446 .constructFakeEntry(token), false)); 447 mPendingChipsCount--; 448 } 449 mPendingChipsCount = 0; 450 } 451 } 452 453 @Override 454 public void setTokenizer(Tokenizer tokenizer) { 455 mTokenizer = tokenizer; 456 super.setTokenizer(mTokenizer); 457 } 458 459 @Override 460 public void setValidator(Validator validator) { 461 mValidator = validator; 462 super.setValidator(validator); 463 } 464 465 /** 466 * We cannot use the default mechanism for replaceText. Instead, 467 * we override onItemClickListener so we can get all the associated 468 * contact information including display text, address, and id. 469 */ 470 @Override 471 protected void replaceText(CharSequence text) { 472 return; 473 } 474 475 /** 476 * Dismiss any selected chips when the back key is pressed. 477 */ 478 @Override 479 public boolean onKeyPreIme(int keyCode, KeyEvent event) { 480 if (keyCode == KeyEvent.KEYCODE_BACK) { 481 clearSelectedChip(); 482 } 483 return super.onKeyPreIme(keyCode, event); 484 } 485 486 /** 487 * Monitor key presses in this view to see if the user types 488 * any commit keys, which consist of ENTER, TAB, or DPAD_CENTER. 489 * If the user has entered text that has contact matches and types 490 * a commit key, create a chip from the topmost matching contact. 491 * If the user has entered text that has no contact matches and types 492 * a commit key, then create a chip from the text they have entered. 493 */ 494 @Override 495 public boolean onKeyUp(int keyCode, KeyEvent event) { 496 switch (keyCode) { 497 case KeyEvent.KEYCODE_ENTER: 498 case KeyEvent.KEYCODE_DPAD_CENTER: 499 case KeyEvent.KEYCODE_TAB: 500 if (event.hasNoModifiers()) { 501 if (commitDefault()) { 502 return true; 503 } 504 } 505 break; 506 } 507 return super.onKeyUp(keyCode, event); 508 } 509 510 /** 511 * Create a chip from the default selection. If the popup is showing, the 512 * default is the first item in the popup suggestions list. Otherwise, it is 513 * whatever the user had typed in. End represents where the the tokenizer 514 * should search for a token to turn into a chip. 515 * @return If a chip was created from a real contact. 516 */ 517 private boolean commitDefault() { 518 Editable editable = getText(); 519 boolean enough = enoughToFilter(); 520 boolean shouldSubmitAtPosition = false; 521 int end = getSelectionEnd(); 522 int start = mTokenizer.findTokenStart(editable, end); 523 if (enough) { 524 RecipientChip[] chips = getSpannable().getSpans(start, end, RecipientChip.class); 525 if ((chips == null || chips.length == 0)) { 526 // There's something being filtered or typed that has not been 527 // completed yet. 528 shouldSubmitAtPosition = true; 529 } 530 } 531 532 if (shouldSubmitAtPosition) { 533 if (getAdapter().getCount() > 0) { 534 // choose the first entry. 535 submitItemAtPosition(0); 536 dismissDropDown(); 537 return true; 538 } else { 539 String text = editable.toString().substring(start, end); 540 clearComposingText(); 541 if (text != null && text.length() > 0 && !text.equals(" ")) { 542 RecipientEntry entry = RecipientEntry.constructFakeEntry(text); 543 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 544 editable.replace(start, end, createChip(entry, false)); 545 dismissDropDown(); 546 } 547 return false; 548 } 549 } 550 return false; 551 } 552 553 /** 554 * If there is a selected chip, delegate the key events 555 * to the selected chip. 556 */ 557 @Override 558 public boolean onKeyDown(int keyCode, KeyEvent event) { 559 if (mSelectedChip != null) { 560 mSelectedChip.onKeyDown(keyCode, event); 561 } 562 563 if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) { 564 return true; 565 } 566 567 return super.onKeyDown(keyCode, event); 568 } 569 570 private Spannable getSpannable() { 571 return (Spannable) getText(); 572 } 573 574 /** 575 * Instead of filtering on the entire contents of the edit box, 576 * this subclass method filters on the range from 577 * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd} 578 * if the length of that range meets or exceeds {@link #getThreshold} 579 * and makes sure that the range is not already a Chip. 580 */ 581 @Override 582 protected void performFiltering(CharSequence text, int keyCode) { 583 if (enoughToFilter()) { 584 int end = getSelectionEnd(); 585 int start = mTokenizer.findTokenStart(text, end); 586 // If this is a RecipientChip, don't filter 587 // on its contents. 588 Spannable span = getSpannable(); 589 RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class); 590 if (chips != null && chips.length > 0) { 591 return; 592 } 593 } 594 super.performFiltering(text, keyCode); 595 } 596 597 private void clearSelectedChip() { 598 if (mSelectedChip != null) { 599 mSelectedChip.unselectChip(); 600 mSelectedChip = null; 601 } 602 setCursorVisible(true); 603 } 604 605 /** 606 * Monitor touch events in the RecipientEditTextView. 607 * If the view does not have focus, any tap on the view 608 * will just focus the view. If the view has focus, determine 609 * if the touch target is a recipient chip. If it is and the chip 610 * is not selected, select it and clear any other selected chips. 611 * If it isn't, then select that chip. 612 */ 613 @Override 614 public boolean onTouchEvent(MotionEvent event) { 615 if (!isFocused()) { 616 // Ignore any chip taps until this view is focused. 617 return super.onTouchEvent(event); 618 } 619 620 boolean handled = super.onTouchEvent(event); 621 int action = event.getAction(); 622 boolean chipWasSelected = false; 623 624 if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { 625 float x = event.getX(); 626 float y = event.getY(); 627 int offset = putOffsetInRange(getOffsetForPosition(x, y)); 628 RecipientChip currentChip = findChip(offset); 629 if (currentChip != null) { 630 if (action == MotionEvent.ACTION_UP) { 631 if (mSelectedChip != null && mSelectedChip != currentChip) { 632 clearSelectedChip(); 633 mSelectedChip = currentChip.selectChip(); 634 } else if (mSelectedChip == null) { 635 // Selection may have moved due to the tap event, 636 // but make sure we correctly reset selection to the 637 // end so that any unfinished chips are committed. 638 setSelection(getText().length()); 639 commitDefault(); 640 mSelectedChip = currentChip.selectChip(); 641 } else { 642 mSelectedChip.onClick(this, offset, x, y); 643 } 644 } 645 chipWasSelected = true; 646 } 647 } 648 if (action == MotionEvent.ACTION_UP && !chipWasSelected) { 649 clearSelectedChip(); 650 } 651 return handled; 652 } 653 654 // TODO: This algorithm will need a lot of tweaking after more people have used 655 // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring 656 // what comes before the finger. 657 private int putOffsetInRange(int o) { 658 int offset = o; 659 Editable text = getText(); 660 int length = text.length(); 661 // Remove whitespace from end to find "real end" 662 int realLength = length; 663 for (int i = length - 1; i >= 0; i--) { 664 if (text.charAt(i) == ' ') { 665 realLength--; 666 } else { 667 break; 668 } 669 } 670 671 // If the offset is beyond or at the end of the text, 672 // leave it alone. 673 if (offset >= realLength) { 674 return offset; 675 } 676 Editable editable = getText(); 677 while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) { 678 // Keep walking backward! 679 offset--; 680 } 681 return offset; 682 } 683 684 private int findText(Editable text, int offset) { 685 if (text.charAt(offset) != ' ') { 686 return offset; 687 } 688 return -1; 689 } 690 691 private RecipientChip findChip(int offset) { 692 RecipientChip[] chips = getSpannable().getSpans(0, getText().length(), RecipientChip.class); 693 // Find the chip that contains this offset. 694 for (int i = 0; i < chips.length; i++) { 695 RecipientChip chip = chips[i]; 696 if (chip.matchesChip(offset)) { 697 return chip; 698 } 699 } 700 return null; 701 } 702 703 private CharSequence createChip(RecipientEntry entry, boolean pressed) { 704 String displayText = entry.getDestination(); 705 displayText = (String) mTokenizer.terminateToken(displayText); 706 // Always leave a blank space at the end of a chip. 707 int textLength = displayText.length() - 1; 708 SpannableString chipText = new SpannableString(displayText); 709 int end = getSelectionEnd(); 710 int start = mTokenizer.findTokenStart(getText(), end); 711 try { 712 chipText.setSpan(constructChipSpan(entry, start, pressed), 0, textLength, 713 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 714 } catch (NullPointerException e) { 715 Log.e(TAG, e.getMessage(), e); 716 return null; 717 } 718 719 return chipText; 720 } 721 722 /** 723 * When an item in the suggestions list has been clicked, create a chip from the 724 * contact information of the selected item. 725 */ 726 @Override 727 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 728 submitItemAtPosition(position); 729 } 730 731 private void submitItemAtPosition(int position) { 732 RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position); 733 // If the display name and the address are the same, then make this 734 // a fake recipient that is editable. 735 if (TextUtils.equals(entry.getDisplayName(), entry.getDestination())) { 736 entry = RecipientEntry.constructFakeEntry(entry.getDestination()); 737 } 738 clearComposingText(); 739 740 int end = getSelectionEnd(); 741 int start = mTokenizer.findTokenStart(getText(), end); 742 743 Editable editable = getText(); 744 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 745 editable.replace(start, end, createChip(entry, false)); 746 } 747 748 /** Returns a collection of contact Id for each chip inside this View. */ 749 /* package */ Collection<Long> getContactIds() { 750 final Set<Long> result = new HashSet<Long>(); 751 RecipientChip[] chips = getRecipients(); 752 if (chips != null) { 753 for (RecipientChip chip : chips) { 754 result.add(chip.getContactId()); 755 } 756 } 757 return result; 758 } 759 760 private RecipientChip[] getRecipients() { 761 return getSpannable().getSpans(0, getText().length(), RecipientChip.class); 762 } 763 764 /** Returns a collection of data Id for each chip inside this View. May be null. */ 765 /* package */ Collection<Long> getDataIds() { 766 final Set<Long> result = new HashSet<Long>(); 767 RecipientChip [] chips = getRecipients(); 768 if (chips != null) { 769 for (RecipientChip chip : chips) { 770 result.add(chip.getDataId()); 771 } 772 } 773 return result; 774 } 775 776 777 @Override 778 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 779 return false; 780 } 781 782 @Override 783 public void onDestroyActionMode(ActionMode mode) { 784 } 785 786 @Override 787 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 788 return false; 789 } 790 791 /** 792 * No chips are selectable. 793 */ 794 @Override 795 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 796 return false; 797 } 798 799 /** 800 * Create the more chip. The more chip is text that replaces any chips that 801 * do not fit in the pre-defined available space when the 802 * RecipientEditTextView loses focus. 803 */ 804 private ImageSpan createMoreChip() { 805 RecipientChip[] recipients = getRecipients(); 806 if (recipients == null || recipients.length <= CHIP_LIMIT) { 807 return null; 808 } 809 int numRecipients = recipients.length; 810 int overage = numRecipients - CHIP_LIMIT; 811 Editable text = getText(); 812 // TODO: get the correct size from visual design. 813 int width = (int) Math.floor(getWidth() * MORE_WIDTH_FACTOR); 814 int height = getLineHeight(); 815 Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 816 Canvas canvas = new Canvas(drawable); 817 String moreText = getResources().getString(mMoreString, overage); 818 canvas.drawText(moreText, 0, moreText.length(), 0, height - getLayout().getLineDescent(0), 819 getPaint()); 820 821 Drawable result = new BitmapDrawable(getResources(), drawable); 822 result.setBounds(0, 0, width, height); 823 ImageSpan moreSpan = new ImageSpan(result); 824 Spannable spannable = getSpannable(); 825 // Remove the overage chips. 826 RecipientChip[] chips = spannable.getSpans(0, text.length(), RecipientChip.class); 827 if (chips == null || chips.length == 0) { 828 Log.w(TAG, 829 "We have recipients. Tt should not be possible to have zero RecipientChips."); 830 return null; 831 } 832 mRemovedSpans = new ArrayList<RecipientChip>(chips.length); 833 int totalReplaceStart = 0; 834 int totalReplaceEnd = 0; 835 for (int i = numRecipients - overage; i < chips.length; i++) { 836 mRemovedSpans.add(chips[i]); 837 if (i == numRecipients - overage) { 838 totalReplaceStart = chips[i].getChipStart(); 839 } 840 if (i == chips.length - 1) { 841 totalReplaceEnd = chips[i].getChipEnd(); 842 } 843 chips[i].setPreviousChipStart(chips[i].getChipStart()); 844 chips[i].setPreviousChipEnd(chips[i].getChipEnd()); 845 spannable.removeSpan(chips[i]); 846 } 847 SpannableString chipText = new SpannableString(text.subSequence(totalReplaceStart, 848 totalReplaceEnd)); 849 chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 850 text.replace(totalReplaceStart, totalReplaceEnd, chipText); 851 return moreSpan; 852 } 853 854 /** 855 * Replace the more chip, if it exists, with all of the recipient chips it had 856 * replaced when the RecipientEditTextView gains focus. 857 */ 858 private void removeMoreChip() { 859 if (mMoreChip != null) { 860 Spannable span = getSpannable(); 861 span.removeSpan(mMoreChip); 862 mMoreChip = null; 863 // Re-add the spans that were removed. 864 if (mRemovedSpans != null && mRemovedSpans.size() > 0) { 865 // Recreate each removed span. 866 Editable editable = getText(); 867 SpannableString associatedText; 868 for (RecipientChip chip : mRemovedSpans) { 869 int chipStart = chip.getPreviousChipStart(); 870 int chipEnd = Math.min(editable.length(), chip.getPreviousChipEnd()); 871 if (Log.isLoggable(TAG, Log.DEBUG) && chipEnd != chip.getPreviousChipEnd()) { 872 Log.d(TAG, 873 "Unexpectedly, the chip ended after the end of the editable text. " 874 + "Chip End " + chip.getPreviousChipEnd() 875 + "Editable length " + editable.length()); 876 } 877 associatedText = new SpannableString(editable.subSequence(chipStart, chipEnd)); 878 associatedText.setSpan(chip, 0, associatedText.length(), 879 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 880 editable.replace(chipStart, chipEnd, associatedText); 881 } 882 mRemovedSpans.clear(); 883 } 884 } 885 } 886 887 /** 888 * RecipientChip defines an ImageSpan that contains information relevant to 889 * a particular recipient. 890 */ 891 public class RecipientChip extends ImageSpan implements OnItemClickListener { 892 private final CharSequence mDisplay; 893 894 private final CharSequence mValue; 895 896 private View mAnchorView; 897 898 private int mLeft; 899 900 private final long mContactId; 901 902 private final long mDataId; 903 904 private RecipientEntry mEntry; 905 906 private boolean mSelected = false; 907 908 private RecipientAlternatesAdapter mAlternatesAdapter; 909 910 private Rect mBounds; 911 912 private int mStart = -1; 913 914 private int mEnd = -1; 915 916 private ListPopupWindow mAlternatesPopup; 917 918 public RecipientChip(Drawable drawable, RecipientEntry entry, int offset, Rect bounds) { 919 super(drawable); 920 mDisplay = entry.getDisplayName(); 921 mValue = entry.getDestination(); 922 mContactId = entry.getContactId(); 923 mDataId = entry.getDataId(); 924 mEntry = entry; 925 mBounds = bounds; 926 927 mAnchorView = new View(getContext()); 928 mAnchorView.setLeft(bounds.left); 929 mAnchorView.setRight(bounds.left); 930 mAnchorView.setTop(bounds.bottom); 931 mAnchorView.setBottom(bounds.bottom); 932 mAnchorView.setVisibility(View.GONE); 933 } 934 935 /** 936 * Store the offset in the spannable where this RecipientChip 937 * is currently being displayed. 938 */ 939 public void setPreviousChipStart(int start) { 940 mStart = start; 941 } 942 943 /** 944 * Get the offset in the spannable where this RecipientChip 945 * was currently being displayed. Use this to determine where 946 * to place a RecipientChip that has been hidden when the 947 * RecipientEditTextView loses focus. 948 */ 949 public int getPreviousChipStart() { 950 return mStart; 951 } 952 953 /** 954 * Store the end offset in the spannable where this RecipientChip 955 * is currently being displayed. 956 */ 957 public void setPreviousChipEnd(int end) { 958 mEnd = end; 959 } 960 961 /** 962 * Get the end offset in the spannable where this RecipientChip 963 * was currently being displayed. Use this to determine where 964 * to place a RecipientChip that has been hidden when the 965 * RecipientEditTextView loses focus. 966 */ 967 public int getPreviousChipEnd() { 968 return mEnd; 969 } 970 971 /** 972 * Remove selection from this chip. Unselecting a RecipientChip will render 973 * the chip without a delete icon and with an unfocused background. This 974 * is called when the RecipientChip not longer has focus. 975 */ 976 public void unselectChip() { 977 int start = getChipStart(); 978 int end = getChipEnd(); 979 Editable editable = getText(); 980 if (start == -1 || end == -1) { 981 Log.e(TAG, "The chip being unselected no longer exists but should."); 982 } else { 983 getSpannable().removeSpan(this); 984 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 985 editable.replace(start, end, createChip(mEntry, false)); 986 } 987 mSelectedChip = null; 988 clearSelectedChip(); 989 setCursorVisible(true); 990 setSelection(editable.length()); 991 } 992 993 /** 994 * Show this chip as selected. If the RecipientChip is just an email address, 995 * selecting the chip will take the contents of the chip and place it at 996 * the end of the RecipientEditTextView for inline editing. If the 997 * RecipientChip is a complete contact, then selecting the chip 998 * will change the background color of the chip, show the delete icon, 999 * and a popup window with the address in use highlighted and any other 1000 * alternate addresses for the contact. 1001 * @return A RecipientChip in the selected state or null if the chip 1002 * just contained an email address. 1003 */ 1004 public RecipientChip selectChip() { 1005 if (mEntry.getContactId() != INVALID_CONTACT) { 1006 int start = getChipStart(); 1007 int end = getChipEnd(); 1008 getSpannable().removeSpan(this); 1009 RecipientChip newChip; 1010 CharSequence displayText = mTokenizer.terminateToken(mEntry.getDestination()); 1011 // Always leave a blank space at the end of a chip. 1012 int textLength = displayText.length() - 1; 1013 SpannableString chipText = new SpannableString(displayText); 1014 try { 1015 newChip = constructChipSpan(mEntry, start, true); 1016 chipText.setSpan(newChip, 0, textLength, 1017 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1018 } catch (NullPointerException e) { 1019 Log.e(TAG, e.getMessage(), e); 1020 return null; 1021 } 1022 Editable editable = getText(); 1023 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1024 if (start == -1 || end == -1) { 1025 Log.d(TAG, "The chip being selected no longer exists but should."); 1026 } else { 1027 editable.replace(start, end, chipText); 1028 } 1029 setCursorVisible(false); 1030 newChip.setSelected(true); 1031 newChip.showAlternates(); 1032 setCursorVisible(false); 1033 return newChip; 1034 } else { 1035 CharSequence text = getValue(); 1036 Editable editable = getText(); 1037 removeChip(); 1038 editable.append(text); 1039 setCursorVisible(true); 1040 setSelection(editable.length()); 1041 return null; 1042 } 1043 } 1044 1045 /** 1046 * Handle key events for a chip. When the keyCode equals 1047 * KeyEvent.KEYCODE_DEL, this deletes the currently selected chip. 1048 */ 1049 public void onKeyDown(int keyCode, KeyEvent event) { 1050 if (keyCode == KeyEvent.KEYCODE_DEL) { 1051 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 1052 mAlternatesPopup.dismiss(); 1053 } 1054 removeChip(); 1055 } 1056 } 1057 1058 /** 1059 * Remove this chip and any text associated with it from the RecipientEditTextView. 1060 */ 1061 private void removeChip() { 1062 Spannable spannable = getSpannable(); 1063 int spanStart = spannable.getSpanStart(this); 1064 int spanEnd = spannable.getSpanEnd(this); 1065 Editable text = getText(); 1066 int toDelete = spanEnd; 1067 boolean wasSelected = this == mSelectedChip; 1068 // Clear that there is a selected chip before updating any text. 1069 if (wasSelected) { 1070 mSelectedChip = null; 1071 } 1072 // Always remove trailing spaces when removing a chip. 1073 while (toDelete >= 0 && toDelete < text.length() - 1 && text.charAt(toDelete) == ' ') { 1074 toDelete++; 1075 } 1076 spannable.removeSpan(this); 1077 text.delete(spanStart, toDelete); 1078 if (wasSelected) { 1079 clearSelectedChip(); 1080 } 1081 } 1082 1083 /** 1084 * Get the start offset of this chip in the view. 1085 */ 1086 public int getChipStart() { 1087 return getSpannable().getSpanStart(this); 1088 } 1089 1090 /** 1091 * Get the end offset of this chip in the view. 1092 */ 1093 public int getChipEnd() { 1094 return getSpannable().getSpanEnd(this); 1095 } 1096 1097 /** 1098 * Replace this currently selected chip with a new chip 1099 * that uses the contact data provided. 1100 */ 1101 public void replaceChip(RecipientEntry entry) { 1102 boolean wasSelected = this == mSelectedChip; 1103 if (wasSelected) { 1104 mSelectedChip = null; 1105 } 1106 int start = getSpannable().getSpanStart(this); 1107 int end = getSpannable().getSpanEnd(this); 1108 getSpannable().removeSpan(this); 1109 Editable editable = getText(); 1110 CharSequence chipText = createChip(entry, false); 1111 if (start == -1 || end == -1) { 1112 Log.e(TAG, "The chip to replace does not exist but should."); 1113 editable.insert(0, chipText); 1114 } else { 1115 editable.replace(start, end, chipText); 1116 } 1117 setCursorVisible(true); 1118 if (wasSelected) { 1119 clearSelectedChip(); 1120 } 1121 } 1122 1123 /** 1124 * Show all addresses associated with a contact. 1125 */ 1126 private void showAlternates() { 1127 mAlternatesPopup = new ListPopupWindow(getContext()); 1128 1129 if (!mAlternatesPopup.isShowing()) { 1130 mAlternatesAdapter = new RecipientAlternatesAdapter(getContext(), 1131 mEntry.getContactId(), mEntry.getDataId(), mAlternatesLayout); 1132 mAnchorView.setLeft(mLeft); 1133 mAnchorView.setRight(mLeft); 1134 mAlternatesPopup.setAnchorView(mAnchorView); 1135 mAlternatesPopup.setAdapter(mAlternatesAdapter); 1136 mAlternatesPopup.setWidth(getWidth()); 1137 mAlternatesPopup.setOnItemClickListener(this); 1138 mAlternatesPopup.show(); 1139 ListView listView = mAlternatesPopup.getListView(); 1140 listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); 1141 listView.setItemChecked(mAlternatesAdapter.getCheckedItemPosition(), true); 1142 } 1143 } 1144 1145 private void setSelected(boolean selected) { 1146 mSelected = selected; 1147 } 1148 1149 /** 1150 * Get the text displayed in the chip. 1151 */ 1152 public CharSequence getDisplay() { 1153 return mDisplay; 1154 } 1155 1156 /** 1157 * Get the text value this chip represents. 1158 */ 1159 public CharSequence getValue() { 1160 return mValue; 1161 } 1162 1163 /** 1164 * See if a touch event was inside the delete target of 1165 * a selected chip. It is in the delete target if: 1166 * 1) the x and y points of the event are within the 1167 * delete assset. 1168 * 2) the point tapped would have caused a cursor to appear 1169 * right after the selected chip. 1170 */ 1171 private boolean isInDelete(int offset, float x, float y) { 1172 // Figure out the bounds of this chip and whether or not 1173 // the user clicked in the X portion. 1174 return mSelected 1175 && (offset == getChipEnd() 1176 || (x > (mBounds.right - mChipDeleteWidth) && x < mBounds.right)); 1177 } 1178 1179 /** 1180 * Return whether this chip contains the position passed in. 1181 */ 1182 public boolean matchesChip(int offset) { 1183 int start = getChipStart(); 1184 int end = getChipEnd(); 1185 return (offset >= start && offset <= end); 1186 } 1187 1188 /** 1189 * Handle click events for a chip. When a selected chip receives a click 1190 * event, see if that event was in the delete icon. If so, delete it. 1191 * Otherwise, unselect the chip. 1192 */ 1193 public void onClick(View widget, int offset, float x, float y) { 1194 if (mSelected) { 1195 if (isInDelete(offset, x, y)) { 1196 removeChip(); 1197 } else { 1198 clearSelectedChip(); 1199 } 1200 } 1201 } 1202 1203 @Override 1204 public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, 1205 int y, int bottom, Paint paint) { 1206 // Shift the bounds of this span to where it is actually drawn on the screeen. 1207 mLeft = (int) x; 1208 super.draw(canvas, text, start, end, x, top, y, bottom, paint); 1209 } 1210 1211 /** 1212 * Handle clicks to alternate addresses for a selected chip. If the user 1213 * selects an alternate, the chip is replaced with a new contact with the 1214 * new contact address information. 1215 */ 1216 @Override 1217 public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) { 1218 Message delayed = Message.obtain(mHandler, DISMISS); 1219 delayed.obj = mAlternatesPopup; 1220 mHandler.sendMessageDelayed(delayed, DISMISS_DELAY); 1221 clearComposingText(); 1222 replaceChip(mAlternatesAdapter.getRecipientEntry(position)); 1223 } 1224 1225 /** 1226 * Get the id of the contact associated with this chip. 1227 */ 1228 public long getContactId() { 1229 return mContactId; 1230 } 1231 1232 /** 1233 * Get the id of the data associated with this chip. 1234 */ 1235 public long getDataId() { 1236 return mDataId; 1237 } 1238 } 1239} 1240