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