MessageListItem.java revision 4beab88c92226628bf743070f88affb701968ce0
1/* 2 * Copyright (C) 2009 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.email.activity; 18 19import android.content.Context; 20import android.content.res.Configuration; 21import android.content.res.Resources; 22import android.graphics.Bitmap; 23import android.graphics.BitmapFactory; 24import android.graphics.Canvas; 25import android.graphics.Paint; 26import android.graphics.Typeface; 27import android.graphics.drawable.Drawable; 28import android.text.Layout.Alignment; 29import android.text.Spannable; 30import android.text.SpannableString; 31import android.text.SpannableStringBuilder; 32import android.text.StaticLayout; 33import android.text.TextPaint; 34import android.text.TextUtils; 35import android.text.TextUtils.TruncateAt; 36import android.text.format.DateUtils; 37import android.text.style.ForegroundColorSpan; 38import android.text.style.StyleSpan; 39import android.util.AttributeSet; 40import android.view.MotionEvent; 41import android.view.View; 42import android.view.accessibility.AccessibilityEvent; 43 44import com.android.email.R; 45import com.android.emailcommon.utility.TextUtilities; 46import com.google.common.base.Objects; 47 48/** 49 * This custom View is the list item for the MessageList activity, and serves two purposes: 50 * 1. It's a container to store message metadata (e.g. the ids of the message, mailbox, & account) 51 * 2. It handles internal clicks such as the checkbox or the favorite star 52 */ 53public class MessageListItem extends View { 54 // Note: messagesAdapter directly fiddles with these fields. 55 /* package */ long mMessageId; 56 /* package */ long mMailboxId; 57 /* package */ long mAccountId; 58 59 private MessagesAdapter mAdapter; 60 private MessageListItemCoordinates mCoordinates; 61 private Context mContext; 62 63 private boolean mDownEvent; 64 65 public static final String MESSAGE_LIST_ITEMS_CLIP_LABEL = 66 "com.android.email.MESSAGE_LIST_ITEMS"; 67 68 public MessageListItem(Context context) { 69 super(context); 70 init(context); 71 } 72 73 public MessageListItem(Context context, AttributeSet attrs) { 74 super(context, attrs); 75 init(context); 76 } 77 78 public MessageListItem(Context context, AttributeSet attrs, int defStyle) { 79 super(context, attrs, defStyle); 80 init(context); 81 } 82 83 // Wide mode shows sender, snippet, time, and favorite spread out across the screen 84 private static final int MODE_WIDE = MessageListItemCoordinates.WIDE_MODE; 85 // Sentinel indicating that the view needs layout 86 public static final int NEEDS_LAYOUT = -1; 87 88 private static boolean sInit = false; 89 private static final TextPaint sDefaultPaint = new TextPaint(); 90 private static final TextPaint sBoldPaint = new TextPaint(); 91 private static final TextPaint sDatePaint = new TextPaint(); 92 private static Bitmap sAttachmentIcon; 93 private static Bitmap sInviteIcon; 94 private static int sBadgeMargin; 95 private static Bitmap sFavoriteIconOff; 96 private static Bitmap sFavoriteIconOn; 97 private static Bitmap sSelectedIconOn; 98 private static Bitmap sSelectedIconOff; 99 private static Bitmap sStateReplied; 100 private static Bitmap sStateForwarded; 101 private static Bitmap sStateRepliedAndForwarded; 102 private static String sSubjectSnippetDivider; 103 private static String sSubjectDescription; 104 private static String sSubjectEmptyDescription; 105 106 // Static colors. 107 private static int DEFAULT_TEXT_COLOR; 108 private static int ACTIVATED_TEXT_COLOR; 109 private static int LIGHT_TEXT_COLOR; 110 private static int DRAFT_TEXT_COLOR; 111 private static int SUBJECT_TEXT_COLOR_READ; 112 private static int SUBJECT_TEXT_COLOR_UNREAD; 113 private static int SNIPPET_TEXT_COLOR_READ; 114 private static int SNIPPET_TEXT_COLOR_UNREAD; 115 private static int SENDERS_TEXT_COLOR_READ; 116 private static int SENDERS_TEXT_COLOR_UNREAD; 117 private static int DATE_TEXT_COLOR_READ; 118 private static int DATE_TEXT_COLOR_UNREAD; 119 120 public String mSender; 121 public SpannableStringBuilder mText; 122 public CharSequence mSnippet; 123 private String mSubject; 124 private StaticLayout mSubjectLayout; 125 public boolean mRead; 126 public boolean mHasAttachment = false; 127 public boolean mHasInvite = true; 128 public boolean mIsFavorite = false; 129 public boolean mHasBeenRepliedTo = false; 130 public boolean mHasBeenForwarded = false; 131 /** {@link Paint} for account color chips. null if no chips should be drawn. */ 132 public Paint mColorChipPaint; 133 134 private int mMode = -1; 135 136 private int mViewWidth = 0; 137 private int mViewHeight = 0; 138 139 private static int sItemHeightWide; 140 private static int sItemHeightNormal; 141 142 // Note: these cannot be shared Drawables because they are selectors which have state. 143 private Drawable mReadSelector; 144 private Drawable mUnreadSelector; 145 private Drawable mWideReadSelector; 146 private Drawable mWideUnreadSelector; 147 148 private CharSequence mFormattedSender; 149 // We must initialize this to something, in case the timestamp of the message is zero (which 150 // should be very rare); this is otherwise set in setTimestamp 151 private CharSequence mFormattedDate = ""; 152 153 private void init(Context context) { 154 mContext = context; 155 if (!sInit) { 156 Resources r = context.getResources(); 157 sSubjectDescription = r.getString(R.string.message_subject_description).concat(", "); 158 sSubjectEmptyDescription = r.getString(R.string.message_is_empty_description); 159 sSubjectSnippetDivider = r.getString(R.string.message_list_subject_snippet_divider); 160 sItemHeightWide = 161 r.getDimensionPixelSize(R.dimen.message_list_item_height_wide); 162 sItemHeightNormal = 163 r.getDimensionPixelSize(R.dimen.message_list_item_height_normal); 164 165 sDefaultPaint.setTypeface(Typeface.DEFAULT); 166 sDefaultPaint.setAntiAlias(true); 167 sDatePaint.setTypeface(Typeface.DEFAULT); 168 sDatePaint.setAntiAlias(true); 169 sBoldPaint.setTypeface(Typeface.DEFAULT_BOLD); 170 sBoldPaint.setAntiAlias(true); 171 172 sAttachmentIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_attachment); 173 sInviteIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_invite_holo_light); 174 sBadgeMargin = r.getDimensionPixelSize(R.dimen.message_list_badge_margin); 175 sFavoriteIconOff = 176 BitmapFactory.decodeResource(r, R.drawable.btn_star_off_normal_email_holo_light); 177 sFavoriteIconOn = 178 BitmapFactory.decodeResource(r, R.drawable.btn_star_on_normal_email_holo_light); 179 sSelectedIconOff = 180 BitmapFactory.decodeResource(r, R.drawable.btn_check_off_normal_holo_light); 181 sSelectedIconOn = 182 BitmapFactory.decodeResource(r, R.drawable.btn_check_on_normal_holo_light); 183 184 sStateReplied = 185 BitmapFactory.decodeResource(r, R.drawable.ic_badge_reply_holo_light); 186 sStateForwarded = 187 BitmapFactory.decodeResource(r, R.drawable.ic_badge_forward_holo_light); 188 sStateRepliedAndForwarded = 189 BitmapFactory.decodeResource(r, R.drawable.ic_badge_reply_forward_holo_light); 190 191 DEFAULT_TEXT_COLOR = r.getColor(R.color.default_text_color); 192 ACTIVATED_TEXT_COLOR = r.getColor(android.R.color.white); 193 SUBJECT_TEXT_COLOR_READ = r.getColor(R.color.subject_text_color_read); 194 SUBJECT_TEXT_COLOR_UNREAD = r.getColor(R.color.subject_text_color_unread); 195 SNIPPET_TEXT_COLOR_READ = r.getColor(R.color.snippet_text_color_read); 196 SNIPPET_TEXT_COLOR_UNREAD = r.getColor(R.color.snippet_text_color_unread); 197 SENDERS_TEXT_COLOR_READ = r.getColor(R.color.senders_text_color_read); 198 SENDERS_TEXT_COLOR_UNREAD = r.getColor(R.color.senders_text_color_unread); 199 DATE_TEXT_COLOR_READ = r.getColor(R.color.date_text_color_read); 200 DATE_TEXT_COLOR_UNREAD = r.getColor(R.color.date_text_color_unread); 201 202 sInit = true; 203 } 204 } 205 206 /** 207 * Invalidate all drawing caches associated with drawing message list items. 208 * This is an expensive operation, and should be done rarely, such as when system font size 209 * changes occurs. 210 */ 211 public static void resetDrawingCaches() { 212 MessageListItemCoordinates.resetCaches(); 213 sInit = false; 214 } 215 216 /** 217 * Sets message subject and snippet safely, ensuring the cache is invalidated. 218 */ 219 public void setText(String subject, String snippet, boolean forceUpdate) { 220 boolean changed = false; 221 if (!Objects.equal(mSubject, subject)) { 222 mSubject = subject; 223 changed = true; 224 populateContentDescription(); 225 } 226 227 if (!Objects.equal(mSnippet, snippet)) { 228 mSnippet = snippet; 229 changed = true; 230 } 231 232 if (forceUpdate || changed || (mSubject == null && mSnippet == null) /* first time */) { 233 SpannableStringBuilder ssb = new SpannableStringBuilder(); 234 boolean hasSubject = false; 235 if (!TextUtils.isEmpty(mSubject)) { 236 SpannableString ss = new SpannableString(mSubject); 237 ss.setSpan(new StyleSpan(mRead ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(), 238 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 239 ssb.append(ss); 240 hasSubject = true; 241 } 242 if (!TextUtils.isEmpty(mSnippet)) { 243 if (hasSubject) { 244 ssb.append(sSubjectSnippetDivider); 245 } 246 ssb.append(mSnippet); 247 } 248 mText = ssb; 249 requestLayout(); 250 } 251 } 252 253 long mTimeFormatted = 0; 254 public void setTimestamp(long timestamp) { 255 if (mTimeFormatted != timestamp) { 256 mFormattedDate = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString(); 257 mTimeFormatted = timestamp; 258 } 259 } 260 261 /** 262 * Determine the mode of this view (WIDE or NORMAL) 263 * 264 * @param width The width of the view 265 * @return The mode of the view 266 */ 267 private int getViewMode(int width) { 268 return MessageListItemCoordinates.getMode(mContext, width); 269 } 270 271 private Drawable mCurentBackground = null; // Only used by updateBackground() 272 273 private void updateBackground() { 274 final Drawable newBackground; 275 if (mRead) { 276 if (mMode == MODE_WIDE) { 277 if (mWideReadSelector == null) { 278 mWideReadSelector = getContext().getResources() 279 .getDrawable(R.drawable.conversation_wide_read_selector); 280 } 281 newBackground = mWideReadSelector; 282 } else { 283 if (mReadSelector == null) { 284 mReadSelector = getContext().getResources() 285 .getDrawable(R.drawable.conversation_read_selector); 286 } 287 newBackground = mReadSelector; 288 } 289 } else { 290 if (mMode == MODE_WIDE) { 291 if (mWideUnreadSelector == null) { 292 mWideUnreadSelector = getContext().getResources().getDrawable( 293 R.drawable.conversation_wide_unread_selector); 294 } 295 newBackground = mWideUnreadSelector; 296 } else { 297 if (mUnreadSelector == null) { 298 mUnreadSelector = getContext().getResources() 299 .getDrawable(R.drawable.conversation_unread_selector); 300 } 301 newBackground = mUnreadSelector; 302 } 303 } 304 if (newBackground != mCurentBackground) { 305 // setBackgroundDrawable is a heavy operation. Only call it when really needed. 306 setBackgroundDrawable(newBackground); 307 mCurentBackground = newBackground; 308 } 309 } 310 311 private void calculateSubjectText() { 312 if (mText == null || mText.length() == 0) { 313 return; 314 } 315 boolean hasSubject = false; 316 if (!TextUtils.isEmpty(mSubject)) { 317 int subjectColor = getFontColor(mRead ? SUBJECT_TEXT_COLOR_READ 318 : SUBJECT_TEXT_COLOR_UNREAD); 319 mText.setSpan(new ForegroundColorSpan(subjectColor), 0, mSubject.length(), 320 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 321 } 322 if (!TextUtils.isEmpty(mSnippet)) { 323 int snippetColor = getFontColor(mRead ? SNIPPET_TEXT_COLOR_READ 324 : SNIPPET_TEXT_COLOR_UNREAD); 325 mText.setSpan(new ForegroundColorSpan(snippetColor), mSubject.length() + 1, mText 326 .length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 327 } 328 } 329 330 private void calculateDrawingData() { 331 sDefaultPaint.setTextSize(mCoordinates.subjectFontSize); 332 calculateSubjectText(); 333 mSubjectLayout = new StaticLayout(mText, sDefaultPaint, 334 mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, false /* includePad */); 335 if (mCoordinates.subjectLineCount < mSubjectLayout.getLineCount()) { 336 // TODO: ellipsize. 337 int end = mSubjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1); 338 mSubjectLayout = new StaticLayout(mText.subSequence(0, end), 339 sDefaultPaint, mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 340 } 341 342 // Now, format the sender for its width 343 TextPaint senderPaint = mRead ? sDefaultPaint : sBoldPaint; 344 // And get the ellipsized string for the calculated width 345 if (TextUtils.isEmpty(mSender)) { 346 mFormattedSender = ""; 347 } else { 348 int senderWidth = mCoordinates.sendersWidth; 349 senderPaint.setTextSize(mCoordinates.sendersFontSize); 350 senderPaint.setColor(getFontColor(mRead ? SENDERS_TEXT_COLOR_READ 351 : SENDERS_TEXT_COLOR_UNREAD)); 352 mFormattedSender = TextUtils.ellipsize(mSender, senderPaint, senderWidth, 353 TruncateAt.END); 354 } 355 } 356 @Override 357 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 358 if (widthMeasureSpec != 0 || mViewWidth == 0) { 359 mViewWidth = MeasureSpec.getSize(widthMeasureSpec); 360 int mode = getViewMode(mViewWidth); 361 if (mode != mMode) { 362 mMode = mode; 363 } 364 mViewHeight = measureHeight(heightMeasureSpec, mMode); 365 } 366 setMeasuredDimension(mViewWidth, mViewHeight); 367 } 368 369 /** 370 * Determine the height of this view 371 * 372 * @param measureSpec A measureSpec packed into an int 373 * @param mode The current mode of this view 374 * @return The height of the view, honoring constraints from measureSpec 375 */ 376 private int measureHeight(int measureSpec, int mode) { 377 int result = 0; 378 int specMode = MeasureSpec.getMode(measureSpec); 379 int specSize = MeasureSpec.getSize(measureSpec); 380 381 if (specMode == MeasureSpec.EXACTLY) { 382 // We were told how big to be 383 result = specSize; 384 } else { 385 // Measure the text 386 if (mMode == MODE_WIDE) { 387 result = sItemHeightWide; 388 } else { 389 result = sItemHeightNormal; 390 } 391 if (specMode == MeasureSpec.AT_MOST) { 392 // Respect AT_MOST value if that was what is called for by 393 // measureSpec 394 result = Math.min(result, specSize); 395 } 396 } 397 return result; 398 } 399 400 @Override 401 public void draw(Canvas canvas) { 402 // Update the background, before View.draw() draws it. 403 setSelected(mAdapter.isSelected(this)); 404 updateBackground(); 405 super.draw(canvas); 406 } 407 408 @Override 409 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 410 super.onLayout(changed, left, top, right, bottom); 411 412 mCoordinates = MessageListItemCoordinates.forWidth(mContext, mViewWidth); 413 calculateDrawingData(); 414 } 415 416 private int getFontColor(int defaultColor) { 417 return isActivated() ? ACTIVATED_TEXT_COLOR : defaultColor; 418 } 419 420 @Override 421 protected void onDraw(Canvas canvas) { 422 // Draw the color chip indicating the mailbox this belongs to 423 if (mColorChipPaint != null) { 424 canvas.drawRect( 425 mCoordinates.chipX, mCoordinates.chipY, 426 mCoordinates.chipX + mCoordinates.chipWidth, 427 mCoordinates.chipY + mCoordinates.chipHeight, 428 mColorChipPaint); 429 } 430 431 // Draw the checkbox 432 canvas.drawBitmap(mAdapter.isSelected(this) ? sSelectedIconOn : sSelectedIconOff, 433 mCoordinates.checkmarkX, mCoordinates.checkmarkY, null); 434 435 // Draw the sender name 436 Paint senderPaint = mRead ? sDefaultPaint : sBoldPaint; 437 senderPaint.setColor(getFontColor(mRead ? SENDERS_TEXT_COLOR_READ 438 : SENDERS_TEXT_COLOR_UNREAD)); 439 senderPaint.setTextSize(mCoordinates.sendersFontSize); 440 canvas.drawText(mFormattedSender, 0, mFormattedSender.length(), 441 mCoordinates.sendersX, mCoordinates.sendersY - mCoordinates.sendersAscent, 442 senderPaint); 443 444 // Draw the reply state. Draw nothing if neither replied nor forwarded. 445 if (mHasBeenRepliedTo && mHasBeenForwarded) { 446 canvas.drawBitmap(sStateRepliedAndForwarded, 447 mCoordinates.stateX, mCoordinates.stateY, null); 448 } else if (mHasBeenRepliedTo) { 449 canvas.drawBitmap(sStateReplied, 450 mCoordinates.stateX, mCoordinates.stateY, null); 451 } else if (mHasBeenForwarded) { 452 canvas.drawBitmap(sStateForwarded, 453 mCoordinates.stateX, mCoordinates.stateY, null); 454 } 455 456 // Subject and snippet. 457 sDefaultPaint.setTextSize(mCoordinates.subjectFontSize); 458 canvas.save(); 459 canvas.translate( 460 mCoordinates.subjectX, 461 mCoordinates.subjectY); 462 mSubjectLayout.draw(canvas); 463 canvas.restore(); 464 465 // Draw the date 466 sDatePaint.setTextSize(mCoordinates.dateFontSize); 467 sDatePaint.setColor(mRead ? DATE_TEXT_COLOR_READ : DATE_TEXT_COLOR_UNREAD); 468 int dateX = mCoordinates.dateXEnd 469 - (int) sDatePaint.measureText(mFormattedDate, 0, mFormattedDate.length()); 470 471 canvas.drawText(mFormattedDate, 0, mFormattedDate.length(), 472 dateX, mCoordinates.dateY - mCoordinates.dateAscent, sDatePaint); 473 474 // Draw the favorite icon 475 canvas.drawBitmap(mIsFavorite ? sFavoriteIconOn : sFavoriteIconOff, 476 mCoordinates.starX, mCoordinates.starY, null); 477 478 // TODO: deal with the icon layouts better from the coordinate class so that this logic 479 // doesn't have to exist. 480 // Draw the attachment and invite icons, if necessary. 481 int iconsLeft = dateX - sBadgeMargin; 482 if (mHasAttachment) { 483 iconsLeft = iconsLeft - sAttachmentIcon.getWidth(); 484 canvas.drawBitmap(sAttachmentIcon, iconsLeft, mCoordinates.paperclipY, null); 485 } 486 if (mHasInvite) { 487 iconsLeft -= sInviteIcon.getWidth(); 488 canvas.drawBitmap(sInviteIcon, iconsLeft, mCoordinates.paperclipY, null); 489 } 490 491 } 492 493 /** 494 * Called by the adapter at bindView() time 495 * 496 * @param adapter the adapter that creates this view 497 */ 498 public void bindViewInit(MessagesAdapter adapter) { 499 mAdapter = adapter; 500 requestLayout(); 501 } 502 503 504 private static final int TOUCH_SLOP = 24; 505 private static int sScaledTouchSlop = -1; 506 507 private void initializeSlop(Context context) { 508 if (sScaledTouchSlop == -1) { 509 final Resources res = context.getResources(); 510 final Configuration config = res.getConfiguration(); 511 final float density = res.getDisplayMetrics().density; 512 final float sizeAndDensity; 513 if (config.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_XLARGE)) { 514 sizeAndDensity = density * 1.5f; 515 } else { 516 sizeAndDensity = density; 517 } 518 sScaledTouchSlop = (int) (sizeAndDensity * TOUCH_SLOP + 0.5f); 519 } 520 } 521 522 /** 523 * Overriding this method allows us to "catch" clicks in the checkbox or star 524 * and process them accordingly. 525 */ 526 @Override 527 public boolean onTouchEvent(MotionEvent event) { 528 initializeSlop(getContext()); 529 530 boolean handled = false; 531 int touchX = (int) event.getX(); 532 int checkRight = mCoordinates.checkmarkX 533 + mCoordinates.checkmarkWidthIncludingMargins + sScaledTouchSlop; 534 int starLeft = mCoordinates.starX - sScaledTouchSlop; 535 536 switch (event.getAction()) { 537 case MotionEvent.ACTION_DOWN: 538 if (touchX < checkRight || touchX > starLeft) { 539 mDownEvent = true; 540 if ((touchX < checkRight) || (touchX > starLeft)) { 541 handled = true; 542 } 543 } 544 break; 545 546 case MotionEvent.ACTION_CANCEL: 547 mDownEvent = false; 548 break; 549 550 case MotionEvent.ACTION_UP: 551 if (mDownEvent) { 552 if (touchX < checkRight) { 553 mAdapter.toggleSelected(this); 554 handled = true; 555 } else if (touchX > starLeft) { 556 mIsFavorite = !mIsFavorite; 557 mAdapter.updateFavorite(this, mIsFavorite); 558 handled = true; 559 } 560 } 561 break; 562 } 563 564 if (handled) { 565 invalidate(); 566 } else { 567 handled = super.onTouchEvent(event); 568 } 569 570 return handled; 571 } 572 573 @Override 574 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 575 event.setClassName(getClass().getName()); 576 event.setPackageName(getContext().getPackageName()); 577 event.setEnabled(true); 578 event.setContentDescription(getContentDescription()); 579 return true; 580 } 581 582 /** 583 * Sets the content description for this item, used for accessibility. 584 */ 585 private void populateContentDescription() { 586 if (!TextUtils.isEmpty(mSubject)) { 587 setContentDescription(sSubjectDescription + mSubject); 588 } else { 589 setContentDescription(sSubjectEmptyDescription); 590 } 591 } 592} 593