RecipientEditTextView.java revision 6e8e8e8165a797611f80a2c17249147333d55ea7
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 private ListPopupWindow mAlternatesPopup; 107 108 public RecipientEditTextView(Context context, AttributeSet attrs) { 109 super(context, attrs); 110 mRecipients = new ArrayList<RecipientChip>(); 111 mAlternatesPopup = new ListPopupWindow(context); 112 setSuggestionsEnabled(false); 113 setOnItemClickListener(this); 114 setCustomSelectionActionModeCallback(this); 115 // When the user starts typing, make sure we unselect any selected 116 // chips. 117 addTextChangedListener(new TextWatcher() { 118 @Override 119 public void afterTextChanged(Editable s) { 120 // Do nothing. 121 } 122 @Override 123 public void onTextChanged(CharSequence s, int start, int before, int count) { 124 // Do nothing. 125 } 126 @Override 127 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 128 if (mSelectedChip != null) { 129 clearSelectedChip(); 130 setSelection(getText().length()); 131 } 132 } 133 }); 134 } 135 136 @Override 137 public void onSelectionChanged(int start, int end) { 138 // When selection changes, see if it is inside the chips area. 139 // If so, move the cursor back after the chips again. 140 if (mRecipients != null && mRecipients.size() > 0) { 141 Spannable span = getSpannable(); 142 RecipientChip[] chips = span.getSpans(start, getText().length(), RecipientChip.class); 143 if (chips != null && chips.length > 0) { 144 // Grab the last chip and set the cursor to after it. 145 setSelection(chips[chips.length - 1].getChipEnd() + 1); 146 } 147 } 148 super.onSelectionChanged(start, end); 149 } 150 151 @Override 152 public void onFocusChanged(boolean hasFocus, int direction, Rect previous) { 153 if (!hasFocus) { 154 shrink(); 155 } else { 156 expand(); 157 } 158 super.onFocusChanged(hasFocus, direction, previous); 159 } 160 161 private void shrink() { 162 if (mSelectedChip != null) { 163 clearSelectedChip(); 164 } else { 165 commitDefault(); 166 } 167 mMoreChip = createMoreChip(); 168 } 169 170 private void expand() { 171 removeMoreChip(); 172 setCursorVisible(true); 173 Editable text = getText(); 174 setSelection(text != null && text.length() > 0 ? text.length() : 0); 175 } 176 177 private CharSequence ellipsizeText(CharSequence text, TextPaint paint, float maxWidth) { 178 paint.setTextSize(mChipFontSize); 179 return TextUtils.ellipsize(text, paint, maxWidth, TextUtils.TruncateAt.END); 180 } 181 182 private Bitmap createSelectedChip(RecipientEntry contact, TextPaint paint, Layout layout) { 183 // Ellipsize the text so that it takes AT MOST the entire width of the 184 // autocomplete text entry area. Make sure to leave space for padding 185 // on the sides. 186 int height = (int) mChipHeight; 187 int deleteWidth = height; 188 CharSequence ellipsizedText = ellipsizeText(contact.getDisplayName(), paint, 189 calculateAvailableWidth(true) - deleteWidth); 190 191 // Make sure there is a minimum chip width so the user can ALWAYS 192 // tap a chip without difficulty. 193 int width = Math.max(deleteWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0, 194 ellipsizedText.length())) 195 + (mChipPadding * 2) + deleteWidth); 196 197 // Create the background of the chip. 198 Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 199 Canvas canvas = new Canvas(tmpBitmap); 200 if (mChipBackgroundPressed != null) { 201 mChipBackgroundPressed.setBounds(0, 0, width, height); 202 mChipBackgroundPressed.draw(canvas); 203 204 // Align the display text with where the user enters text. 205 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height 206 - Math.abs(height - mChipFontSize)/2, paint); 207 // Make the delete a square. 208 mChipDelete.setBounds(width - deleteWidth, 0, width, height); 209 mChipDelete.draw(canvas); 210 } else { 211 Log.w(TAG, "Unable to draw a background for the chips as it was never set"); 212 } 213 return tmpBitmap; 214 } 215 216 private Bitmap createUnselectedChip(RecipientEntry contact, TextPaint paint, Layout layout) { 217 // Ellipsize the text so that it takes AT MOST the entire width of the 218 // autocomplete text entry area. Make sure to leave space for padding 219 // on the sides. 220 int height = (int) mChipHeight; 221 int iconWidth = height; 222 CharSequence ellipsizedText = ellipsizeText(contact.getDisplayName(), paint, 223 calculateAvailableWidth(false) - iconWidth); 224 // Make sure there is a minimum chip width so the user can ALWAYS 225 // tap a chip without difficulty. 226 int width = Math.max(iconWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0, 227 ellipsizedText.length())) 228 + (mChipPadding * 2) + iconWidth); 229 230 // Create the background of the chip. 231 Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 232 Canvas canvas = new Canvas(tmpBitmap); 233 if (mChipBackground != null) { 234 mChipBackground.setBounds(0, 0, width, height); 235 mChipBackground.draw(canvas); 236 237 // Don't draw photos for recipients that have been typed in. 238 if (contact.getContactId() != -1) { 239 byte[] photoBytes = contact.getPhotoBytes(); 240 Bitmap photo; 241 if (photoBytes != null) { 242 // TODO: cache this in the recipient entry? 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 419 && (text.length() != 1 && text.charAt(0) != ' ')) { 420 RecipientEntry entry = RecipientEntry.constructFakeEntry(text); 421 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 422 editable.replace(start, end, createChip(entry)); 423 dismissDropDown(); 424 } 425 return false; 426 } 427 } 428 return false; 429 } 430 431 @Override 432 public boolean onKeyDown(int keyCode, KeyEvent event) { 433 if (mSelectedChip != null) { 434 mSelectedChip.onKeyDown(keyCode, event); 435 } 436 437 if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) { 438 return true; 439 } 440 441 return super.onKeyDown(keyCode, event); 442 } 443 444 private Spannable getSpannable() { 445 return (Spannable) getText(); 446 } 447 448 /** 449 * Instead of filtering on the entire contents of the edit box, 450 * this subclass method filters on the range from 451 * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd} 452 * if the length of that range meets or exceeds {@link #getThreshold} 453 * and makes sure that the range is not already a Chip. 454 */ 455 @Override 456 protected void performFiltering(CharSequence text, int keyCode) { 457 if (enoughToFilter()) { 458 int end = getSelectionEnd(); 459 int start = mTokenizer.findTokenStart(text, end); 460 // If this is a RecipientChip, don't filter 461 // on its contents. 462 Spannable span = getSpannable(); 463 RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class); 464 if (chips != null && chips.length > 0) { 465 return; 466 } 467 } 468 super.performFiltering(text, keyCode); 469 } 470 471 private void clearSelectedChip() { 472 if (mSelectedChip != null) { 473 mSelectedChip.unselectChip(); 474 mSelectedChip = null; 475 } 476 setCursorVisible(true); 477 } 478 479 @Override 480 public boolean onTouchEvent(MotionEvent event) { 481 if (!isFocused()) { 482 // Ignore any chip taps until this view is focused. 483 return super.onTouchEvent(event); 484 } 485 486 boolean handled = super.onTouchEvent(event); 487 int action = event.getAction(); 488 boolean chipWasSelected = false; 489 490 if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { 491 float x = event.getX(); 492 float y = event.getY(); 493 int offset = putOffsetInRange(getOffsetForPosition(x, y)); 494 RecipientChip currentChip = findChip(offset); 495 if (currentChip != null) { 496 if (action == MotionEvent.ACTION_UP) { 497 if (mSelectedChip != null && mSelectedChip != currentChip) { 498 clearSelectedChip(); 499 mSelectedChip = currentChip.selectChip(); 500 } else if (mSelectedChip == null) { 501 mSelectedChip = currentChip.selectChip(); 502 } else { 503 mSelectedChip.onClick(this, offset, x, y); 504 } 505 } 506 chipWasSelected = true; 507 } 508 } 509 if (action == MotionEvent.ACTION_UP && !chipWasSelected) { 510 clearSelectedChip(); 511 } 512 return handled; 513 } 514 515 // TODO: This algorithm will need a lot of tweaking after more people have used 516 // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring 517 // what comes before the finger. 518 private int putOffsetInRange(int o) { 519 int offset = o; 520 Editable text = getText(); 521 int length = text.length(); 522 // Remove whitespace from end to find "real end" 523 int realLength = length; 524 for (int i = length - 1; i >= 0; i--) { 525 if (text.charAt(i) == ' ') { 526 realLength--; 527 } else { 528 break; 529 } 530 } 531 532 // If the offset is beyond or at the end of the text, 533 // leave it alone. 534 if (offset >= realLength) { 535 return offset; 536 } 537 Editable editable = getText(); 538 while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) { 539 // Keep walking backward! 540 offset--; 541 } 542 return offset; 543 } 544 545 private int findText(Editable text, int offset) { 546 if (text.charAt(offset) != ' ') { 547 return offset; 548 } 549 return -1; 550 } 551 552 private RecipientChip findChip(int offset) { 553 RecipientChip[] chips = getSpannable().getSpans(0, getText().length(), RecipientChip.class); 554 // Find the chip that contains this offset. 555 for (int i = 0; i < chips.length; i++) { 556 RecipientChip chip = chips[i]; 557 if (chip.matchesChip(offset)) { 558 return chip; 559 } 560 } 561 return null; 562 } 563 564 private CharSequence createChip(RecipientEntry entry) { 565 CharSequence displayText = mTokenizer.terminateToken(entry.getDestination()); 566 // Always leave a blank space at the end of a chip. 567 int textLength = displayText.length(); 568 if (displayText.charAt(textLength - 1) == ' ') { 569 textLength--; 570 } else { 571 displayText = displayText.toString().concat(" "); 572 textLength = displayText.length(); 573 } 574 SpannableString chipText = new SpannableString(displayText); 575 int end = getSelectionEnd(); 576 int start = mTokenizer.findTokenStart(getText(), end); 577 try { 578 chipText.setSpan(constructChipSpan(entry, start, false), 0, textLength, 579 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 580 } catch (NullPointerException e) { 581 Log.e(TAG, e.getMessage(), e); 582 return null; 583 } 584 585 return chipText; 586 } 587 588 @Override 589 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 590 submitItemAtPosition(position); 591 } 592 593 private void submitItemAtPosition(int position) { 594 RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position); 595 // If the display name and the address are the same, then make this 596 // a fake recipient that is editable. 597 if (TextUtils.equals(entry.getDisplayName(), entry.getDestination())) { 598 entry = RecipientEntry.constructFakeEntry(entry.getDestination()); 599 } 600 clearComposingText(); 601 602 int end = getSelectionEnd(); 603 int start = mTokenizer.findTokenStart(getText(), end); 604 605 Editable editable = getText(); 606 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 607 editable.replace(start, end, createChip(entry)); 608 } 609 610 /** Returns a collection of contact Id for each chip inside this View. */ 611 /* package */ Collection<Long> getContactIds() { 612 final Set<Long> result = new HashSet<Long>(); 613 for (RecipientChip chip : mRecipients) { 614 result.add(chip.getContactId()); 615 } 616 return result; 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 for (RecipientChip chip : mRecipients) { 623 result.add(chip.getDataId()); 624 } 625 return result; 626 } 627 628 629 @Override 630 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 631 return false; 632 } 633 634 @Override 635 public void onDestroyActionMode(ActionMode mode) { 636 } 637 638 @Override 639 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 640 return false; 641 } 642 643 // Prevent selection of chips. 644 @Override 645 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 646 return false; 647 } 648 649 // The more chip is text that replaces any chips that do not fit in the pre-defined 650 // available space when the RecipientEditTextView loses focus and is drawn in a 651 // collapsed fashion. 652 private ImageSpan createMoreChip() { 653 if (mRecipients == null || mRecipients.size() <= CHIP_LIMIT) { 654 return null; 655 } 656 int numRecipients = mRecipients.size(); 657 int overage = numRecipients - CHIP_LIMIT; 658 Editable text = getText(); 659 // TODO: get the correct size from visual design. 660 int width = (int) Math.floor(getWidth() * MORE_WIDTH_FACTOR); 661 int height = getLineHeight(); 662 Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 663 Canvas canvas = new Canvas(drawable); 664 String moreText = getResources().getString(mMoreString, overage); 665 canvas.drawText(moreText, 0, moreText.length(), 0, height - getLayout().getLineDescent(0), 666 getPaint()); 667 668 Drawable result = new BitmapDrawable(getResources(), drawable); 669 result.setBounds(0, 0, width, height); 670 ImageSpan moreSpan = new ImageSpan(result); 671 Spannable spannable = getSpannable(); 672 // Remove the overage chips. 673 RecipientChip[] chips = spannable.getSpans(0, text.length(), RecipientChip.class); 674 if (chips == null || chips.length == 0) { 675 Log.w(TAG, 676 "We have recipients. Tt should not be possible to have zero RecipientChips."); 677 return null; 678 } 679 mRemovedSpans = new ArrayList<RecipientChip>(); 680 int totalReplaceStart = 0; 681 int totalReplaceEnd = 0; 682 for (int i = numRecipients - overage; i < chips.length; i++) { 683 mRemovedSpans.add(chips[i]); 684 if (i == numRecipients - overage) { 685 totalReplaceStart = chips[i].getChipStart(); 686 } 687 if (i == chips.length - 1) { 688 totalReplaceEnd = chips[i].getChipEnd(); 689 } 690 chips[i].setPreviousChipStart(chips[i].getChipStart()); 691 chips[i].setPreviousChipEnd(chips[i].getChipEnd()); 692 spannable.removeSpan(chips[i]); 693 } 694 695 for (int i = chips.length - 1; i >= numRecipients - overage; i--) { 696 mRecipients.remove(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 mRecipients.add(chip); 725 } 726 mRemovedSpans.clear(); 727 } 728 } 729 } 730 731 /** 732 * RecipientChip defines an ImageSpan that contains information relevant to 733 * a particular recipient. 734 */ 735 public class RecipientChip extends ImageSpan implements OnItemClickListener { 736 private final CharSequence mDisplay; 737 738 private final CharSequence mValue; 739 740 private final int mOffset; 741 742 private View mAnchorView; 743 744 private int mLeft; 745 746 private final long mContactId; 747 748 private final long mDataId; 749 750 private RecipientEntry mEntry; 751 752 private boolean mSelected = false; 753 754 private RecipientAlternatesAdapter mAlternatesAdapter; 755 756 private Rect mBounds; 757 758 private int mStart = -1; 759 private int mEnd = -1; 760 761 public RecipientChip(Drawable drawable, RecipientEntry entry, int offset, Rect bounds) { 762 super(drawable); 763 mDisplay = entry.getDisplayName(); 764 mValue = entry.getDestination(); 765 mContactId = entry.getContactId(); 766 mDataId = entry.getDataId(); 767 mOffset = offset; 768 mEntry = entry; 769 mBounds = bounds; 770 771 mAnchorView = new View(getContext()); 772 mAnchorView.setLeft(bounds.left); 773 mAnchorView.setRight(bounds.left); 774 mAnchorView.setTop(bounds.bottom); 775 mAnchorView.setBottom(bounds.bottom); 776 mAnchorView.setVisibility(View.GONE); 777 mRecipients.add(this); 778 mStart = offset; 779 // Add +1 for comma (?) 780 mEnd = offset + mValue.length() + 1; 781 } 782 783 public int getPreviousChipStart() { 784 return mStart; 785 } 786 787 public int getPreviousChipEnd() { 788 return mEnd; 789 } 790 791 public void setPreviousChipStart(int start) { 792 mStart = start; 793 } 794 795 public void setPreviousChipEnd(int end) { 796 mEnd = end; 797 } 798 799 public void unselectChip() { 800 if (getChipStart() == -1 || getChipEnd() == -1) { 801 mSelectedChip = null; 802 return; 803 } 804 clearComposingText(); 805 RecipientChip newChipSpan = null; 806 try { 807 newChipSpan = constructChipSpan(mEntry, mOffset, false); 808 } catch (NullPointerException e) { 809 Log.e(TAG, e.getMessage(), e); 810 return; 811 } 812 replace(newChipSpan); 813 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 814 mAlternatesPopup.dismiss(); 815 } 816 return; 817 } 818 819 public void onKeyDown(int keyCode, KeyEvent event) { 820 if (keyCode == KeyEvent.KEYCODE_DEL) { 821 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 822 mAlternatesPopup.dismiss(); 823 } 824 removeChip(); 825 } 826 } 827 828 public boolean isCompletedContact() { 829 return mContactId != -1; 830 } 831 832 private void replace(RecipientChip newChip) { 833 Spannable spannable = getSpannable(); 834 int spanStart = getChipStart(); 835 int spanEnd = getChipEnd(); 836 boolean wasSelected = this == mSelectedChip; 837 if (wasSelected) { 838 mSelectedChip = null; 839 } 840 QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, ""); 841 spannable.removeSpan(this); 842 mRecipients.remove(this); 843 spannable.setSpan(newChip, spanStart, spanEnd, 0); 844 if (wasSelected) { 845 clearSelectedChip(); 846 mSelectedChip = newChip; 847 } 848 } 849 850 public void removeChip() { 851 Spannable spannable = getSpannable(); 852 int spanStart = spannable.getSpanStart(this); 853 int spanEnd = spannable.getSpanEnd(this); 854 Editable text = getText(); 855 int toDelete = spanEnd; 856 boolean wasSelected = this == mSelectedChip; 857 // Clear that there is a selected chip before updating any text. 858 if (wasSelected) { 859 mSelectedChip = null; 860 } 861 // Always remove trailing spaces when removing a chip. 862 while (toDelete >= 0 && toDelete < text.length() - 1 && text.charAt(toDelete) == ' ') { 863 toDelete++; 864 } 865 spannable.removeSpan(this); 866 mRecipients.remove(this); 867 text.delete(spanStart, toDelete); 868 if (wasSelected) { 869 clearSelectedChip(); 870 } 871 } 872 873 public int getChipStart() { 874 return getSpannable().getSpanStart(this); 875 } 876 877 public int getChipEnd() { 878 return getSpannable().getSpanEnd(this); 879 } 880 881 public void replaceChip(RecipientEntry entry) { 882 clearComposingText(); 883 884 RecipientChip newChipSpan = null; 885 try { 886 newChipSpan = constructChipSpan(entry, mOffset, false); 887 } catch (NullPointerException e) { 888 Log.e(TAG, e.getMessage(), e); 889 return; 890 } 891 replace(newChipSpan); 892 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 893 mAlternatesPopup.dismiss(); 894 } 895 } 896 897 public RecipientChip selectChip() { 898 clearComposingText(); 899 RecipientChip newChipSpan = null; 900 if (isCompletedContact()) { 901 try { 902 newChipSpan = constructChipSpan(mEntry, mOffset, true); 903 newChipSpan.setSelected(true); 904 } catch (NullPointerException e) { 905 Log.e(TAG, e.getMessage(), e); 906 return newChipSpan; 907 } 908 replace(newChipSpan); 909 if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) { 910 mAlternatesPopup.dismiss(); 911 } 912 mSelected = true; 913 // Make sure we call edit on the new chip span. 914 newChipSpan.showAlternates(); 915 setCursorVisible(false); 916 } else { 917 CharSequence text = getValue(); 918 removeChip(); 919 Editable editable = getText(); 920 editable.append(text); 921 setCursorVisible(true); 922 setSelection(editable.length()); 923 } 924 return newChipSpan; 925 } 926 927 private void showAlternates() { 928 // mAlternatesPopup = new ListPopupWindow(getContext()); 929 930 if (!mAlternatesPopup.isShowing()) { 931 mAlternatesAdapter = new RecipientAlternatesAdapter( 932 getContext(), 933 mEntry.getContactId(), mEntry.getDataId(), 934 mAlternatesLayout, mAlternatesSelectedLayout); 935 mAnchorView.setLeft(mLeft); 936 mAnchorView.setRight(mLeft); 937 mAlternatesPopup.setAnchorView(mAnchorView); 938 mAlternatesPopup.setAdapter(mAlternatesAdapter); 939 mAlternatesPopup.setWidth(getWidth()); 940 mAlternatesPopup.setOnItemClickListener(this); 941 mAlternatesPopup.show(); 942 } 943 } 944 945 private void setSelected(boolean selected) { 946 mSelected = selected; 947 } 948 949 public CharSequence getDisplay() { 950 return mDisplay; 951 } 952 953 public CharSequence getValue() { 954 return mValue; 955 } 956 957 private boolean isInDelete(int offset, float x, float y) { 958 // Figure out the bounds of this chip and whether or not 959 // the user clicked in the X portion. 960 return mSelected 961 && (offset == getChipEnd() 962 || (x > (mBounds.right - mChipDeleteWidth) && x < mBounds.right)); 963 } 964 965 public boolean matchesChip(int offset) { 966 int start = getChipStart(); 967 int end = getChipEnd(); 968 return (offset >= start && offset <= end); 969 } 970 971 public void onClick(View widget, int offset, float x, float y) { 972 if (mSelected) { 973 if (isInDelete(offset, x, y)) { 974 removeChip(); 975 } else { 976 clearSelectedChip(); 977 } 978 } 979 } 980 981 @Override 982 public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, 983 int y, int bottom, Paint paint) { 984 // Shift the bounds of this span to where it is actually drawn on the screeen. 985 mLeft = (int) x; 986 super.draw(canvas, text, start, end, x, top, y, bottom, paint); 987 } 988 989 @Override 990 public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) { 991 mAlternatesPopup.dismiss(); 992 clearComposingText(); 993 replaceChip(mAlternatesAdapter.getRecipientEntry(position)); 994 } 995 996 public long getContactId() { 997 return mContactId; 998 } 999 1000 public long getDataId() { 1001 return mDataId; 1002 } 1003 } 1004} 1005 1006