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