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