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