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