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