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