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