RecipientEditTextView.java revision fab0afdc6742dcba55cfbe802cd143434d48f413
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.text.Editable; 30import android.text.Layout; 31import android.text.Spannable; 32import android.text.SpannableString; 33import android.text.Spanned; 34import android.text.TextPaint; 35import android.text.TextUtils; 36import android.text.TextWatcher; 37import android.text.method.QwertyKeyListener; 38import android.text.style.ImageSpan; 39import android.util.AttributeSet; 40import android.util.Log; 41import android.view.ActionMode; 42import android.view.KeyEvent; 43import android.view.Menu; 44import android.view.MenuItem; 45import android.view.MotionEvent; 46import android.view.View; 47import android.view.ActionMode.Callback; 48import android.widget.AdapterView; 49import android.widget.AdapterView.OnItemClickListener; 50import android.widget.ListPopupWindow; 51import android.widget.MultiAutoCompleteTextView; 52 53import java.util.Collection; 54import java.util.HashSet; 55import java.util.Set; 56 57import java.util.ArrayList; 58 59/** 60 * RecipientEditTextView is an auto complete text view for use with applications 61 * that use the new Chips UI for addressing a message to recipients. 62 */ 63public class RecipientEditTextView extends MultiAutoCompleteTextView 64 implements OnItemClickListener, Callback { 65 66 private static final String TAG = "RecipientEditTextView"; 67 68 // TODO: get correct number/ algorithm from with UX. 69 private static final int CHIP_LIMIT = 2; 70 71 // TODO: get correct size from UX. 72 private static final float MORE_WIDTH_FACTOR = 0.25f; 73 74 private Drawable mChipBackground = null; 75 76 private Drawable mChipDelete = null; 77 78 private int mChipPadding; 79 80 private Tokenizer mTokenizer; 81 82 private Drawable mChipBackgroundPressed; 83 84 private RecipientChip mSelectedChip; 85 86 private int mChipDeleteWidth; 87 88 private ArrayList<RecipientChip> mRecipients; 89 90 private int mAlternatesLayout; 91 92 private int mAlternatesSelectedLayout; 93 94 private Bitmap mDefaultContactPhoto; 95 96 private ImageSpan mMoreChip; 97 98 private int mMoreString; 99 100 private ArrayList<RecipientChip> mRemovedSpans; 101 102 private float mChipHeight; 103 104 private float mChipFontSize; 105 106 public RecipientEditTextView(Context context, AttributeSet attrs) { 107 super(context, attrs); 108 mRecipients = new ArrayList<RecipientChip>(); 109 setSuggestionsEnabled(false); 110 setOnItemClickListener(this); 111 setCustomSelectionActionModeCallback(this); 112 // When the user starts typing, make sure we unselect any selected 113 // chips. 114 addTextChangedListener(new TextWatcher() { 115 @Override 116 public void afterTextChanged(Editable s) { 117 // Do nothing. 118 } 119 @Override 120 public void onTextChanged(CharSequence s, int start, int before, int count) { 121 // Do nothing. 122 } 123 @Override 124 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 125 // TODO: find a better way to unfocus a chip when a user starts typing. 126 } 127 }); 128 } 129 130 @Override 131 public void onSelectionChanged(int start, int end) { 132 // When selection changes, see if it is inside the chips area. 133 // If so, move the cursor back after the chips again. 134 if (mRecipients != null && mRecipients.size() > 0) { 135 Spannable span = getSpannable(); 136 int textLength = getText().length(); 137 RecipientChip[] chips = span.getSpans(start, textLength, RecipientChip.class); 138 if (chips != null && chips.length > 0) { 139 // Grab the last chip and set the cursor to after it. 140 setSelection(Math.min(chips[chips.length - 1].getChipEnd() + 1, textLength)); 141 } 142 } 143 super.onSelectionChanged(start, end); 144 } 145 146 @Override 147 public void onFocusChanged(boolean hasFocus, int direction, Rect previous) { 148 if (!hasFocus) { 149 shrink(); 150 } else { 151 expand(); 152 } 153 super.onFocusChanged(hasFocus, direction, previous); 154 } 155 156 private void shrink() { 157 if (mSelectedChip != null) { 158 clearSelectedChip(); 159 } else { 160 commitDefault(); 161 } 162 mMoreChip = createMoreChip(); 163 } 164 165 private void expand() { 166 removeMoreChip(); 167 setCursorVisible(true); 168 Editable text = getText(); 169 setSelection(text != null && text.length() > 0 ? text.length() : 0); 170 } 171 172 private CharSequence ellipsizeText(CharSequence text, TextPaint paint, float maxWidth) { 173 paint.setTextSize(mChipFontSize); 174 return TextUtils.ellipsize(text, paint, maxWidth, TextUtils.TruncateAt.END); 175 } 176 177 private Bitmap createSelectedChip(RecipientEntry contact, TextPaint paint, Layout layout) { 178 // Ellipsize the text so that it takes AT MOST the entire width of the 179 // autocomplete text entry area. Make sure to leave space for padding 180 // on the sides. 181 int height = (int) mChipHeight; 182 int deleteWidth = height; 183 CharSequence ellipsizedText = ellipsizeText(contact.getDisplayName(), paint, 184 calculateAvailableWidth(true) - deleteWidth); 185 186 // Make sure there is a minimum chip width so the user can ALWAYS 187 // tap a chip without difficulty. 188 int width = Math.max(deleteWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0, 189 ellipsizedText.length())) 190 + (mChipPadding * 2) + deleteWidth); 191 192 // Create the background of the chip. 193 Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 194 Canvas canvas = new Canvas(tmpBitmap); 195 if (mChipBackgroundPressed != null) { 196 mChipBackgroundPressed.setBounds(0, 0, width, height); 197 mChipBackgroundPressed.draw(canvas); 198 199 // Align the display text with where the user enters text. 200 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height 201 - Math.abs(height - mChipFontSize)/2, paint); 202 // Make the delete a square. 203 mChipDelete.setBounds(width - deleteWidth, 0, width, height); 204 mChipDelete.draw(canvas); 205 } else { 206 Log.w(TAG, "Unable to draw a background for the chips as it was never set"); 207 } 208 return tmpBitmap; 209 } 210 211 private Bitmap createUnselectedChip(RecipientEntry contact, TextPaint paint, Layout layout) { 212 // Ellipsize the text so that it takes AT MOST the entire width of the 213 // autocomplete text entry area. Make sure to leave space for padding 214 // on the sides. 215 int height = (int) mChipHeight; 216 int iconWidth = height; 217 CharSequence ellipsizedText = ellipsizeText(contact.getDisplayName(), paint, 218 calculateAvailableWidth(false) - iconWidth); 219 // Make sure there is a minimum chip width so the user can ALWAYS 220 // tap a chip without difficulty. 221 int width = Math.max(iconWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0, 222 ellipsizedText.length())) 223 + (mChipPadding * 2) + iconWidth); 224 225 // Create the background of the chip. 226 Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 227 Canvas canvas = new Canvas(tmpBitmap); 228 if (mChipBackground != null) { 229 mChipBackground.setBounds(0, 0, width, height); 230 mChipBackground.draw(canvas); 231 232 // Don't draw photos for recipients that have been typed in. 233 if (contact.getContactId() != -1) { 234 byte[] photoBytes = contact.getPhotoBytes(); 235 // There may not be a photo yet if anything but the first contact address 236 // was selected. 237 if (photoBytes == null && contact.getPhotoThumbnailUri() != null) { 238 // TODO: cache this in the recipient entry? 239 ((BaseRecipientAdapter) getAdapter()).fetchPhoto(contact, contact 240 .getPhotoThumbnailUri()); 241 photoBytes = contact.getPhotoBytes(); 242 } 243 244 Bitmap photo; 245 if (photoBytes != null) { 246 photo = BitmapFactory.decodeByteArray(photoBytes, 0, photoBytes.length); 247 } else { 248 // TODO: can the scaled down default photo be cached? 249 photo = mDefaultContactPhoto; 250 } 251 // Draw the photo on the left side. 252 Matrix matrix = new Matrix(); 253 RectF src = new RectF(0, 0, photo.getWidth(), photo.getHeight()); 254 RectF dst = new RectF(0, 0, iconWidth, height); 255 matrix.setRectToRect(src, dst, Matrix.ScaleToFit.CENTER); 256 canvas.drawBitmap(photo, matrix, paint); 257 } else { 258 // Don't leave any space for the icon. It isn't being drawn. 259 iconWidth = 0; 260 } 261 262 // Align the display text with where the user enters text. 263 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding + iconWidth, 264 height - Math.abs(height - mChipFontSize) / 2, paint); 265 } else { 266 Log.w(TAG, "Unable to draw a background for the chips as it was never set"); 267 } 268 return tmpBitmap; 269 } 270 271 public RecipientChip constructChipSpan(RecipientEntry contact, int offset, boolean pressed) 272 throws NullPointerException { 273 if (mChipBackground == null) { 274 throw new NullPointerException( 275 "Unable to render any chips as setChipDimensions was not called."); 276 } 277 Layout layout = getLayout(); 278 int line = layout.getLineForOffset(offset); 279 int lineTop = layout.getLineTop(line); 280 281 TextPaint paint = getPaint(); 282 float defaultSize = paint.getTextSize(); 283 284 Bitmap tmpBitmap; 285 if (pressed) { 286 tmpBitmap = createSelectedChip(contact, paint, layout); 287 288 } else { 289 tmpBitmap = createUnselectedChip(contact, paint, layout); 290 } 291 292 // Get the location of the widget so we can properly offset 293 // the anchor for each chip. 294 int[] xy = new int[2]; 295 getLocationOnScreen(xy); 296 // Pass the full text, un-ellipsized, to the chip. 297 Drawable result = new BitmapDrawable(getResources(), tmpBitmap); 298 result.setBounds(0, 0, tmpBitmap.getWidth(), tmpBitmap.getHeight()); 299 Rect bounds = new Rect(xy[0] + offset, xy[1] + lineTop, xy[0] + tmpBitmap.getWidth(), 300 calculateLineBottom(xy[1], line, tmpBitmap.getHeight())); 301 RecipientChip recipientChip = new RecipientChip(result, contact, offset, bounds); 302 303 // Return text to the original size. 304 paint.setTextSize(defaultSize); 305 306 return recipientChip; 307 } 308 309 // The bottom of the line the chip will be located on is calculated by 4 factors: 310 // 1) which line the chip appears on 311 // 2) the height of a line in the autocomplete view vs the heigt of a chip 312 // 3) padding built into the edit text view will move the bottom position 313 // 4) the position of the autocomplete view on the screen, taking into account 314 // that any top padding will move this down visually 315 private int calculateLineBottom(int yOffset, int line, int chipHeight) { 316 int bottomPadding = 0; 317 if (line == getLineCount() - 1) { 318 bottomPadding += getPaddingBottom(); 319 } 320 return ((line + 1) * getLineHeight()) + yOffset + getPaddingTop() 321 + (chipHeight - getLineHeight()) + bottomPadding; 322 } 323 324 // Get the max amount of space a chip can take up. The formula takes into 325 // account the width of the EditTextView, any view padding, and padding 326 // that will be added to the chip. 327 private float calculateAvailableWidth(boolean pressed) { 328 return getWidth() - getPaddingLeft() - getPaddingRight() - (mChipPadding * 2); 329 } 330 331 /** 332 * Set all chip dimensions and resources. This has to be done from the application 333 * as this is a static library. 334 * @param chipBackground drawable 335 * @param chipBackgroundPressed 336 * @param chipDelete 337 * @param defaultContact 338 * @param alternatesLayout 339 * @param alternatesSelectedLayout 340 * @param padding Padding around the text in a chip 341 */ 342 public void setChipDimensions(Drawable chipBackground, Drawable chipBackgroundPressed, 343 Drawable chipDelete, Bitmap defaultContact, int moreResource, int alternatesLayout, 344 int alternatesSelectedLayout, float chipHeight, float padding, float chipFontSize) { 345 mChipBackground = chipBackground; 346 mChipBackgroundPressed = chipBackgroundPressed; 347 mChipDelete = chipDelete; 348 mChipPadding = (int) padding; 349 mAlternatesLayout = alternatesLayout; 350 mAlternatesSelectedLayout = alternatesSelectedLayout; 351 mDefaultContactPhoto = defaultContact; 352 mMoreString = moreResource; 353 mChipHeight = chipHeight; 354 mChipFontSize = chipFontSize; 355 } 356 357 @Override 358 public void setTokenizer(Tokenizer tokenizer) { 359 mTokenizer = tokenizer; 360 super.setTokenizer(mTokenizer); 361 } 362 363 // We want to handle replacing text in the onItemClickListener 364 // so we can get all the associated contact information including 365 // display text, address, and id. 366 @Override 367 protected void replaceText(CharSequence text) { 368 return; 369 } 370 371 @Override 372 public boolean onKeyPreIme(int keyCode, KeyEvent event) { 373 if (keyCode == KeyEvent.KEYCODE_BACK) { 374 clearSelectedChip(); 375 } 376 return super.onKeyPreIme(keyCode, event); 377 } 378 379 @Override 380 public boolean onKeyUp(int keyCode, KeyEvent event) { 381 switch (keyCode) { 382 case KeyEvent.KEYCODE_ENTER: 383 case KeyEvent.KEYCODE_DPAD_CENTER: 384 case KeyEvent.KEYCODE_TAB: 385 if (event.hasNoModifiers()) { 386 if (commitDefault()) { 387 return true; 388 } 389 } 390 break; 391 } 392 return super.onKeyUp(keyCode, event); 393 } 394 395 // If the popup is showing, the default is the first item in the popup 396 // suggestions list. Otherwise, it is whatever the user had typed in. 397 private boolean commitDefault() { 398 Editable editable = getText(); 399 boolean enough = enoughToFilter(); 400 boolean shouldSubmitAtPosition = false; 401 int end = getSelectionEnd(); 402 int start = mTokenizer.findTokenStart(editable, end); 403 if (enough) { 404 RecipientChip[] chips = getSpannable().getSpans(start, end, RecipientChip.class); 405 if ((chips == null || chips.length == 0)) { 406 // There's something being filtered or typed that has not been 407 // completed yet. 408 shouldSubmitAtPosition = true; 409 } 410 } 411 412 if (shouldSubmitAtPosition) { 413 if (getAdapter().getCount() > 0) { 414 // choose the first entry. 415 submitItemAtPosition(0); 416 dismissDropDown(); 417 return true; 418 } else { 419 String text = editable.toString().substring(start, end); 420 clearComposingText(); 421 if (text != null && text.length() > 0 && !text.equals(" ")) { 422 RecipientEntry entry = RecipientEntry.constructFakeEntry(text); 423 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 424 editable.replace(start, end, createChip(entry, false)); 425 dismissDropDown(); 426 } 427 return false; 428 } 429 } 430 return false; 431 } 432 433 @Override 434 public boolean onKeyDown(int keyCode, KeyEvent event) { 435 if (mSelectedChip != null) { 436 mSelectedChip.onKeyDown(keyCode, event); 437 } 438 439 if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) { 440 return true; 441 } 442 443 return super.onKeyDown(keyCode, event); 444 } 445 446 private Spannable getSpannable() { 447 return (Spannable) getText(); 448 } 449 450 /** 451 * Instead of filtering on the entire contents of the edit box, 452 * this subclass method filters on the range from 453 * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd} 454 * if the length of that range meets or exceeds {@link #getThreshold} 455 * and makes sure that the range is not already a Chip. 456 */ 457 @Override 458 protected void performFiltering(CharSequence text, int keyCode) { 459 if (enoughToFilter()) { 460 int end = getSelectionEnd(); 461 int start = mTokenizer.findTokenStart(text, end); 462 // If this is a RecipientChip, don't filter 463 // on its contents. 464 Spannable span = getSpannable(); 465 RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class); 466 if (chips != null && chips.length > 0) { 467 return; 468 } 469 } 470 super.performFiltering(text, keyCode); 471 } 472 473 private void clearSelectedChip() { 474 if (mSelectedChip != null) { 475 mSelectedChip.unselectChip(); 476 mSelectedChip = null; 477 } 478 setCursorVisible(true); 479 } 480 481 @Override 482 public boolean onTouchEvent(MotionEvent event) { 483 if (!isFocused()) { 484 // Ignore any chip taps until this view is focused. 485 return super.onTouchEvent(event); 486 } 487 488 boolean handled = super.onTouchEvent(event); 489 int action = event.getAction(); 490 boolean chipWasSelected = false; 491 492 if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { 493 float x = event.getX(); 494 float y = event.getY(); 495 int offset = putOffsetInRange(getOffsetForPosition(x, y)); 496 RecipientChip currentChip = findChip(offset); 497 if (currentChip != null) { 498 if (action == MotionEvent.ACTION_UP) { 499 if (mSelectedChip != null && mSelectedChip != currentChip) { 500 clearSelectedChip(); 501 mSelectedChip = currentChip.selectChip(); 502 } else if (mSelectedChip == null) { 503 mSelectedChip = currentChip.selectChip(); 504 } else { 505 mSelectedChip.onClick(this, offset, x, y); 506 } 507 } 508 chipWasSelected = true; 509 } 510 } 511 if (action == MotionEvent.ACTION_UP && !chipWasSelected) { 512 clearSelectedChip(); 513 } 514 return handled; 515 } 516 517 // TODO: This algorithm will need a lot of tweaking after more people have used 518 // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring 519 // what comes before the finger. 520 private int putOffsetInRange(int o) { 521 int offset = o; 522 Editable text = getText(); 523 int length = text.length(); 524 // Remove whitespace from end to find "real end" 525 int realLength = length; 526 for (int i = length - 1; i >= 0; i--) { 527 if (text.charAt(i) == ' ') { 528 realLength--; 529 } else { 530 break; 531 } 532 } 533 534 // If the offset is beyond or at the end of the text, 535 // leave it alone. 536 if (offset >= realLength) { 537 return offset; 538 } 539 Editable editable = getText(); 540 while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) { 541 // Keep walking backward! 542 offset--; 543 } 544 return offset; 545 } 546 547 private int findText(Editable text, int offset) { 548 if (text.charAt(offset) != ' ') { 549 return offset; 550 } 551 return -1; 552 } 553 554 private RecipientChip findChip(int offset) { 555 RecipientChip[] chips = getSpannable().getSpans(0, getText().length(), RecipientChip.class); 556 // Find the chip that contains this offset. 557 for (int i = 0; i < chips.length; i++) { 558 RecipientChip chip = chips[i]; 559 if (chip.matchesChip(offset)) { 560 return chip; 561 } 562 } 563 return null; 564 } 565 566 private CharSequence createChip(RecipientEntry entry, boolean pressed) { 567 CharSequence displayText = mTokenizer.terminateToken(entry.getDestination()); 568 // Always leave a blank space at the end of a chip. 569 int textLength = displayText.length()-1; 570 SpannableString chipText = new SpannableString(displayText); 571 int end = getSelectionEnd(); 572 int start = mTokenizer.findTokenStart(getText(), end); 573 try { 574 chipText.setSpan(constructChipSpan(entry, start, pressed), 0, textLength, 575 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 576 } catch (NullPointerException e) { 577 Log.e(TAG, e.getMessage(), e); 578 return null; 579 } 580 581 return chipText; 582 } 583 584 @Override 585 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 586 submitItemAtPosition(position); 587 } 588 589 private void submitItemAtPosition(int position) { 590 RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position); 591 // If the display name and the address are the same, then make this 592 // a fake recipient that is editable. 593 if (TextUtils.equals(entry.getDisplayName(), entry.getDestination())) { 594 entry = RecipientEntry.constructFakeEntry(entry.getDestination()); 595 } 596 clearComposingText(); 597 598 int end = getSelectionEnd(); 599 int start = mTokenizer.findTokenStart(getText(), end); 600 601 Editable editable = getText(); 602 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 603 editable.replace(start, end, createChip(entry, false)); 604 } 605 606 /** Returns a collection of contact Id for each chip inside this View. */ 607 /* package */ Collection<Long> getContactIds() { 608 final Set<Long> result = new HashSet<Long>(); 609 for (RecipientChip chip : mRecipients) { 610 result.add(chip.getContactId()); 611 } 612 return result; 613 } 614 615 /** Returns a collection of data Id for each chip inside this View. May be null. */ 616 /* package */ Collection<Long> getDataIds() { 617 final Set<Long> result = new HashSet<Long>(); 618 for (RecipientChip chip : mRecipients) { 619 result.add(chip.getDataId()); 620 } 621 return result; 622 } 623 624 625 @Override 626 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 627 return false; 628 } 629 630 @Override 631 public void onDestroyActionMode(ActionMode mode) { 632 } 633 634 @Override 635 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 636 return false; 637 } 638 639 // Prevent selection of chips. 640 @Override 641 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 642 return false; 643 } 644 645 // The more chip is text that replaces any chips that do not fit in the pre-defined 646 // available space when the RecipientEditTextView loses focus and is drawn in a 647 // collapsed fashion. 648 private ImageSpan createMoreChip() { 649 if (mRecipients == null || mRecipients.size() <= CHIP_LIMIT) { 650 return null; 651 } 652 int numRecipients = mRecipients.size(); 653 int overage = numRecipients - CHIP_LIMIT; 654 Editable text = getText(); 655 // TODO: get the correct size from visual design. 656 int width = (int) Math.floor(getWidth() * MORE_WIDTH_FACTOR); 657 int height = getLineHeight(); 658 Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 659 Canvas canvas = new Canvas(drawable); 660 String moreText = getResources().getString(mMoreString, overage); 661 canvas.drawText(moreText, 0, moreText.length(), 0, height - getLayout().getLineDescent(0), 662 getPaint()); 663 664 Drawable result = new BitmapDrawable(getResources(), drawable); 665 result.setBounds(0, 0, width, height); 666 ImageSpan moreSpan = new ImageSpan(result); 667 Spannable spannable = getSpannable(); 668 // Remove the overage chips. 669 RecipientChip[] chips = spannable.getSpans(0, text.length(), RecipientChip.class); 670 if (chips == null || chips.length == 0) { 671 Log.w(TAG, 672 "We have recipients. Tt should not be possible to have zero RecipientChips."); 673 return null; 674 } 675 mRemovedSpans = new ArrayList<RecipientChip>(); 676 int totalReplaceStart = 0; 677 int totalReplaceEnd = 0; 678 for (int i = numRecipients - overage; i < chips.length; i++) { 679 mRemovedSpans.add(chips[i]); 680 if (i == numRecipients - overage) { 681 totalReplaceStart = chips[i].getChipStart(); 682 } 683 if (i == chips.length - 1) { 684 totalReplaceEnd = chips[i].getChipEnd(); 685 } 686 chips[i].setPreviousChipStart(chips[i].getChipStart()); 687 chips[i].setPreviousChipEnd(chips[i].getChipEnd()); 688 spannable.removeSpan(chips[i]); 689 } 690 691 for (int i = chips.length - 1; i >= numRecipients - overage; i--) { 692 mRecipients.remove(i); 693 } 694 SpannableString chipText = new SpannableString(text.subSequence(totalReplaceStart, 695 totalReplaceEnd)); 696 chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 697 text.replace(totalReplaceStart, totalReplaceEnd, chipText); 698 return moreSpan; 699 } 700 701 // Replace the more chip, if it exists, with all of the recipient chips it had 702 // replaced when the RecipientEditTextView gains focus. 703 private void removeMoreChip() { 704 if (mMoreChip != null) { 705 Spannable span = getSpannable(); 706 span.removeSpan(mMoreChip); 707 mMoreChip = null; 708 // Re-add the spans that were removed. 709 if (mRemovedSpans != null && mRemovedSpans.size() > 0) { 710 // Recreate each removed span. 711 Editable editable = getText(); 712 SpannableString associatedText; 713 for (RecipientChip chip : mRemovedSpans) { 714 int chipStart = chip.getPreviousChipStart(); 715 int chipEnd = chip.getPreviousChipEnd(); 716 associatedText = new SpannableString(editable.subSequence(chipStart, chipEnd)); 717 associatedText.setSpan(chip, 0, associatedText.length(), 718 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 719 editable.replace(chipStart, chipEnd, associatedText); 720 mRecipients.add(chip); 721 } 722 mRemovedSpans.clear(); 723 } 724 } 725 } 726 727 /** 728 * RecipientChip defines an ImageSpan that contains information relevant to 729 * a particular recipient. 730 */ 731 public class RecipientChip extends ImageSpan implements OnItemClickListener { 732 private final CharSequence mDisplay; 733 734 private final CharSequence mValue; 735 736 private final int mOffset; 737 738 private View mAnchorView; 739 740 private int mLeft; 741 742 private final long mContactId; 743 744 private final long mDataId; 745 746 private RecipientEntry mEntry; 747 748 private boolean mSelected = false; 749 750 private RecipientAlternatesAdapter mAlternatesAdapter; 751 752 private Rect mBounds; 753 754 private int mStart = -1; 755 private int mEnd = -1; 756 757 private ListPopupWindow mAlternatesPopup; 758 public RecipientChip(Drawable drawable, RecipientEntry entry, int offset, Rect bounds) { 759 super(drawable); 760 mDisplay = entry.getDisplayName(); 761 mValue = entry.getDestination(); 762 mContactId = entry.getContactId(); 763 mDataId = entry.getDataId(); 764 mOffset = offset; 765 mEntry = entry; 766 mBounds = bounds; 767 768 mAnchorView = new View(getContext()); 769 mAnchorView.setLeft(bounds.left); 770 mAnchorView.setRight(bounds.left); 771 mAnchorView.setTop(bounds.bottom); 772 mAnchorView.setBottom(bounds.bottom); 773 mAnchorView.setVisibility(View.GONE); 774 mRecipients.add(this); 775 mStart = offset; 776 // Add +1 for comma (?) 777 mEnd = offset + mValue.length() + 1; 778 } 779 780 public int getPreviousChipStart() { 781 return mStart; 782 } 783 784 public int getPreviousChipEnd() { 785 return mEnd; 786 } 787 788 public void setPreviousChipStart(int start) { 789 mStart = start; 790 } 791 792 public void setPreviousChipEnd(int end) { 793 mEnd = end; 794 } 795 796 public void unselectChip() { 797 int start = getChipStart(); 798 int end = getChipEnd(); 799 getSpannable().removeSpan(this); 800 mRecipients.remove(this); 801 Editable editable = getText(); 802 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 803 editable.replace(start, end, createChip(mEntry, false)); 804 clearSelectedChip(); 805 mSelectedChip = null; 806 setCursorVisible(true); 807 setSelection(editable.length()); 808 } 809 810 public RecipientChip selectChip() { 811 if (mEntry.getContactId() != -1) { 812 int start = getChipStart(); 813 int end = getChipEnd(); 814 getSpannable().removeSpan(this); 815 mRecipients.remove(this); 816 RecipientChip newChip; 817 CharSequence displayText = mTokenizer.terminateToken(mEntry.getDestination()); 818 // Always leave a blank space at the end of a chip. 819 int textLength = displayText.length()-1; 820 SpannableString chipText = new SpannableString(displayText); 821 try { 822 newChip = constructChipSpan(mEntry, start, true); 823 chipText.setSpan(newChip, 0, textLength, 824 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 825 } catch (NullPointerException e) { 826 Log.e(TAG, e.getMessage(), e); 827 return null; 828 } 829 Editable editable = getText(); 830 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 831 editable.replace(start, end, chipText); 832 setCursorVisible(false); 833 newChip.setSelected(true); 834 newChip.showAlternates(); 835 setCursorVisible(false); 836 return newChip; 837 } else { 838 CharSequence text = getValue(); 839 Editable editable = getText(); 840 removeChip(); 841 editable.append(text); 842 setCursorVisible(true); 843 setSelection(editable.length()); 844 return null; 845 } 846 } 847 848 public void onKeyDown(int keyCode, KeyEvent event) { 849 if (keyCode == KeyEvent.KEYCODE_DEL) { 850 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 851 mAlternatesPopup.dismiss(); 852 } 853 removeChip(); 854 } 855 } 856 857 public boolean isCompletedContact() { 858 return mContactId != -1; 859 } 860 861 public void removeChip() { 862 Spannable spannable = getSpannable(); 863 int spanStart = spannable.getSpanStart(this); 864 int spanEnd = spannable.getSpanEnd(this); 865 Editable text = getText(); 866 int toDelete = spanEnd; 867 boolean wasSelected = this == mSelectedChip; 868 // Clear that there is a selected chip before updating any text. 869 if (wasSelected) { 870 mSelectedChip = null; 871 } 872 // Always remove trailing spaces when removing a chip. 873 while (toDelete >= 0 && toDelete < text.length() - 1 && text.charAt(toDelete) == ' ') { 874 toDelete++; 875 } 876 spannable.removeSpan(this); 877 mRecipients.remove(this); 878 text.delete(spanStart, toDelete); 879 if (wasSelected) { 880 clearSelectedChip(); 881 } 882 } 883 884 public int getChipStart() { 885 return getSpannable().getSpanStart(this); 886 } 887 888 public int getChipEnd() { 889 return getSpannable().getSpanEnd(this); 890 } 891 892 public void replaceChip(RecipientEntry entry) { 893 boolean wasSelected = this == mSelectedChip; 894 if (wasSelected) { 895 mSelectedChip = null; 896 } 897 int start = getSpannable().getSpanStart(this); 898 int end = getSpannable().getSpanEnd(this); 899 getSpannable().removeSpan(this); 900 mRecipients.remove(this); 901 Editable editable = getText(); 902 CharSequence chipText = createChip(entry, false); 903 if (start == -1 || end == -1) { 904 Log.e(TAG, "The chip to replace does not exist but should."); 905 editable.insert(0, chipText); 906 } else { 907 editable.replace(start, end, chipText); 908 } 909 setCursorVisible(true); 910 if (wasSelected) { 911 clearSelectedChip(); 912 } 913 } 914 915 private void showAlternates() { 916 mAlternatesPopup = new ListPopupWindow(getContext()); 917 918 if (!mAlternatesPopup.isShowing()) { 919 mAlternatesAdapter = new RecipientAlternatesAdapter( 920 getContext(), 921 mEntry.getContactId(), mEntry.getDataId(), 922 mAlternatesLayout, mAlternatesSelectedLayout); 923 mAnchorView.setLeft(mLeft); 924 mAnchorView.setRight(mLeft); 925 mAlternatesPopup.setAnchorView(mAnchorView); 926 mAlternatesPopup.setAdapter(mAlternatesAdapter); 927 mAlternatesPopup.setWidth(getWidth()); 928 mAlternatesPopup.setOnItemClickListener(this); 929 mAlternatesPopup.show(); 930 } 931 } 932 933 private void setSelected(boolean selected) { 934 mSelected = selected; 935 } 936 937 public CharSequence getDisplay() { 938 return mDisplay; 939 } 940 941 public CharSequence getValue() { 942 return mValue; 943 } 944 945 private boolean isInDelete(int offset, float x, float y) { 946 // Figure out the bounds of this chip and whether or not 947 // the user clicked in the X portion. 948 return mSelected 949 && (offset == getChipEnd() 950 || (x > (mBounds.right - mChipDeleteWidth) && x < mBounds.right)); 951 } 952 953 public boolean matchesChip(int offset) { 954 int start = getChipStart(); 955 int end = getChipEnd(); 956 return (offset >= start && offset <= end); 957 } 958 959 public void onClick(View widget, int offset, float x, float y) { 960 if (mSelected) { 961 if (isInDelete(offset, x, y)) { 962 removeChip(); 963 } else { 964 clearSelectedChip(); 965 } 966 } 967 } 968 969 @Override 970 public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, 971 int y, int bottom, Paint paint) { 972 // Shift the bounds of this span to where it is actually drawn on the screeen. 973 mLeft = (int) x; 974 super.draw(canvas, text, start, end, x, top, y, bottom, paint); 975 } 976 977 @Override 978 public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) { 979 mAlternatesPopup.dismiss(); 980 clearComposingText(); 981 replaceChip(mAlternatesAdapter.getRecipientEntry(position)); 982 } 983 984 public long getContactId() { 985 return mContactId; 986 } 987 988 public long getDataId() { 989 return mDataId; 990 } 991 } 992} 993