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