RecipientEditTextView.java revision 2cac23112a97802d460bffe41fa80a939bf730a5
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.Rect; 25import android.graphics.RectF; 26import android.graphics.drawable.BitmapDrawable; 27import android.graphics.drawable.Drawable; 28import android.os.AsyncTask; 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.SpannableStringBuilder; 36import android.text.Spanned; 37import android.text.TextPaint; 38import android.text.TextUtils; 39import android.text.TextWatcher; 40import android.text.method.QwertyKeyListener; 41import android.text.style.ImageSpan; 42import android.text.util.Rfc822Token; 43import android.text.util.Rfc822Tokenizer; 44import android.util.AttributeSet; 45import android.util.Log; 46import android.view.ActionMode; 47import android.view.KeyEvent; 48import android.view.Menu; 49import android.view.MenuItem; 50import android.view.MotionEvent; 51import android.view.View; 52import android.view.ViewParent; 53import android.view.ActionMode.Callback; 54import android.widget.AdapterView; 55import android.widget.AdapterView.OnItemClickListener; 56import android.widget.Filterable; 57import android.widget.ListAdapter; 58import android.widget.ListPopupWindow; 59import android.widget.ListView; 60import android.widget.MultiAutoCompleteTextView; 61import android.widget.ScrollView; 62 63import java.util.Collection; 64import java.util.HashMap; 65import java.util.HashSet; 66import java.util.Set; 67 68import java.util.ArrayList; 69 70/** 71 * RecipientEditTextView is an auto complete text view for use with applications 72 * that use the new Chips UI for addressing a message to recipients. 73 */ 74public class RecipientEditTextView extends MultiAutoCompleteTextView implements 75 OnItemClickListener, Callback, RecipientAlternatesAdapter.OnCheckedItemChangedListener { 76 77 private static final String TAG = "RecipientEditTextView"; 78 79 // TODO: get correct number/ algorithm from with UX. 80 private static final int CHIP_LIMIT = 2; 81 82 // TODO: get correct size from UX. 83 private static final float MORE_WIDTH_FACTOR = 0.25f; 84 85 private Drawable mChipBackground = null; 86 87 private Drawable mChipDelete = null; 88 89 private int mChipPadding; 90 91 private Tokenizer mTokenizer; 92 93 private Drawable mChipBackgroundPressed; 94 95 private RecipientChip mSelectedChip; 96 97 private int mAlternatesLayout; 98 99 private Bitmap mDefaultContactPhoto; 100 101 private ImageSpan mMoreChip; 102 103 private int mMoreString; 104 105 106 private final ArrayList<String> mPendingChips = new ArrayList<String>(); 107 108 private float mChipHeight; 109 110 private float mChipFontSize; 111 112 private Validator mValidator; 113 114 private Drawable mInvalidChipBackground; 115 116 private Handler mHandler; 117 118 private static int DISMISS = "dismiss".hashCode(); 119 120 private static final long DISMISS_DELAY = 300; 121 122 private int mPendingChipsCount = 0; 123 124 private static int sSelectedTextColor = -1; 125 126 private static final char COMMIT_CHAR_COMMA = ','; 127 128 private static final char COMMIT_CHAR_SEMICOLON = ';'; 129 130 private static final char COMMIT_CHAR_SPACE = ' '; 131 132 private ListPopupWindow mAlternatesPopup; 133 134 private ListPopupWindow mAddressPopup; 135 136 private ArrayList<RecipientChip> mTemporaryRecipients; 137 138 private ArrayList<RecipientChip> mRemovedSpans; 139 140 /** 141 * Used with {@link mAlternatesPopup}. Handles clicks to alternate addresses for a selected chip. 142 */ 143 private OnItemClickListener mAlternatesListener; 144 145 private int mCheckedItem; 146 private TextWatcher mTextWatcher; 147 148 private ScrollView mScrollView; 149 150 private boolean mTried; 151 152 private final Runnable mAddTextWatcher = new Runnable() { 153 @Override 154 public void run() { 155 if (mTextWatcher == null) { 156 mTextWatcher = new RecipientTextWatcher(); 157 addTextChangedListener(mTextWatcher); 158 } 159 } 160 }; 161 162 private IndividualReplacementTask mIndividualReplacements; 163 164 private Runnable mHandlePendingChips = new Runnable() { 165 166 @Override 167 public void run() { 168 handlePendingChips(); 169 mHandler.post(mAddTextWatcher); 170 } 171 172 }; 173 174 public RecipientEditTextView(Context context, AttributeSet attrs) { 175 super(context, attrs); 176 if (sSelectedTextColor == -1) { 177 sSelectedTextColor = context.getResources().getColor(android.R.color.white); 178 } 179 mAlternatesPopup = new ListPopupWindow(context); 180 mAddressPopup = new ListPopupWindow(context); 181 mAlternatesListener = new OnItemClickListener() { 182 @Override 183 public void onItemClick(AdapterView<?> adapterView,View view, int position, 184 long rowId) { 185 mAlternatesPopup.setOnItemClickListener(null); 186 replaceChip(mSelectedChip, ((RecipientAlternatesAdapter) adapterView.getAdapter()) 187 .getRecipientEntry(position)); 188 Message delayed = Message.obtain(mHandler, DISMISS); 189 delayed.obj = mAlternatesPopup; 190 mHandler.sendMessageDelayed(delayed, DISMISS_DELAY); 191 clearComposingText(); 192 } 193 }; 194 setSuggestionsEnabled(false); 195 setOnItemClickListener(this); 196 setCustomSelectionActionModeCallback(this); 197 mHandler = new Handler() { 198 @Override 199 public void handleMessage(Message msg) { 200 if (msg.what == DISMISS) { 201 ((ListPopupWindow) msg.obj).dismiss(); 202 return; 203 } 204 super.handleMessage(msg); 205 } 206 }; 207 } 208 209 @Override 210 public <T extends ListAdapter & Filterable> void setAdapter(T adapter) { 211 super.setAdapter(adapter); 212 if (adapter == null) { 213 return; 214 } 215 } 216 217 @Override 218 public void onSelectionChanged(int start, int end) { 219 // When selection changes, see if it is inside the chips area. 220 // If so, move the cursor back after the chips again. 221 Spannable span = getSpannable(); 222 int textLength = getText().length(); 223 RecipientChip[] chips = span.getSpans(start, textLength, RecipientChip.class); 224 if (chips != null && chips.length > 0) { 225 if (chips != null && chips.length > 0) { 226 // Grab the last chip and set the cursor to after it. 227 setSelection(Math.min(span.getSpanEnd(chips[chips.length - 1]) + 1, textLength)); 228 } 229 } 230 super.onSelectionChanged(start, end); 231 } 232 233 /** 234 * Convenience method: Append the specified text slice to the TextView's 235 * display buffer, upgrading it to BufferType.EDITABLE if it was 236 * not already editable. Commas are excluded as they are added automatically 237 * by the view. 238 */ 239 @Override 240 public void append(CharSequence text, int start, int end) { 241 // We don't care about watching text changes while appending. 242 if (mTextWatcher != null) { 243 removeTextChangedListener(mTextWatcher); 244 } 245 super.append(text, start, end); 246 if (!TextUtils.isEmpty(text) && TextUtils.getTrimmedLength(text) > 0) { 247 final String displayString = (String) text; 248 int seperatorPos = displayString.indexOf(COMMIT_CHAR_COMMA); 249 if (seperatorPos != 0 && !TextUtils.isEmpty(displayString) 250 && TextUtils.getTrimmedLength(displayString) > 0) { 251 mPendingChipsCount++; 252 mPendingChips.add((String)text); 253 } 254 } 255 // Put a message on the queue to make sure we ALWAYS handle pending chips. 256 if (mPendingChipsCount > 0) { 257 postHandlePendingChips(); 258 } 259 } 260 261 @Override 262 public void onFocusChanged(boolean hasFocus, int direction, Rect previous) { 263 if (!hasFocus) { 264 shrink(); 265 } else { 266 expand(); 267 scrollLineIntoView(getLineCount()); 268 } 269 super.onFocusChanged(hasFocus, direction, previous); 270 } 271 272 private void shrink() { 273 if (mSelectedChip != null) { 274 clearSelectedChip(); 275 } else { 276 // Reset any pending chips as they would have been handled 277 // when the field lost focus. 278 if (mPendingChipsCount > 0) { 279 postHandlePendingChips(); 280 } else { 281 Editable editable = getText(); 282 int end = getSelectionEnd(); 283 int start = mTokenizer.findTokenStart(editable, end); 284 RecipientChip[] chips = getSpannable().getSpans(start, end, RecipientChip.class); 285 if ((chips == null || chips.length == 0)) { 286 int whatEnd = mTokenizer.findTokenEnd(getText(), start); 287 // In the middle of chip; treat this as an edit 288 // and commit the whole token. 289 if (whatEnd != getSelectionEnd()) { 290 handleEdit(start, whatEnd); 291 } else { 292 commitChip(start, end, editable); 293 } 294 } 295 } 296 mHandler.post(mAddTextWatcher); 297 } 298 createMoreChip(); 299 } 300 301 private void expand() { 302 removeMoreChip(); 303 setCursorVisible(true); 304 Editable text = getText(); 305 setSelection(text != null && text.length() > 0 ? text.length() : 0); 306 // If there are any temporary chips, try replacing them now that the user 307 // has expanded the field. 308 if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0) { 309 new RecipientReplacementTask().execute(); 310 mTemporaryRecipients = null; 311 } 312 } 313 314 private CharSequence ellipsizeText(CharSequence text, TextPaint paint, float maxWidth) { 315 paint.setTextSize(mChipFontSize); 316 if (maxWidth <= 0 && Log.isLoggable(TAG, Log.DEBUG)) { 317 Log.d(TAG, "Max width is negative: " + maxWidth); 318 } 319 return TextUtils.ellipsize(text, paint, maxWidth, 320 TextUtils.TruncateAt.END); 321 } 322 323 private Bitmap createSelectedChip(RecipientEntry contact, TextPaint paint, Layout layout) { 324 // Ellipsize the text so that it takes AT MOST the entire width of the 325 // autocomplete text entry area. Make sure to leave space for padding 326 // on the sides. 327 int height = (int) mChipHeight; 328 int deleteWidth = height; 329 CharSequence ellipsizedText = ellipsizeText(contact.getDisplayName(), paint, 330 calculateAvailableWidth(true) - deleteWidth); 331 332 // Make sure there is a minimum chip width so the user can ALWAYS 333 // tap a chip without difficulty. 334 int width = Math.max(deleteWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0, 335 ellipsizedText.length())) 336 + (mChipPadding * 2) + deleteWidth); 337 338 // Create the background of the chip. 339 Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 340 Canvas canvas = new Canvas(tmpBitmap); 341 if (mChipBackgroundPressed != null) { 342 mChipBackgroundPressed.setBounds(0, 0, width, height); 343 mChipBackgroundPressed.draw(canvas); 344 paint.setColor(sSelectedTextColor); 345 // Align the display text with where the user enters text. 346 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height 347 - Math.abs(height - mChipFontSize)/2, paint); 348 // Make the delete a square. 349 mChipDelete.setBounds(width - deleteWidth, 0, width, height); 350 mChipDelete.draw(canvas); 351 } else { 352 Log.w(TAG, "Unable to draw a background for the chips as it was never set"); 353 } 354 return tmpBitmap; 355 } 356 357 358 /** 359 * Get the background drawable for a RecipientChip. 360 */ 361 public Drawable getChipBackground(RecipientEntry contact) { 362 return (mValidator != null && mValidator.isValid(contact.getDestination())) ? 363 mChipBackground : mInvalidChipBackground; 364 } 365 366 private Bitmap createUnselectedChip(RecipientEntry contact, TextPaint paint, Layout layout) { 367 // Ellipsize the text so that it takes AT MOST the entire width of the 368 // autocomplete text entry area. Make sure to leave space for padding 369 // on the sides. 370 int height = (int) mChipHeight; 371 int iconWidth = height; 372 CharSequence ellipsizedText = ellipsizeText(contact.getDisplayName(), paint, 373 calculateAvailableWidth(false) - iconWidth); 374 // Make sure there is a minimum chip width so the user can ALWAYS 375 // tap a chip without difficulty. 376 int width = Math.max(iconWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0, 377 ellipsizedText.length())) 378 + (mChipPadding * 2) + iconWidth); 379 380 // Create the background of the chip. 381 Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 382 Canvas canvas = new Canvas(tmpBitmap); 383 Drawable background = getChipBackground(contact); 384 if (background != null) { 385 background.setBounds(0, 0, width, height); 386 background.draw(canvas); 387 388 // Don't draw photos for recipients that have been typed in. 389 if (contact.getContactId() != RecipientEntry.INVALID_CONTACT) { 390 byte[] photoBytes = contact.getPhotoBytes(); 391 // There may not be a photo yet if anything but the first contact address 392 // was selected. 393 if (photoBytes == null && contact.getPhotoThumbnailUri() != null) { 394 // TODO: cache this in the recipient entry? 395 ((BaseRecipientAdapter) getAdapter()).fetchPhoto(contact, contact 396 .getPhotoThumbnailUri()); 397 photoBytes = contact.getPhotoBytes(); 398 } 399 400 Bitmap photo; 401 if (photoBytes != null) { 402 photo = BitmapFactory.decodeByteArray(photoBytes, 0, photoBytes.length); 403 } else { 404 // TODO: can the scaled down default photo be cached? 405 photo = mDefaultContactPhoto; 406 } 407 // Draw the photo on the left side. 408 Matrix matrix = new Matrix(); 409 RectF src = new RectF(0, 0, photo.getWidth(), photo.getHeight()); 410 RectF dst = new RectF(width - iconWidth, 0, width, height); 411 matrix.setRectToRect(src, dst, Matrix.ScaleToFit.CENTER); 412 canvas.drawBitmap(photo, matrix, paint); 413 } else { 414 // Don't leave any space for the icon. It isn't being drawn. 415 iconWidth = 0; 416 } 417 418 // Align the display text with where the user enters text. 419 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, 420 height - Math.abs(height - mChipFontSize) / 2, paint); 421 } else { 422 Log.w(TAG, "Unable to draw a background for the chips as it was never set"); 423 } 424 return tmpBitmap; 425 } 426 427 public RecipientChip constructChipSpan(RecipientEntry contact, int offset, boolean pressed) 428 throws NullPointerException { 429 if (mChipBackground == null) { 430 throw new NullPointerException( 431 "Unable to render any chips as setChipDimensions was not called."); 432 } 433 Layout layout = getLayout(); 434 435 TextPaint paint = getPaint(); 436 float defaultSize = paint.getTextSize(); 437 int defaultColor = paint.getColor(); 438 439 Bitmap tmpBitmap; 440 if (pressed) { 441 tmpBitmap = createSelectedChip(contact, paint, layout); 442 443 } else { 444 tmpBitmap = createUnselectedChip(contact, paint, layout); 445 } 446 447 // Pass the full text, un-ellipsized, to the chip. 448 Drawable result = new BitmapDrawable(getResources(), tmpBitmap); 449 result.setBounds(0, 0, tmpBitmap.getWidth(), tmpBitmap.getHeight()); 450 RecipientChip recipientChip = new RecipientChip(result, contact, offset); 451 // Return text to the original size. 452 paint.setTextSize(defaultSize); 453 paint.setColor(defaultColor); 454 return recipientChip; 455 } 456 457 /** 458 * Calculate the bottom of the line the chip will be located on using: 459 * 1) which line the chip appears on 460 * 2) the height of a chip 461 * 3) padding built into the edit text view 462 */ 463 private int calculateOffsetFromBottom(int line) { 464 // Line offsets start at zero. 465 int actualLine = getLineCount() - (line + 1); 466 return -((actualLine * ((int)mChipHeight) + getPaddingBottom()) + getPaddingTop()); 467 } 468 469 /** 470 * Get the max amount of space a chip can take up. The formula takes into 471 * account the width of the EditTextView, any view padding, and padding 472 * that will be added to the chip. 473 */ 474 private float calculateAvailableWidth(boolean pressed) { 475 return getWidth() - getPaddingLeft() - getPaddingRight() - (mChipPadding * 2); 476 } 477 478 /** 479 * Set all chip dimensions and resources. This has to be done from the 480 * application as this is a static library. 481 * @param chipBackground 482 * @param chipBackgroundPressed 483 * @param invalidChip 484 * @param chipDelete 485 * @param defaultContact 486 * @param moreResource 487 * @param alternatesLayout 488 * @param chipHeight 489 * @param padding Padding around the text in a chip 490 */ 491 public void setChipDimensions(Drawable chipBackground, Drawable chipBackgroundPressed, 492 Drawable invalidChip, Drawable chipDelete, Bitmap defaultContact, int moreResource, 493 int alternatesLayout, float chipHeight, float padding, 494 float chipFontSize) { 495 mChipBackground = chipBackground; 496 mChipBackgroundPressed = chipBackgroundPressed; 497 mChipDelete = chipDelete; 498 mChipPadding = (int) padding; 499 mAlternatesLayout = alternatesLayout; 500 mDefaultContactPhoto = defaultContact; 501 mMoreString = moreResource; 502 mChipHeight = chipHeight; 503 mChipFontSize = chipFontSize; 504 mInvalidChipBackground = invalidChip; 505 } 506 507 @Override 508 public void onSizeChanged(int width, int height, int oldw, int oldh) { 509 super.onSizeChanged(width, height, oldw, oldh); 510 // Try to find the scroll view parent, if it exists. 511 if (mScrollView == null && !mTried) { 512 ViewParent parent = getParent(); 513 while (parent != null && !(parent instanceof ScrollView)) { 514 parent = parent.getParent(); 515 } 516 if (parent != null) { 517 mScrollView = (ScrollView) parent; 518 } 519 mTried = true; 520 } 521 } 522 523 private void postHandlePendingChips() { 524 mHandler.removeCallbacks(mHandlePendingChips); 525 mHandler.post(mHandlePendingChips); 526 } 527 528 private void handlePendingChips() { 529 if (mPendingChipsCount <= 0) { 530 return; 531 } 532 synchronized (mPendingChips) { 533 mTemporaryRecipients = new ArrayList<RecipientChip>(mPendingChipsCount); 534 Editable editable = getText(); 535 // Tokenize! 536 for (int i = 0; i < mPendingChips.size(); i++) { 537 String current = mPendingChips.get(i); 538 int tokenStart = editable.toString().indexOf(current); 539 int tokenEnd = tokenStart + current.length(); 540 if (tokenStart >= 0) { 541 // When we have a valid token, include it with the token 542 // to the left. 543 if (tokenEnd < editable.length() - 2 544 && editable.charAt(tokenEnd) == COMMIT_CHAR_COMMA) { 545 tokenEnd++; 546 } 547 createReplacementChip(tokenStart, tokenEnd, editable); 548 } 549 mPendingChipsCount--; 550 } 551 sanitizeSpannable(); 552 if (mTemporaryRecipients != null 553 && mTemporaryRecipients.size() <= RecipientAlternatesAdapter.MAX_LOOKUPS) { 554 if (hasFocus() || mTemporaryRecipients.size() < CHIP_LIMIT) { 555 new RecipientReplacementTask().execute(); 556 mTemporaryRecipients = null; 557 } else { 558 // Create the "more" chip 559 mIndividualReplacements = new IndividualReplacementTask(); 560 mIndividualReplacements.execute(new ArrayList<RecipientChip>( 561 mTemporaryRecipients.subList(0, CHIP_LIMIT))); 562 563 createMoreChip(); 564 } 565 } else { 566 // There are too many recipients to look up, so just fall back 567 // to 568 // showing addresses for all of them. 569 mTemporaryRecipients = null; 570 createMoreChip(); 571 } 572 mPendingChipsCount = 0; 573 mPendingChips.clear(); 574 } 575 } 576 577 /** 578 * Remove any characters after the last valid chip. 579 */ 580 private void sanitizeSpannable() { 581 // Find the last chip; eliminate any commit characters after it. 582 RecipientChip[] chips = getRecipients(); 583 if (chips != null && chips.length > 0) { 584 int end; 585 ImageSpan lastSpan; 586 if (mMoreChip != null) { 587 lastSpan = mMoreChip; 588 } else { 589 lastSpan = chips[chips.length - 1]; 590 } 591 end = getSpannable().getSpanEnd(lastSpan); 592 Editable editable = getText(); 593 int length = editable.length(); 594 if (length > end) { 595 // See what characters occur after that and eliminate them. 596 if (Log.isLoggable(TAG, Log.DEBUG)) { 597 Log.d(TAG, "There were extra characters after the last tokenizable entry." 598 + editable); 599 } 600 editable.delete(end + 1, length); 601 } 602 } 603 } 604 605 /** 606 * Create a chip that represents just the email address of a recipient. At some later 607 * point, this chip will be attached to a real contact entry, if one exists. 608 */ 609 private void createReplacementChip(int tokenStart, int tokenEnd, Editable editable) { 610 String token = editable.toString().substring(tokenStart, tokenEnd); 611 int commitCharIndex = token.indexOf(COMMIT_CHAR_COMMA); 612 if (commitCharIndex == token.length() - 1) { 613 token = token.substring(0, token.length() - 1); 614 } 615 RecipientEntry entry = createTokenizedEntry(token); 616 String displayText = entry.getDestination(); 617 displayText = (String) mTokenizer.terminateToken(displayText); 618 // Always leave a blank space at the end of a chip. 619 int textLength = displayText.length() - 1; 620 SpannableString chipText = new SpannableString(displayText); 621 int end = getSelectionEnd(); 622 int start = mTokenizer.findTokenStart(getText(), end); 623 RecipientChip chip = null; 624 try { 625 chip = constructChipSpan(entry, start, false); 626 chipText.setSpan(chip, 0, textLength, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 627 } catch (NullPointerException e) { 628 Log.e(TAG, e.getMessage(), e); 629 } 630 631 editable.replace(tokenStart, tokenEnd, chipText); 632 // Add this chip to the list of entries "to replace" 633 if (chip != null) { 634 mTemporaryRecipients.add(chip); 635 } 636 } 637 638 private RecipientEntry createTokenizedEntry(String token) { 639 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(token); 640 String display = null; 641 if (tokens != null && tokens.length > 0) { 642 display = tokens[0].getName(); 643 if (!TextUtils.isEmpty(display)) { 644 return RecipientEntry.constructGeneratedEntry(display, token); 645 } 646 display = tokens[0].getAddress(); 647 if (!TextUtils.isEmpty(display)) { 648 return RecipientEntry.constructGeneratedEntry(display, token); 649 } 650 } 651 return RecipientEntry.constructFakeEntry(token); 652 } 653 654 private String tokenizeAddress(String destination) { 655 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(destination); 656 if (tokens != null && tokens.length > 0) { 657 return tokens[0].getAddress(); 658 } 659 return destination; 660 } 661 662 @Override 663 public void setTokenizer(Tokenizer tokenizer) { 664 mTokenizer = tokenizer; 665 super.setTokenizer(mTokenizer); 666 } 667 668 @Override 669 public void setValidator(Validator validator) { 670 mValidator = validator; 671 super.setValidator(validator); 672 } 673 674 /** 675 * We cannot use the default mechanism for replaceText. Instead, 676 * we override onItemClickListener so we can get all the associated 677 * contact information including display text, address, and id. 678 */ 679 @Override 680 protected void replaceText(CharSequence text) { 681 return; 682 } 683 684 /** 685 * Dismiss any selected chips when the back key is pressed. 686 */ 687 @Override 688 public boolean onKeyPreIme(int keyCode, KeyEvent event) { 689 if (keyCode == KeyEvent.KEYCODE_BACK) { 690 clearSelectedChip(); 691 } 692 return super.onKeyPreIme(keyCode, event); 693 } 694 695 /** 696 * Monitor key presses in this view to see if the user types 697 * any commit keys, which consist of ENTER, TAB, or DPAD_CENTER. 698 * If the user has entered text that has contact matches and types 699 * a commit key, create a chip from the topmost matching contact. 700 * If the user has entered text that has no contact matches and types 701 * a commit key, then create a chip from the text they have entered. 702 */ 703 @Override 704 public boolean onKeyUp(int keyCode, KeyEvent event) { 705 switch (keyCode) { 706 case KeyEvent.KEYCODE_ENTER: 707 case KeyEvent.KEYCODE_DPAD_CENTER: 708 if (event.hasNoModifiers()) { 709 if (commitDefault()) { 710 return true; 711 } 712 if (mSelectedChip != null) { 713 clearSelectedChip(); 714 return true; 715 } else if (focusNext()) { 716 return true; 717 } 718 } 719 break; 720 case KeyEvent.KEYCODE_TAB: 721 if (event.hasNoModifiers()) { 722 if (mSelectedChip != null) { 723 clearSelectedChip(); 724 } else { 725 commitDefault(); 726 } 727 if (focusNext()) { 728 return true; 729 } 730 } 731 } 732 return super.onKeyUp(keyCode, event); 733 } 734 735 private boolean focusNext() { 736 View next = focusSearch(View.FOCUS_DOWN); 737 if (next != null) { 738 next.requestFocus(); 739 return true; 740 } 741 return false; 742 } 743 744 /** 745 * Create a chip from the default selection. If the popup is showing, the 746 * default is the first item in the popup suggestions list. Otherwise, it is 747 * whatever the user had typed in. End represents where the the tokenizer 748 * should search for a token to turn into a chip. 749 * @return If a chip was created from a real contact. 750 */ 751 private boolean commitDefault() { 752 Editable editable = getText(); 753 int end = getSelectionEnd(); 754 int start = mTokenizer.findTokenStart(editable, end); 755 756 if (shouldCreateChip(start, end)) { 757 int whatEnd = mTokenizer.findTokenEnd(getText(), start); 758 // In the middle of chip; treat this as an edit 759 // and commit the whole token. 760 if (whatEnd != getSelectionEnd()) { 761 handleEdit(start, whatEnd); 762 return true; 763 } 764 return commitChip(start, end , editable); 765 } 766 return false; 767 } 768 769 private void commitByCharacter() { 770 Editable editable = getText(); 771 int end = getSelectionEnd(); 772 int start = mTokenizer.findTokenStart(editable, end); 773 if (shouldCreateChip(start, end)) { 774 commitChip(start, end, editable); 775 } 776 setSelection(getText().length()); 777 } 778 779 private boolean commitChip(int start, int end, Editable editable) { 780 if (getAdapter().getCount() > 0 && enoughToFilter()) { 781 // choose the first entry. 782 submitItemAtPosition(0); 783 dismissDropDown(); 784 return true; 785 } else { 786 int tokenEnd = mTokenizer.findTokenEnd(editable, start); 787 String text = editable.toString().substring(start, tokenEnd).trim(); 788 clearComposingText(); 789 if (text != null && text.length() > 0 && !text.equals(" ")) { 790 RecipientEntry entry = createTokenizedEntry(text); 791 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 792 CharSequence chipText = createChip(entry, false); 793 editable.replace(start, end, chipText); 794 dismissDropDown(); 795 return true; 796 } 797 } 798 return false; 799 } 800 801 private boolean shouldCreateChip(int start, int end) { 802 if (hasFocus() && enoughToFilter()) { 803 RecipientChip[] chips = getSpannable().getSpans(start, end, RecipientChip.class); 804 if ((chips == null || chips.length == 0)) { 805 return true; 806 } 807 } 808 return false; 809 } 810 811 private void handleEdit(int start, int end) { 812 // This is in the middle of a chip, so select out the whole chip 813 // and commit it. 814 Editable editable = getText(); 815 setSelection(end); 816 String text = getText().toString().substring(start, end); 817 RecipientEntry entry = RecipientEntry.constructFakeEntry(text); 818 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 819 CharSequence chipText = createChip(entry, false); 820 editable.replace(start, getSelectionEnd(), chipText); 821 dismissDropDown(); 822 } 823 824 /** 825 * If there is a selected chip, delegate the key events 826 * to the selected chip. 827 */ 828 @Override 829 public boolean onKeyDown(int keyCode, KeyEvent event) { 830 if (mSelectedChip != null && keyCode == KeyEvent.KEYCODE_DEL) { 831 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 832 mAlternatesPopup.dismiss(); 833 } 834 removeChip(mSelectedChip); 835 } 836 837 if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) { 838 return true; 839 } 840 841 return super.onKeyDown(keyCode, event); 842 } 843 844 private Spannable getSpannable() { 845 return getText(); 846 } 847 848 private int getChipStart(RecipientChip chip) { 849 return getSpannable().getSpanStart(chip); 850 } 851 852 private int getChipEnd(RecipientChip chip) { 853 return getSpannable().getSpanEnd(chip); 854 } 855 856 /** 857 * Instead of filtering on the entire contents of the edit box, 858 * this subclass method filters on the range from 859 * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd} 860 * if the length of that range meets or exceeds {@link #getThreshold} 861 * and makes sure that the range is not already a Chip. 862 */ 863 @Override 864 protected void performFiltering(CharSequence text, int keyCode) { 865 if (enoughToFilter()) { 866 int end = getSelectionEnd(); 867 int start = mTokenizer.findTokenStart(text, end); 868 // If this is a RecipientChip, don't filter 869 // on its contents. 870 Spannable span = getSpannable(); 871 RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class); 872 if (chips != null && chips.length > 0) { 873 return; 874 } 875 } 876 super.performFiltering(text, keyCode); 877 } 878 879 private void clearSelectedChip() { 880 if (mSelectedChip != null) { 881 unselectChip(mSelectedChip); 882 mSelectedChip = null; 883 } 884 setCursorVisible(true); 885 } 886 887 /** 888 * Monitor touch events in the RecipientEditTextView. 889 * If the view does not have focus, any tap on the view 890 * will just focus the view. If the view has focus, determine 891 * if the touch target is a recipient chip. If it is and the chip 892 * is not selected, select it and clear any other selected chips. 893 * If it isn't, then select that chip. 894 */ 895 @Override 896 public boolean onTouchEvent(MotionEvent event) { 897 if (!isFocused()) { 898 // Ignore any chip taps until this view is focused. 899 return super.onTouchEvent(event); 900 } 901 902 boolean handled = super.onTouchEvent(event); 903 int action = event.getAction(); 904 boolean chipWasSelected = false; 905 906 if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { 907 float x = event.getX(); 908 float y = event.getY(); 909 int offset = putOffsetInRange(getOffsetForPosition(x, y)); 910 RecipientChip currentChip = findChip(offset); 911 if (currentChip != null) { 912 if (action == MotionEvent.ACTION_UP) { 913 if (mSelectedChip != null && mSelectedChip != currentChip) { 914 clearSelectedChip(); 915 mSelectedChip = selectChip(currentChip); 916 } else if (mSelectedChip == null) { 917 // Selection may have moved due to the tap event, 918 // but make sure we correctly reset selection to the 919 // end so that any unfinished chips are committed. 920 setSelection(getText().length()); 921 commitDefault(); 922 mSelectedChip = selectChip(currentChip); 923 } else { 924 onClick(mSelectedChip, offset, x, y); 925 } 926 } 927 chipWasSelected = true; 928 setCursorVisible(false); 929 handled = true; 930 } 931 } 932 if (action == MotionEvent.ACTION_UP && !chipWasSelected) { 933 clearSelectedChip(); 934 } 935 return handled; 936 } 937 938 private void scrollLineIntoView(int line) { 939 if (mScrollView != null) { 940 mScrollView.scrollBy(0, calculateOffsetFromBottom(line)); 941 } 942 } 943 944 private void showAlternates(RecipientChip currentChip, ListPopupWindow alternatesPopup, 945 int width, Context context) { 946 int line = getLayout().getLineForOffset(getChipStart(currentChip)); 947 int bottom = calculateOffsetFromBottom(line); 948 // Align the alternates popup with the left side of the View, 949 // regardless of the position of the chip tapped. 950 alternatesPopup.setWidth(width); 951 alternatesPopup.setAnchorView(this); 952 alternatesPopup.setVerticalOffset(bottom); 953 alternatesPopup.setAdapter(createAlternatesAdapter(currentChip)); 954 alternatesPopup.setOnItemClickListener(mAlternatesListener); 955 alternatesPopup.show(); 956 ListView listView = alternatesPopup.getListView(); 957 listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); 958 // Checked item would be -1 if the adapter has not 959 // loaded the view that should be checked yet. The 960 // variable will be set correctly when onCheckedItemChanged 961 // is called in a separate thread. 962 if (mCheckedItem != -1) { 963 listView.setItemChecked(mCheckedItem, true); 964 mCheckedItem = -1; 965 } 966 } 967 968 private ListAdapter createAlternatesAdapter(RecipientChip chip) { 969 return new RecipientAlternatesAdapter(getContext(), chip.getContactId(), chip.getDataId(), 970 mAlternatesLayout, this); 971 } 972 973 private ListAdapter createSingleAddressAdapter(RecipientChip currentChip) { 974 return new SingleRecipientArrayAdapter(getContext(), mAlternatesLayout, currentChip 975 .getEntry()); 976 } 977 978 public void onCheckedItemChanged(int position) { 979 ListView listView = mAlternatesPopup.getListView(); 980 if (listView != null && listView.getCheckedItemCount() == 0) { 981 listView.setItemChecked(position, true); 982 } else { 983 mCheckedItem = position; 984 } 985 } 986 987 // TODO: This algorithm will need a lot of tweaking after more people have used 988 // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring 989 // what comes before the finger. 990 private int putOffsetInRange(int o) { 991 int offset = o; 992 Editable text = getText(); 993 int length = text.length(); 994 // Remove whitespace from end to find "real end" 995 int realLength = length; 996 for (int i = length - 1; i >= 0; i--) { 997 if (text.charAt(i) == ' ') { 998 realLength--; 999 } else { 1000 break; 1001 } 1002 } 1003 1004 // If the offset is beyond or at the end of the text, 1005 // leave it alone. 1006 if (offset >= realLength) { 1007 return offset; 1008 } 1009 Editable editable = getText(); 1010 while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) { 1011 // Keep walking backward! 1012 offset--; 1013 } 1014 return offset; 1015 } 1016 1017 private int findText(Editable text, int offset) { 1018 if (text.charAt(offset) != ' ') { 1019 return offset; 1020 } 1021 return -1; 1022 } 1023 1024 private RecipientChip findChip(int offset) { 1025 RecipientChip[] chips = getSpannable().getSpans(0, getText().length(), RecipientChip.class); 1026 // Find the chip that contains this offset. 1027 for (int i = 0; i < chips.length; i++) { 1028 RecipientChip chip = chips[i]; 1029 int start = getChipStart(chip); 1030 int end = getChipEnd(chip); 1031 if (offset >= start && offset <= end) { 1032 return chip; 1033 } 1034 } 1035 return null; 1036 } 1037 1038 private CharSequence createChip(RecipientEntry entry, boolean pressed) { 1039 String displayText = entry.getDestination(); 1040 displayText = (String) mTokenizer.terminateToken(displayText); 1041 // Always leave a blank space at the end of a chip. 1042 int textLength = displayText.length()-1; 1043 SpannableString chipText = new SpannableString(displayText); 1044 int end = getSelectionEnd(); 1045 int start = mTokenizer.findTokenStart(getText(), end); 1046 try { 1047 chipText.setSpan(constructChipSpan(entry, start, pressed), 0, textLength, 1048 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1049 } catch (NullPointerException e) { 1050 Log.e(TAG, e.getMessage(), e); 1051 return null; 1052 } 1053 1054 return chipText; 1055 } 1056 1057 /** 1058 * When an item in the suggestions list has been clicked, create a chip from the 1059 * contact information of the selected item. 1060 */ 1061 @Override 1062 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 1063 submitItemAtPosition(position); 1064 } 1065 1066 private void submitItemAtPosition(int position) { 1067 RecipientEntry entry = createValidatedEntry( 1068 (RecipientEntry)getAdapter().getItem(position)); 1069 if (entry == null) { 1070 return; 1071 } 1072 clearComposingText(); 1073 1074 int end = getSelectionEnd(); 1075 int start = mTokenizer.findTokenStart(getText(), end); 1076 1077 Editable editable = getText(); 1078 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1079 editable.replace(start, end, createChip(entry, false)); 1080 } 1081 1082 private RecipientEntry createValidatedEntry(RecipientEntry item) { 1083 if (item == null) { 1084 return null; 1085 } 1086 final RecipientEntry entry; 1087 // If the display name and the address are the same, or if this is a 1088 // valid contact, but the destination is invalid, then make this a fake 1089 // recipient that is editable. 1090 String destination = item.getDestination(); 1091 if (TextUtils.isEmpty(item.getDisplayName()) 1092 || TextUtils.equals(item.getDisplayName(), destination) 1093 || (mValidator != null && !mValidator.isValid(destination))) { 1094 entry = RecipientEntry.constructFakeEntry(destination); 1095 } else { 1096 entry = item; 1097 } 1098 return entry; 1099 } 1100 1101 /** Returns a collection of contact Id for each chip inside this View. */ 1102 /* package */ Collection<Long> getContactIds() { 1103 final Set<Long> result = new HashSet<Long>(); 1104 RecipientChip[] chips = getRecipients(); 1105 if (chips != null) { 1106 for (RecipientChip chip : chips) { 1107 result.add(chip.getContactId()); 1108 } 1109 } 1110 return result; 1111 } 1112 1113 private RecipientChip[] getRecipients() { 1114 return getSpannable().getSpans(0, getText().length(), RecipientChip.class); 1115 } 1116 1117 /** Returns a collection of data Id for each chip inside this View. May be null. */ 1118 /* package */ Collection<Long> getDataIds() { 1119 final Set<Long> result = new HashSet<Long>(); 1120 RecipientChip [] chips = getRecipients(); 1121 if (chips != null) { 1122 for (RecipientChip chip : chips) { 1123 result.add(chip.getDataId()); 1124 } 1125 } 1126 return result; 1127 } 1128 1129 1130 @Override 1131 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 1132 return false; 1133 } 1134 1135 @Override 1136 public void onDestroyActionMode(ActionMode mode) { 1137 } 1138 1139 @Override 1140 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 1141 return false; 1142 } 1143 1144 /** 1145 * No chips are selectable. 1146 */ 1147 @Override 1148 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 1149 return false; 1150 } 1151 1152 /** 1153 * Create the more chip. The more chip is text that replaces any chips that 1154 * do not fit in the pre-defined available space when the 1155 * RecipientEditTextView loses focus. 1156 */ 1157 private void createMoreChip() { 1158 RecipientChip[] recipients = getRecipients(); 1159 if (recipients == null || recipients.length <= CHIP_LIMIT) { 1160 mMoreChip = null; 1161 return; 1162 } 1163 int numRecipients = recipients.length; 1164 int overage = numRecipients - CHIP_LIMIT; 1165 Editable text = getText(); 1166 // TODO: get the correct size from visual design. 1167 int width = (int) Math.floor(getWidth() * MORE_WIDTH_FACTOR); 1168 int height = getLineHeight(); 1169 Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 1170 Canvas canvas = new Canvas(drawable); 1171 String moreText = getResources().getString(mMoreString, overage); 1172 canvas.drawText(moreText, 0, moreText.length(), 0, height - getLayout().getLineDescent(0), 1173 getPaint()); 1174 1175 Drawable result = new BitmapDrawable(getResources(), drawable); 1176 result.setBounds(0, 0, width, height); 1177 ImageSpan moreSpan = new ImageSpan(result); 1178 Spannable spannable = getSpannable(); 1179 // Remove the overage chips. 1180 if (recipients == null || recipients.length == 0) { 1181 Log.w(TAG, 1182 "We have recipients. Tt should not be possible to have zero RecipientChips."); 1183 mMoreChip = null; 1184 return; 1185 } 1186 mRemovedSpans = new ArrayList<RecipientChip>(); 1187 int totalReplaceStart = 0; 1188 int totalReplaceEnd = 0; 1189 for (int i = numRecipients - overage; i < recipients.length; i++) { 1190 mRemovedSpans.add(recipients[i]); 1191 if (i == numRecipients - overage) { 1192 totalReplaceStart = spannable.getSpanStart(recipients[i]); 1193 } 1194 if (i == recipients.length - 1) { 1195 totalReplaceEnd = spannable.getSpanEnd(recipients[i]); 1196 } 1197 if (mTemporaryRecipients != null && !mTemporaryRecipients.contains(recipients[i])) { 1198 recipients[i].storeChipStart(spannable.getSpanStart(recipients[i])); 1199 recipients[i].storeChipEnd(spannable.getSpanEnd(recipients[i])); 1200 } 1201 spannable.removeSpan(recipients[i]); 1202 } 1203 // TODO: why would these ever be backwards? 1204 int end = Math.max(totalReplaceStart, totalReplaceEnd); 1205 int start = Math.min(totalReplaceStart, totalReplaceEnd); 1206 SpannableString chipText = new SpannableString(text.subSequence(start, end)); 1207 chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1208 text.replace(start, end, chipText); 1209 mMoreChip = moreSpan; 1210 } 1211 1212 /** 1213 * Replace the more chip, if it exists, with all of the recipient chips it had 1214 * replaced when the RecipientEditTextView gains focus. 1215 */ 1216 private void removeMoreChip() { 1217 if (mMoreChip != null) { 1218 Spannable span = getSpannable(); 1219 span.removeSpan(mMoreChip); 1220 mMoreChip = null; 1221 // Re-add the spans that were removed. 1222 if (mRemovedSpans != null && mRemovedSpans.size() > 0) { 1223 // Recreate each removed span. 1224 Editable editable = getText(); 1225 for (RecipientChip chip : mRemovedSpans) { 1226 int chipStart = chip.getStoredChipStart(); 1227 int chipEnd; 1228 String token; 1229 if (chipStart == -1) { 1230 // Need to find the location of the chip, again. 1231 token = (String)mTokenizer.terminateToken(chip.getEntry().getDestination()); 1232 chipStart = editable.toString().indexOf(token); 1233 // -1 for the space! 1234 chipEnd = chipStart + token.length() - 1; 1235 } else { 1236 chipEnd = Math.min(editable.length(), chip.getStoredChipEnd()); 1237 } 1238 if (Log.isLoggable(TAG, Log.DEBUG) && chipEnd != chip.getStoredChipEnd()) { 1239 Log.d(TAG, 1240 "Unexpectedly, the chip ended after the end of the editable text. " 1241 + "Chip End " + chip.getStoredChipEnd() 1242 + "Editable length " + editable.length()); 1243 } 1244 // Only set the span if we found a matching token. 1245 if (chipStart != -1) { 1246 editable.setSpan(chip, chipStart, chipEnd, 1247 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1248 } 1249 } 1250 mRemovedSpans.clear(); 1251 } 1252 } 1253 } 1254 1255 /** 1256 * Show specified chip as selected. If the RecipientChip is just an email address, 1257 * selecting the chip will take the contents of the chip and place it at 1258 * the end of the RecipientEditTextView for inline editing. If the 1259 * RecipientChip is a complete contact, then selecting the chip 1260 * will change the background color of the chip, show the delete icon, 1261 * and a popup window with the address in use highlighted and any other 1262 * alternate addresses for the contact. 1263 * @param currentChip Chip to select. 1264 * @return A RecipientChip in the selected state or null if the chip 1265 * just contained an email address. 1266 */ 1267 public RecipientChip selectChip(RecipientChip currentChip) { 1268 if (currentChip.getContactId() == RecipientEntry.INVALID_CONTACT) { 1269 CharSequence text = currentChip.getValue(); 1270 Editable editable = getText(); 1271 removeChip(currentChip); 1272 editable.append(text); 1273 setCursorVisible(true); 1274 setSelection(editable.length()); 1275 return null; 1276 } else if (currentChip.getContactId() == RecipientEntry.GENERATED_CONTACT) { 1277 int start = getChipStart(currentChip); 1278 int end = getChipEnd(currentChip); 1279 getSpannable().removeSpan(currentChip); 1280 RecipientChip newChip; 1281 try { 1282 newChip = constructChipSpan(currentChip.getEntry(), start, true); 1283 } catch (NullPointerException e) { 1284 Log.e(TAG, e.getMessage(), e); 1285 return null; 1286 } 1287 Editable editable = getText(); 1288 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1289 if (start == -1 || end == -1) { 1290 Log.d(TAG, "The chip being selected no longer exists but should."); 1291 } else { 1292 editable.setSpan(newChip, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1293 } 1294 newChip.setSelected(true); 1295 if (newChip.getEntry().getContactId() == RecipientEntry.INVALID_CONTACT) { 1296 scrollLineIntoView(getLayout().getLineForOffset(getChipStart(newChip))); 1297 } 1298 showAddress(newChip, mAddressPopup, getWidth(), getContext()); 1299 return newChip; 1300 } else { 1301 int start = getChipStart(currentChip); 1302 int end = getChipEnd(currentChip); 1303 getSpannable().removeSpan(currentChip); 1304 RecipientChip newChip; 1305 try { 1306 newChip = constructChipSpan(currentChip.getEntry(), start, true); 1307 } catch (NullPointerException e) { 1308 Log.e(TAG, e.getMessage(), e); 1309 return null; 1310 } 1311 Editable editable = getText(); 1312 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1313 if (start == -1 || end == -1) { 1314 Log.d(TAG, "The chip being selected no longer exists but should."); 1315 } else { 1316 editable.setSpan(newChip, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1317 } 1318 newChip.setSelected(true); 1319 if (newChip.getEntry().getContactId() == RecipientEntry.INVALID_CONTACT) { 1320 scrollLineIntoView(getLayout().getLineForOffset(getChipStart(newChip))); 1321 } 1322 showAlternates(newChip, mAlternatesPopup, getWidth(), getContext()); 1323 return newChip; 1324 } 1325 } 1326 1327 1328 private void showAddress(final RecipientChip currentChip, final ListPopupWindow popup, 1329 int width, Context context) { 1330 int line = getLayout().getLineForOffset(getChipStart(currentChip)); 1331 int bottom = calculateOffsetFromBottom(line); 1332 // Align the alternates popup with the left side of the View, 1333 // regardless of the position of the chip tapped. 1334 popup.setWidth(width); 1335 popup.setAnchorView(this); 1336 popup.setVerticalOffset(bottom); 1337 popup.setAdapter(createSingleAddressAdapter(currentChip)); 1338 popup.setOnItemClickListener(new OnItemClickListener() { 1339 @Override 1340 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 1341 unselectChip(currentChip); 1342 popup.dismiss(); 1343 } 1344 }); 1345 popup.show(); 1346 ListView listView = popup.getListView(); 1347 listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); 1348 listView.setItemChecked(0, true); 1349 } 1350 1351 /** 1352 * Remove selection from this chip. Unselecting a RecipientChip will render 1353 * the chip without a delete icon and with an unfocused background. This 1354 * is called when the RecipientChip no longer has focus. 1355 */ 1356 public void unselectChip(RecipientChip chip) { 1357 int start = getChipStart(chip); 1358 int end = getChipEnd(chip); 1359 Editable editable = getText(); 1360 mSelectedChip = null; 1361 if (start == -1 || end == -1) { 1362 Log.e(TAG, "The chip being unselected no longer exists but should."); 1363 } else { 1364 getSpannable().removeSpan(chip); 1365 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 1366 editable.removeSpan(chip); 1367 try { 1368 editable.setSpan(constructChipSpan(chip.getEntry(), start, false), start, end, 1369 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1370 } catch (NullPointerException e) { 1371 Log.e(TAG, e.getMessage(), e); 1372 } 1373 } 1374 setCursorVisible(true); 1375 setSelection(editable.length()); 1376 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 1377 mAlternatesPopup.dismiss(); 1378 } 1379 } 1380 1381 1382 /** 1383 * Return whether this chip contains the position passed in. 1384 */ 1385 public boolean matchesChip(RecipientChip chip, int offset) { 1386 int start = getChipStart(chip); 1387 int end = getChipEnd(chip); 1388 if (start == -1 || end == -1) { 1389 return false; 1390 } 1391 return (offset >= start && offset <= end); 1392 } 1393 1394 1395 /** 1396 * Return whether a touch event was inside the delete target of 1397 * a selected chip. It is in the delete target if: 1398 * 1) the x and y points of the event are within the 1399 * delete assset. 1400 * 2) the point tapped would have caused a cursor to appear 1401 * right after the selected chip. 1402 * @return boolean 1403 */ 1404 private boolean isInDelete(RecipientChip chip, int offset, float x, float y) { 1405 // Figure out the bounds of this chip and whether or not 1406 // the user clicked in the X portion. 1407 return chip.isSelected() && offset == getChipEnd(chip); 1408 } 1409 1410 /** 1411 * Remove the chip and any text associated with it from the RecipientEditTextView. 1412 */ 1413 private void removeChip(RecipientChip chip) { 1414 Spannable spannable = getSpannable(); 1415 int spanStart = spannable.getSpanStart(chip); 1416 int spanEnd = spannable.getSpanEnd(chip); 1417 Editable text = getText(); 1418 int toDelete = spanEnd; 1419 boolean wasSelected = chip == mSelectedChip; 1420 // Clear that there is a selected chip before updating any text. 1421 if (wasSelected) { 1422 mSelectedChip = null; 1423 } 1424 // Always remove trailing spaces when removing a chip. 1425 while (toDelete >= 0 && toDelete < text.length() && text.charAt(toDelete) == ' ') { 1426 toDelete++; 1427 } 1428 spannable.removeSpan(chip); 1429 text.delete(spanStart, toDelete); 1430 if (wasSelected) { 1431 clearSelectedChip(); 1432 } 1433 } 1434 1435 /** 1436 * Replace this currently selected chip with a new chip 1437 * that uses the contact data provided. 1438 */ 1439 public void replaceChip(RecipientChip chip, RecipientEntry entry) { 1440 boolean wasSelected = chip == mSelectedChip; 1441 if (wasSelected) { 1442 mSelectedChip = null; 1443 } 1444 int start = getChipStart(chip); 1445 int end = getChipEnd(chip); 1446 getSpannable().removeSpan(chip); 1447 Editable editable = getText(); 1448 CharSequence chipText = createChip(entry, false); 1449 if (start == -1 || end == -1) { 1450 Log.e(TAG, "The chip to replace does not exist but should."); 1451 editable.insert(0, chipText); 1452 } else { 1453 // There may be a space to replace with this chip's new associated 1454 // space. Check for it. 1455 int toReplace = end; 1456 while (toReplace >= 0 && toReplace < editable.length() 1457 && editable.charAt(toReplace) == ' ') { 1458 toReplace++; 1459 } 1460 editable.replace(start, toReplace, chipText); 1461 } 1462 setCursorVisible(true); 1463 if (wasSelected) { 1464 clearSelectedChip(); 1465 } 1466 } 1467 1468 /** 1469 * Handle click events for a chip. When a selected chip receives a click 1470 * event, see if that event was in the delete icon. If so, delete it. 1471 * Otherwise, unselect the chip. 1472 */ 1473 public void onClick(RecipientChip chip, int offset, float x, float y) { 1474 if (chip.isSelected()) { 1475 if (isInDelete(chip, offset, x, y)) { 1476 removeChip(chip); 1477 } else { 1478 clearSelectedChip(); 1479 } 1480 } 1481 } 1482 1483 private boolean chipsPending() { 1484 return mPendingChipsCount > 0 || (mRemovedSpans != null && mRemovedSpans.size() > 0); 1485 } 1486 1487 private class RecipientTextWatcher implements TextWatcher { 1488 @Override 1489 public void afterTextChanged(Editable s) { 1490 // Get whether there are any recipients pending addition to the 1491 // view. If there are, don't do anything in the text watcher. 1492 if (chipsPending()) { 1493 return; 1494 } 1495 if (mSelectedChip != null) { 1496 setCursorVisible(true); 1497 setSelection(getText().length()); 1498 clearSelectedChip(); 1499 } 1500 int length = s.length(); 1501 // Make sure there is content there to parse and that it is 1502 // not just the commit character. 1503 if (length > 1) { 1504 char last; 1505 int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1; 1506 int len = length() - 1; 1507 if (end != len) { 1508 last = s.charAt(end); 1509 } else { 1510 last = s.charAt(len); 1511 } 1512 if (last == COMMIT_CHAR_SEMICOLON || last == COMMIT_CHAR_COMMA) { 1513 commitByCharacter(); 1514 } else if (last == COMMIT_CHAR_SPACE) { 1515 // Check if this is a valid email address. If it is, 1516 // commit it. 1517 String text = getText().toString(); 1518 int tokenStart = mTokenizer.findTokenStart(text, getSelectionEnd()); 1519 String sub = text.substring(tokenStart, mTokenizer.findTokenEnd(text, 1520 tokenStart)); 1521 if (mValidator != null && mValidator.isValid(sub)) { 1522 commitByCharacter(); 1523 } 1524 } 1525 } 1526 } 1527 1528 @Override 1529 public void onTextChanged(CharSequence s, int start, int before, int count) { 1530 // Do nothing. 1531 } 1532 1533 @Override 1534 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 1535 } 1536 } 1537 1538 private class RecipientReplacementTask extends AsyncTask<Void, Void, Void> { 1539 private RecipientChip createFreeChip(RecipientEntry entry) { 1540 String displayText = entry.getDestination(); 1541 displayText = (String) mTokenizer.terminateToken(displayText); 1542 try { 1543 return constructChipSpan(entry, -1, false); 1544 } catch (NullPointerException e) { 1545 Log.e(TAG, e.getMessage(), e); 1546 return null; 1547 } 1548 } 1549 1550 @Override 1551 protected Void doInBackground(Void... params) { 1552 if (mIndividualReplacements != null) { 1553 mIndividualReplacements.cancel(true); 1554 } 1555 // For each chip in the list, look up the matching contact. 1556 // If there is a match, replace that chip with the matching 1557 // chip. 1558 final ArrayList<RecipientChip> originalRecipients = new ArrayList<RecipientChip>(); 1559 RecipientChip[] existingChips = getSpannable().getSpans(0, getText().length(), 1560 RecipientChip.class); 1561 for (int i = 0; i < existingChips.length; i++) { 1562 originalRecipients.add(existingChips[i]); 1563 } 1564 if (mRemovedSpans != null) { 1565 originalRecipients.addAll(mRemovedSpans); 1566 } 1567 String[] addresses = new String[originalRecipients.size()]; 1568 for (int i = 0; i < originalRecipients.size(); i++) { 1569 addresses[i] = originalRecipients.get(i).getEntry().getDestination(); 1570 } 1571 HashMap<String, RecipientEntry> entries = RecipientAlternatesAdapter 1572 .getMatchingRecipients(getContext(), addresses); 1573 final ArrayList<RecipientChip> replacements = new ArrayList<RecipientChip>(); 1574 for (final RecipientChip temp : originalRecipients) { 1575 RecipientEntry entry = null; 1576 if (RecipientEntry.isCreatedRecipient(temp.getEntry().getContactId()) 1577 && getSpannable().getSpanStart(temp) != -1) { 1578 // Replace this. 1579 entry = createValidatedEntry(entries.get(tokenizeAddress(temp.getEntry() 1580 .getDestination()))); 1581 } 1582 if (entry != null) { 1583 replacements.add(createFreeChip(entry)); 1584 } else { 1585 replacements.add(temp); 1586 } 1587 } 1588 if (replacements != null && replacements.size() > 0) { 1589 mHandler.post(new Runnable() { 1590 @Override 1591 public void run() { 1592 SpannableStringBuilder text = new SpannableStringBuilder(getText() 1593 .toString()); 1594 Editable oldText = getText(); 1595 int start, end; 1596 int i = 0; 1597 for (RecipientChip chip : originalRecipients) { 1598 start = oldText.getSpanStart(chip); 1599 if (start != -1) { 1600 end = oldText.getSpanEnd(chip); 1601 text.removeSpan(chip); 1602 // Leave a spot for the space! 1603 text.setSpan(replacements.get(i), start, end, 1604 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1605 } 1606 i++; 1607 } 1608 Editable editable = getText(); 1609 editable.clear(); 1610 editable.insert(0, text); 1611 originalRecipients.clear(); 1612 } 1613 }); 1614 } 1615 return null; 1616 } 1617 } 1618 1619 private class IndividualReplacementTask extends AsyncTask<Object, Void, Void> { 1620 @SuppressWarnings("unchecked") 1621 @Override 1622 protected Void doInBackground(Object... params) { 1623 // For each chip in the list, look up the matching contact. 1624 // If there is a match, replace that chip with the matching 1625 // chip. 1626 final ArrayList<RecipientChip> originalRecipients = 1627 (ArrayList<RecipientChip>) params[0]; 1628 String[] addresses = new String[originalRecipients.size()]; 1629 for (int i = 0; i < originalRecipients.size(); i++) { 1630 addresses[i] = originalRecipients.get(i).getEntry().getDestination(); 1631 } 1632 HashMap<String, RecipientEntry> entries = RecipientAlternatesAdapter 1633 .getMatchingRecipients(getContext(), addresses); 1634 for (final RecipientChip temp : originalRecipients) { 1635 if (RecipientEntry.isCreatedRecipient(temp.getEntry().getContactId()) 1636 && getSpannable().getSpanStart(temp) != -1) { 1637 // Replace this. 1638 final RecipientEntry entry = createValidatedEntry(entries 1639 .get(tokenizeAddress(temp.getEntry().getDestination()))); 1640 if (entry != null) { 1641 mHandler.post(new Runnable() { 1642 @Override 1643 public void run() { 1644 replaceChip(temp, entry); 1645 } 1646 }); 1647 } 1648 } 1649 } 1650 return null; 1651 } 1652 } 1653} 1654