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