RecipientEditTextView.java revision cd61195b9be5614aefc4cda76c1732cc4840b18e
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.Canvas; 22import android.graphics.Paint; 23import android.graphics.Rect; 24import android.graphics.drawable.BitmapDrawable; 25import android.graphics.drawable.Drawable; 26import android.os.Handler; 27import android.text.Editable; 28import android.text.Layout; 29import android.text.Spannable; 30import android.text.SpannableString; 31import android.text.Spanned; 32import android.text.TextPaint; 33import android.text.TextUtils; 34import android.text.method.QwertyKeyListener; 35import android.text.style.ImageSpan; 36import android.util.AttributeSet; 37import android.util.Log; 38import android.view.KeyEvent; 39import android.view.MotionEvent; 40import android.view.View; 41import android.widget.AdapterView; 42import android.widget.AdapterView.OnItemClickListener; 43import android.widget.ListPopupWindow; 44import android.widget.MultiAutoCompleteTextView; 45import android.widget.PopupWindow.OnDismissListener; 46 47import java.util.Collection; 48import java.util.HashSet; 49import java.util.Set; 50 51import java.util.ArrayList; 52 53/** 54 * RecipientEditTextView is an auto complete text view for use with applications 55 * that use the new Chips UI for addressing a message to recipients. 56 */ 57public class RecipientEditTextView extends MultiAutoCompleteTextView 58 implements OnItemClickListener { 59 60 private static final String TAG = "RecipientEditTextView"; 61 62 private Drawable mChipBackground = null; 63 64 private Drawable mChipDelete = null; 65 66 private int mChipPadding; 67 68 private Tokenizer mTokenizer; 69 70 private final Handler mHandler; 71 72 private Runnable mDelayedSelectionMode = new Runnable() { 73 @Override 74 public void run() { 75 setSelection(getText().length()); 76 } 77 }; 78 79 private Drawable mChipBackgroundPressed; 80 81 private RecipientChip mSelectedChip; 82 83 private int mChipDeleteWidth; 84 85 private ArrayList<RecipientChip> mRecipients; 86 87 private int mAlternatesLayout; 88 89 private int mAlternatesSelectedLayout; 90 91 public RecipientEditTextView(Context context, AttributeSet attrs) { 92 super(context, attrs); 93 mHandler = new Handler(); 94 setOnItemClickListener(this); 95 mRecipients = new ArrayList<RecipientChip>(); 96 } 97 98 public RecipientChip constructChipSpan(RecipientEntry contact, int offset, boolean pressed) 99 throws NullPointerException { 100 if (mChipBackground == null) { 101 throw new NullPointerException 102 ("Unable to render any chips as setChipDimensions was not called."); 103 } 104 String text = contact.getDisplayName(); 105 Layout layout = getLayout(); 106 int line = layout.getLineForOffset(offset); 107 int lineTop = layout.getLineTop(line); 108 109 TextPaint paint = getPaint(); 110 float defaultSize = paint.getTextSize(); 111 112 // Reduce the size of the text slightly so that we can get the "look" of 113 // padding. 114 paint.setTextSize((float) (paint.getTextSize() * .9)); 115 116 // Ellipsize the text so that it takes AT MOST the entire width of the 117 // autocomplete text entry area. Make sure to leave space for padding 118 // on the sides. 119 CharSequence ellipsizedText = TextUtils.ellipsize(text, paint, 120 calculateAvailableWidth(pressed), TextUtils.TruncateAt.END); 121 122 int height = getLineHeight(); 123 int width = (int) Math.floor(paint.measureText(ellipsizedText, 0, ellipsizedText.length())) 124 + (mChipPadding * 2); 125 if (pressed) { 126 width += mChipDeleteWidth; 127 } 128 129 // Create the background of the chip. 130 Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); 131 Canvas canvas = new Canvas(tmpBitmap); 132 if (pressed) { 133 if (mChipBackgroundPressed != null) { 134 mChipBackgroundPressed.setBounds(0, 0, width, height); 135 mChipBackgroundPressed.draw(canvas); 136 137 // Align the display text with where the user enters text. 138 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height 139 - layout.getLineDescent(line), paint); 140 mChipDelete.setBounds(width - mChipDeleteWidth, 0, width, height); 141 mChipDelete.draw(canvas); 142 } else { 143 Log.w(TAG, 144 "Unable to draw a background for the chips as it was never set"); 145 } 146 } else { 147 if (mChipBackground != null) { 148 mChipBackground.setBounds(0, 0, width, height); 149 mChipBackground.draw(canvas); 150 151 // Align the display text with where the user enters text. 152 canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height 153 - layout.getLineDescent(line), paint); 154 } else { 155 Log.w(TAG, 156 "Unable to draw a background for the chips as it was never set"); 157 } 158 } 159 160 161 // Get the location of the widget so we can properly offset 162 // the anchor for each chip. 163 int[] xy = new int[2]; 164 getLocationOnScreen(xy); 165 // Pass the full text, un-ellipsized, to the chip. 166 Drawable result = new BitmapDrawable(getResources(), tmpBitmap); 167 result.setBounds(0, 0, width, height); 168 Rect bounds = new Rect(xy[0] + offset, xy[1] + lineTop, xy[0] + width, 169 calculateLineBottom(xy[1], line)); 170 RecipientChip recipientChip = new RecipientChip(result, contact, offset, bounds); 171 172 // Return text to the original size. 173 paint.setTextSize(defaultSize); 174 175 return recipientChip; 176 } 177 178 // The bottom of the line the chip will be located on is calculated by 4 factors: 179 // 1) which line the chip appears on 180 // 2) the height of a line in the autocomplete view 181 // 3) padding built into the edit text view will move the bottom position 182 // 4) the position of the autocomplete view on the screen, taking into account 183 // that any top padding will move this down visually 184 private int calculateLineBottom(int yOffset, int line) { 185 int bottomPadding = 0; 186 if (line == getLineCount() - 1) { 187 bottomPadding += getPaddingBottom(); 188 } 189 return ((line + 1) * getLineHeight()) + (yOffset + getPaddingTop()) + bottomPadding; 190 } 191 192 // Get the max amount of space a chip can take up. The formula takes into 193 // account the width of the EditTextView, any view padding, and padding 194 // that will be added to the chip. 195 private float calculateAvailableWidth(boolean pressed) { 196 int paddingRight = 0; 197 if (pressed) { 198 paddingRight = mChipDeleteWidth; 199 } 200 return getWidth() - getPaddingLeft() - getPaddingRight() - (mChipPadding * 2) 201 - paddingRight; 202 } 203 204 /** 205 * Set all chip dimensions and resources. This has to be done from the application 206 * as this is a static library. 207 * @param chipBackground drawable 208 * @param padding Padding around the text in a chip 209 * @param offset Offset between the chip and the dropdown of alternates 210 */ 211 public void setChipDimensions(Drawable chipBackground, Drawable chipBackgroundPressed, 212 Drawable chipDelete, int alternatesLayout, int alternatesSelectedLayout, float padding) { 213 mChipBackground = chipBackground; 214 mChipBackgroundPressed = chipBackgroundPressed; 215 mChipDelete = chipDelete; 216 mChipDeleteWidth = chipDelete.getIntrinsicWidth(); 217 mChipPadding = (int) padding; 218 mAlternatesLayout = alternatesLayout; 219 mAlternatesSelectedLayout = alternatesSelectedLayout; 220 } 221 222 @Override 223 public void setTokenizer(Tokenizer tokenizer) { 224 mTokenizer = tokenizer; 225 super.setTokenizer(mTokenizer); 226 } 227 228 // We want to handle replacing text in the onItemClickListener 229 // so we can get all the associated contact information including 230 // display text, address, and id. 231 @Override 232 protected void replaceText(CharSequence text) { 233 return; 234 } 235 236 @Override 237 public boolean onKeyUp(int keyCode, KeyEvent event) { 238 switch (keyCode) { 239 case KeyEvent.KEYCODE_ENTER: 240 case KeyEvent.KEYCODE_DPAD_CENTER: 241 case KeyEvent.KEYCODE_TAB: 242 if (event.hasNoModifiers()) { 243 if (isPopupShowing()) { 244 // choose the first entry. 245 submitItemAtPosition(0); 246 dismissDropDown(); 247 return true; 248 } else { 249 int end = getSelectionEnd(); 250 int start = mTokenizer.findTokenStart(getText(), end); 251 String text = getText().toString().substring(start, end); 252 clearComposingText(); 253 254 Editable editable = getText(); 255 RecipientEntry entry = RecipientEntry.constructFakeEntry(text); 256 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 257 editable.replace(start, end, createChip(entry)); 258 dismissDropDown(); 259 } 260 } 261 } 262 return super.onKeyUp(keyCode, event); 263 } 264 265 public void onChipChanged() { 266 // Must be posted so that the previous span 267 // is correctly replaced with the previous selection points. 268 mHandler.post(mDelayedSelectionMode); 269 } 270 271 @Override 272 public boolean onKeyDown(int keyCode, KeyEvent event) { 273 274 if (mSelectedChip != null) { 275 mSelectedChip.onKeyDown(keyCode, event); 276 } 277 278 if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) { 279 return true; 280 } 281 282 return super.onKeyDown(keyCode, event); 283 } 284 285 private Spannable getSpannable() { 286 return (Spannable) getText(); 287 } 288 289 /** 290 * Instead of filtering on the entire contents of the edit box, 291 * this subclass method filters on the range from 292 * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd} 293 * if the length of that range meets or exceeds {@link #getThreshold} 294 * and makes sure that the range is not already a Chip. 295 */ 296 @Override 297 protected void performFiltering(CharSequence text, int keyCode) { 298 if (enoughToFilter()) { 299 int end = getSelectionEnd(); 300 int start = mTokenizer.findTokenStart(text, end); 301 // If this is a RecipientChip, don't filter 302 // on its contents. 303 Spannable span = getSpannable(); 304 RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class); 305 if (chips != null && chips.length > 0) { 306 return; 307 } 308 } 309 super.performFiltering(text, keyCode); 310 } 311 312 private void clearSelectedChip() { 313 if (mSelectedChip != null) { 314 mSelectedChip.unselectChip(); 315 mSelectedChip = null; 316 } 317 } 318 319 @Override 320 public boolean onTouchEvent(MotionEvent event) { 321 int action = event.getAction(); 322 boolean handled = super.onTouchEvent(event); 323 boolean chipWasSelected = false; 324 325 if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { 326 float x = event.getX(); 327 float y = event.getY(); 328 int offset = putOffsetInRange(getOffsetForPosition(x, y)); 329 RecipientChip currentChip = findChip(offset); 330 if (currentChip != null) { 331 if (action == MotionEvent.ACTION_UP) { 332 if (mSelectedChip != null && mSelectedChip != currentChip) { 333 clearSelectedChip(); 334 mSelectedChip = currentChip.selectChip(); 335 } else if (mSelectedChip == null) { 336 mSelectedChip = currentChip.selectChip(); 337 } else { 338 mSelectedChip.onClick(this, offset, x, y); 339 } 340 } 341 chipWasSelected = true; 342 } 343 } 344 if (action == MotionEvent.ACTION_UP && !chipWasSelected) { 345 clearSelectedChip(); 346 } 347 return handled; 348 } 349 350 // TODO: This algorithm will need a lot of tweaking after more people have used 351 // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring 352 // what comes before the finger. 353 private int putOffsetInRange(int o) { 354 int offset = o; 355 Editable text = getText(); 356 int length = text.length(); 357 // Remove whitespace from end to find "real end" 358 int realLength = length; 359 for (int i = length - 1; i >= 0; i--) { 360 if (text.charAt(i) == ' ') { 361 realLength--; 362 } else { 363 break; 364 } 365 } 366 367 // If the offset is beyond where there was any visible text, 368 // then leave it should not be pulled into the range of a chip. 369 if (offset > realLength) { 370 return offset; 371 } 372 while (offset >= 0 && findChip(offset) == null) { 373 // Keep walking backward! 374 offset--; 375 } 376 return offset; 377 } 378 379 private RecipientChip findChip(int offset) { 380 RecipientChip[] chips = getSpannable().getSpans(0, offset, RecipientChip.class); 381 // Find the chip that contains this offset. 382 for (int i = 0; i < chips.length; i++) { 383 RecipientChip chip = chips[i]; 384 if (chip.matchesChip(offset)) { 385 return chip; 386 } 387 } 388 return null; 389 } 390 391 private CharSequence createChip(RecipientEntry entry) { 392 CharSequence displayText = mTokenizer.terminateToken(entry.getDestination()); 393 // Always leave a blank space at the end of a chip. 394 int textLength = displayText.length(); 395 if (displayText.charAt(textLength - 1) == ' ') { 396 textLength--; 397 } else { 398 displayText = displayText.toString().concat(" "); 399 textLength = displayText.length(); 400 } 401 SpannableString chipText = new SpannableString(displayText); 402 int end = getSelectionEnd(); 403 int start = mTokenizer.findTokenStart(getText(), end); 404 try { 405 chipText.setSpan(constructChipSpan(entry, start, false), 0, textLength, 406 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 407 } catch (NullPointerException e) { 408 Log.e(TAG, e.getMessage(), e); 409 return null; 410 } 411 412 return chipText; 413 } 414 415 @Override 416 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 417 submitItemAtPosition(position); 418 } 419 420 private void submitItemAtPosition(int position) { 421 RecipientEntry entry = (RecipientEntry) getAdapter().getItem(position); 422 clearComposingText(); 423 424 int end = getSelectionEnd(); 425 int start = mTokenizer.findTokenStart(getText(), end); 426 427 Editable editable = getText(); 428 editable.replace(start, end, createChip(entry)); 429 QwertyKeyListener.markAsReplaced(editable, start, end, ""); 430 } 431 432 /** Returns a collection of contact Id for each chip inside this View. */ 433 /* package */ Collection<Long> getContactIds() { 434 final Set<Long> result = new HashSet<Long>(); 435 for (RecipientChip chip : mRecipients) { 436 result.add(chip.getContactId()); 437 } 438 return result; 439 } 440 441 /** Returns a collection of data Id for each chip inside this View. May be null. */ 442 /* package */ Collection<Long> getDataIds() { 443 final Set<Long> result = new HashSet<Long>(); 444 for (RecipientChip chip : mRecipients) { 445 result.add(chip.getDataId()); 446 } 447 return result; 448 } 449 450 /** 451 * RecipientChip defines an ImageSpan that contains information relevant to 452 * a particular recipient. 453 */ 454 public class RecipientChip extends ImageSpan implements OnItemClickListener, OnDismissListener { 455 private final CharSequence mDisplay; 456 457 private final CharSequence mValue; 458 459 private final int mOffset; 460 461 private ListPopupWindow mPopup; 462 463 private View mAnchorView; 464 465 private int mLeft; 466 467 private final long mContactId; 468 469 private final long mDataId; 470 471 private RecipientEntry mEntry; 472 473 private boolean mSelected = false; 474 475 private RecipientAlternatesAdapter mAlternatesAdapter; 476 477 private Rect mBounds; 478 479 public RecipientChip(Drawable drawable, RecipientEntry entry, int offset, Rect bounds) { 480 super(drawable); 481 mDisplay = entry.getDisplayName(); 482 mValue = entry.getDestination(); 483 mContactId = entry.getContactId(); 484 mDataId = entry.getDataId(); 485 mOffset = offset; 486 mEntry = entry; 487 mBounds = bounds; 488 489 mAnchorView = new View(getContext()); 490 mAnchorView.setLeft(bounds.left); 491 mAnchorView.setRight(bounds.left); 492 mAnchorView.setTop(bounds.bottom); 493 mAnchorView.setBottom(bounds.bottom); 494 mAnchorView.setVisibility(View.GONE); 495 mRecipients.add(this); 496 } 497 498 public void unselectChip() { 499 if (getChipStart() == -1 || getChipEnd() == -1) { 500 mSelectedChip = null; 501 return; 502 } 503 clearComposingText(); 504 RecipientChip newChipSpan = null; 505 try { 506 newChipSpan = constructChipSpan(mEntry, mOffset, false); 507 } catch (NullPointerException e) { 508 Log.e(TAG, e.getMessage(), e); 509 return; 510 } 511 replace(newChipSpan); 512 if (mPopup != null && mPopup.isShowing()) { 513 mPopup.dismiss(); 514 } 515 return; 516 } 517 518 public void onKeyDown(int keyCode, KeyEvent event) { 519 if (keyCode == KeyEvent.KEYCODE_DEL) { 520 if (mPopup != null && mPopup.isShowing()) { 521 mPopup.dismiss(); 522 } 523 removeChip(); 524 } 525 } 526 527 public boolean isCompletedContact() { 528 return mContactId != -1; 529 } 530 531 private void replace(RecipientChip newChip) { 532 Spannable spannable = getSpannable(); 533 int spanStart = getChipStart(); 534 int spanEnd = getChipEnd(); 535 QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, ""); 536 spannable.removeSpan(this); 537 mRecipients.remove(this); 538 spannable.setSpan(newChip, spanStart, spanEnd, 0); 539 } 540 541 public void removeChip() { 542 Spannable spannable = getSpannable(); 543 int spanStart = getChipStart(); 544 int spanEnd = getChipEnd(); 545 if (this == mSelectedChip) { 546 mSelectedChip = null; 547 } 548 Editable text = getText(); 549 int toDelete = spanEnd; 550 // Always remove trailing spaces when removing a chip. 551 while (toDelete < text.length() - 1 && text.charAt(toDelete) == ' ') { 552 toDelete++; 553 } 554 QwertyKeyListener.markAsReplaced(getText(), spanStart, spanEnd, ""); 555 spannable.removeSpan(this); 556 mRecipients.remove(this); 557 spannable.setSpan(null, spanStart, spanEnd, 0); 558 text.delete(spanStart, toDelete); 559 } 560 561 public int getChipStart() { 562 return getSpannable().getSpanStart(this); 563 } 564 565 public int getChipEnd() { 566 return getSpannable().getSpanEnd(this); 567 } 568 569 public void replaceChip(RecipientEntry entry) { 570 clearComposingText(); 571 572 RecipientChip newChipSpan = null; 573 try { 574 newChipSpan = constructChipSpan(entry, mOffset, false); 575 } catch (NullPointerException e) { 576 Log.e(TAG, e.getMessage(), e); 577 return; 578 } 579 replace(newChipSpan); 580 if (mPopup != null && mPopup.isShowing()) { 581 mPopup.dismiss(); 582 } 583 onChipChanged(); 584 } 585 586 public RecipientChip selectChip() { 587 clearComposingText(); 588 RecipientChip newChipSpan = null; 589 if (isCompletedContact()) { 590 try { 591 newChipSpan = constructChipSpan(mEntry, mOffset, true); 592 newChipSpan.setSelected(true); 593 } catch (NullPointerException e) { 594 Log.e(TAG, e.getMessage(), e); 595 return newChipSpan; 596 } 597 replace(newChipSpan); 598 if (mPopup != null && mPopup.isShowing()) { 599 mPopup.dismiss(); 600 } 601 mSelected = true; 602 // Make sure we call edit on the new chip span. 603 newChipSpan.showAlternates(); 604 } else { 605 CharSequence text = getValue(); 606 removeChip(); 607 Editable editable = getText(); 608 setSelection(editable.length()); 609 editable.append(text); 610 } 611 return newChipSpan; 612 } 613 614 private void showAlternates() { 615 mPopup = new ListPopupWindow(RecipientEditTextView.this.getContext()); 616 617 if (!mPopup.isShowing()) { 618 mAlternatesAdapter = new RecipientAlternatesAdapter( 619 RecipientEditTextView.this.getContext(), 620 mEntry.getContactId(), mEntry.getDataId(), 621 mAlternatesLayout, mAlternatesSelectedLayout); 622 mAnchorView.setLeft(mLeft); 623 mAnchorView.setRight(mLeft); 624 mPopup.setAnchorView(mAnchorView); 625 mPopup.setAdapter(mAlternatesAdapter); 626 mPopup.setWidth(getWidth()); 627 mPopup.setOnItemClickListener(this); 628 mPopup.setOnDismissListener(this); 629 mPopup.show(); 630 } 631 } 632 633 private void setSelected(boolean selected) { 634 mSelected = selected; 635 } 636 637 public CharSequence getDisplay() { 638 return mDisplay; 639 } 640 641 public CharSequence getValue() { 642 return mValue; 643 } 644 645 private boolean isInDelete(int offset, float x, float y) { 646 // Figure out the bounds of this chip and whether or not 647 // the user clicked in the X portion. 648 return mSelected 649 && (offset == getChipEnd() 650 || (x > (mBounds.right - mChipDeleteWidth) && x < mBounds.right)); 651 } 652 653 public boolean matchesChip(int offset) { 654 int start = getChipStart(); 655 int end = getChipEnd(); 656 return (offset >= start && offset <= end); 657 } 658 659 public void onClick(View widget, int offset, float x, float y) { 660 if (mSelected) { 661 if (isInDelete(offset, x, y)) { 662 removeChip(); 663 return; 664 } 665 } 666 } 667 668 @Override 669 public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, 670 int y, int bottom, Paint paint) { 671 // Shift the bounds of this span to where it is actually drawn on the screeen. 672 mLeft = (int) x; 673 super.draw(canvas, text, start, end, x, top, y, bottom, paint); 674 } 675 676 @Override 677 public void onItemClick(AdapterView<?> adapterView, View view, int position, long rowId) { 678 mPopup.dismiss(); 679 clearComposingText(); 680 replaceChip(mAlternatesAdapter.getRecipientEntry(position)); 681 } 682 683 // When the popup dialog is dismissed, return the cursor to the end. 684 @Override 685 public void onDismiss() { 686 mHandler.post(mDelayedSelectionMode); 687 } 688 689 public long getContactId() { 690 return mContactId; 691 } 692 693 public long getDataId() { 694 return mDataId; 695 } 696 } 697} 698 699