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