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