ConversationItemView.java revision 866d319dd23ec8b7b7d5476c65f7f83469d55d2d
1/* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.mail.browse; 19 20import com.google.common.annotations.VisibleForTesting; 21 22import android.animation.Animator; 23import android.animation.AnimatorSet; 24import android.animation.ObjectAnimator; 25import android.animation.Animator.AnimatorListener; 26import android.content.Context; 27import android.content.res.Resources; 28import android.database.Cursor; 29import android.graphics.Bitmap; 30import android.graphics.BitmapFactory; 31import android.graphics.Canvas; 32import android.graphics.Color; 33import android.graphics.LinearGradient; 34import android.graphics.Paint; 35import android.graphics.Rect; 36import android.graphics.Shader; 37import android.graphics.Typeface; 38import android.graphics.drawable.Drawable; 39import android.text.Layout.Alignment; 40import android.text.Spannable; 41import android.text.SpannableStringBuilder; 42import android.text.StaticLayout; 43import android.text.TextPaint; 44import android.text.TextUtils; 45import android.text.TextUtils.TruncateAt; 46import android.text.format.DateUtils; 47import android.text.style.CharacterStyle; 48import android.text.style.ForegroundColorSpan; 49import android.text.style.StyleSpan; 50import android.text.util.Rfc822Token; 51import android.text.util.Rfc822Tokenizer; 52import android.util.SparseArray; 53import android.view.MotionEvent; 54import android.view.View; 55import android.view.View.MeasureSpec; 56import android.view.animation.DecelerateInterpolator; 57import android.widget.Checkable; 58import android.widget.ListView; 59 60import com.android.mail.R; 61import com.android.mail.browse.ConversationItemViewModel.SenderFragment; 62import com.android.mail.perf.Timer; 63import com.android.mail.providers.Address; 64import com.android.mail.providers.Conversation; 65import com.android.mail.providers.Folder; 66import com.android.mail.providers.UIProvider; 67import com.android.mail.providers.UIProvider.ConversationColumns; 68import com.android.mail.ui.ConversationSelectionSet; 69import com.android.mail.ui.FolderDisplayer; 70import com.android.mail.ui.ViewMode; 71import com.android.mail.utils.Utils; 72 73public class ConversationItemView extends View { 74 // Timer. 75 private static int sLayoutCount = 0; 76 private static Timer sTimer; // Create the sTimer here if you need to do perf analysis. 77 private static final int PERF_LAYOUT_ITERATIONS = 50; 78 private static final String PERF_TAG_LAYOUT = "CCHV.layout"; 79 private static final String PERF_TAG_CALCULATE_TEXTS_BITMAPS = "CCHV.txtsbmps"; 80 private static final String PERF_TAG_CALCULATE_SENDER_SUBJECT = "CCHV.sendersubj"; 81 private static final String PERF_TAG_CALCULATE_FOLDERS = "CCHV.folders"; 82 private static final String PERF_TAG_CALCULATE_COORDINATES = "CCHV.coordinates"; 83 84 // Static bitmaps. 85 private static Bitmap CHECKMARK_OFF; 86 private static Bitmap CHECKMARK_ON; 87 private static Bitmap STAR_OFF; 88 private static Bitmap STAR_ON; 89 private static Bitmap ATTACHMENT; 90 private static Bitmap ONLY_TO_ME; 91 private static Bitmap TO_ME_AND_OTHERS; 92 private static Bitmap IMPORTANT_ONLY_TO_ME; 93 private static Bitmap IMPORTANT_TO_ME_AND_OTHERS; 94 private static Bitmap IMPORTANT_TO_OTHERS; 95 private static Bitmap DATE_BACKGROUND; 96 private static Bitmap STATE_REPLIED; 97 private static Bitmap STATE_FORWARDED; 98 private static Bitmap STATE_REPLIED_AND_FORWARDED; 99 private static Bitmap STATE_CALENDAR_INVITE; 100 101 // Static colors. 102 private static int DEFAULT_TEXT_COLOR; 103 private static int ACTIVATED_TEXT_COLOR; 104 private static int LIGHT_TEXT_COLOR; 105 private static int DRAFT_TEXT_COLOR; 106 private static int SUBJECT_TEXT_COLOR_READ; 107 private static int SUBJECT_TEXT_COLOR_UNREAD; 108 private static int SNIPPET_TEXT_COLOR_READ; 109 private static int SNIPPET_TEXT_COLOR_UNREAD; 110 private static int SENDERS_TEXT_COLOR_READ; 111 private static int SENDERS_TEXT_COLOR_UNREAD; 112 private static int DATE_TEXT_COLOR_READ; 113 private static int DATE_TEXT_COLOR_UNREAD; 114 private static int DATE_BACKGROUND_PADDING_LEFT; 115 private static int TOUCH_SLOP; 116 private static int sDateBackgroundHeight; 117 private static int sStandardScaledDimen; 118 private static int sUndoAnimationDuration; 119 private static CharacterStyle sLightTextStyle; 120 private static CharacterStyle sNormalTextStyle; 121 122 // Static paints. 123 private static TextPaint sPaint = new TextPaint(); 124 private static TextPaint sFoldersPaint = new TextPaint(); 125 126 // Backgrounds for different states. 127 private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>(); 128 129 // Dimensions and coordinates. 130 private int mViewWidth = -1; 131 private int mMode = -1; 132 private int mDateX; 133 private int mPaperclipX; 134 private int mFoldersXEnd; 135 private int mSendersWidth; 136 137 /** Whether we're running under test mode. */ 138 private boolean mTesting = false; 139 /** Whether we are on a tablet device or not */ 140 private final boolean mTabletDevice; 141 142 @VisibleForTesting 143 ConversationItemViewCoordinates mCoordinates; 144 145 private final Context mContext; 146 147 public ConversationItemViewModel mHeader; 148 private ViewMode mViewMode; 149 private boolean mDownEvent; 150 private boolean mChecked = false; 151 private static int sFadedActivatedColor = -1; 152 private ConversationSelectionSet mSelectedConversationSet; 153 private Folder mDisplayedFolder; 154 private boolean mPriorityMarkersEnabled; 155 private int mAnimatedHeight = -1; 156 private static Bitmap MORE_FOLDERS; 157 158 static { 159 sPaint.setAntiAlias(true); 160 sFoldersPaint.setAntiAlias(true); 161 } 162 163 164 /** 165 * Handles displaying folders in a conversation header view. 166 */ 167 static class ConversationItemFolderDisplayer extends FolderDisplayer { 168 // Maximum number of folders to be displayed. 169 private static final int MAX_DISPLAYED_FOLDERS_COUNT = 4; 170 171 private int mFoldersCount; 172 private boolean mHasMoreFolders; 173 174 public ConversationItemFolderDisplayer(Context context) { 175 super(context); 176 } 177 178 @Override 179 public void loadConversationFolders(String rawFolders, Folder ignoreFolder) { 180 super.loadConversationFolders(rawFolders, ignoreFolder); 181 182 mFoldersCount = mFoldersSortedSet.size(); 183 mHasMoreFolders = mFoldersCount > MAX_DISPLAYED_FOLDERS_COUNT; 184 mFoldersCount = Math.min(mFoldersCount, MAX_DISPLAYED_FOLDERS_COUNT); 185 } 186 187 public boolean hasVisibleFolders() { 188 return mFoldersCount > 0; 189 } 190 191 private int measureFolders(int mode) { 192 int availableSpace = ConversationItemViewCoordinates.getFoldersWidth(mContext, mode); 193 int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext, mode, 194 mFoldersCount); 195 196 int totalWidth = 0; 197 for (Folder f : mFoldersSortedSet) { 198 final String folderString = f.name; 199 int width = (int) sFoldersPaint.measureText(folderString) + cellSize; 200 if (width % cellSize != 0) { 201 width += cellSize - (width % cellSize); 202 } 203 totalWidth += width; 204 if (totalWidth > availableSpace) { 205 break; 206 } 207 } 208 209 return totalWidth; 210 } 211 212 public void drawFolders(Canvas canvas, ConversationItemViewCoordinates coordinates, 213 int foldersXEnd, int mode) { 214 if (mFoldersCount == 0) { 215 return; 216 } 217 218 int xEnd = foldersXEnd; 219 int y = coordinates.foldersY - coordinates.foldersAscent; 220 int height = coordinates.foldersHeight; 221 int topPadding = coordinates.foldersTopPadding; 222 int ascent = coordinates.foldersAscent; 223 sFoldersPaint.setTextSize(coordinates.foldersFontSize); 224 225 // Initialize space and cell size based on the current mode. 226 int availableSpace = ConversationItemViewCoordinates.getFoldersWidth(mContext, mode); 227 int averageWidth = availableSpace / mFoldersCount; 228 int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext, mode, 229 mFoldersCount); 230 231 // First pass to calculate the starting point. 232 int totalWidth = measureFolders(mode); 233 int xStart = xEnd - Math.min(availableSpace, totalWidth); 234 235 // Second pass to draw folders. 236 for (Folder f : mFoldersSortedSet) { 237 final String folderString = f.name; 238 final int fgColor = f.getForegroundColor(mDefaultFgColor); 239 final int bgColor = f.getBackgroundColor(mDefaultBgColor); 240 int width = cellSize; 241 boolean labelTooLong = false; 242 width = (int) sFoldersPaint.measureText(folderString) + cellSize; 243 if (width % cellSize != 0) { 244 width += cellSize - (width % cellSize); 245 } 246 if (totalWidth > availableSpace && width > averageWidth) { 247 width = averageWidth; 248 labelTooLong = true; 249 } 250 251 // TODO (mindyp): how to we get this? 252 final boolean isMuted = false; 253 // labelValues.folderId == sGmail.getFolderMap(mAccount).getFolderIdIgnored(); 254 255 // Draw the box. 256 sFoldersPaint.setColor(bgColor); 257 sFoldersPaint.setStyle(isMuted ? Paint.Style.STROKE : Paint.Style.FILL_AND_STROKE); 258 canvas.drawRect(xStart, y + ascent, xStart + width, y + ascent + height, 259 sFoldersPaint); 260 261 // Draw the text. 262 int padding = getPadding(width, (int) sFoldersPaint.measureText(folderString)); 263 if (labelTooLong) { 264 TextPaint shortPaint = new TextPaint(); 265 shortPaint.setColor(fgColor); 266 shortPaint.setTextSize(coordinates.foldersFontSize); 267 padding = cellSize / 2; 268 int rightBorder = xStart + width - padding; 269 Shader shader = new LinearGradient(rightBorder - padding, y, rightBorder, y, 270 fgColor, Utils.getTransparentColor(fgColor), Shader.TileMode.CLAMP); 271 shortPaint.setShader(shader); 272 canvas.drawText(folderString, xStart + padding, y + topPadding, shortPaint); 273 } else { 274 sFoldersPaint.setColor(fgColor); 275 canvas.drawText(folderString, xStart + padding, y + topPadding, sFoldersPaint); 276 } 277 278 availableSpace -= width; 279 xStart += width; 280 if (availableSpace <= 0 && mHasMoreFolders) { 281 canvas.drawBitmap(MORE_FOLDERS, xEnd, y + ascent, sFoldersPaint); 282 return; 283 } 284 } 285 } 286 287 /** 288 * Helpers function to align an element in the center of a space. 289 */ 290 private static int getPadding(int space, int length) { 291 return (space - length) / 2; 292 } 293 } 294 295 public ConversationItemView(Context context, String account) { 296 super(context); 297 mContext = context.getApplicationContext(); 298 mTabletDevice = Utils.useTabletUI(mContext); 299 300 Resources res = mContext.getResources(); 301 302 if (CHECKMARK_OFF == null) { 303 // Initialize static bitmaps. 304 CHECKMARK_OFF = BitmapFactory.decodeResource(res, 305 R.drawable.btn_check_off_normal_holo_light); 306 CHECKMARK_ON = BitmapFactory.decodeResource(res, 307 R.drawable.btn_check_on_normal_holo_light); 308 STAR_OFF = BitmapFactory.decodeResource(res, 309 R.drawable.btn_star_off_normal_email_holo_light); 310 STAR_ON = BitmapFactory.decodeResource(res, 311 R.drawable.btn_star_on_normal_email_holo_light); 312 ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double); 313 TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single); 314 IMPORTANT_ONLY_TO_ME = BitmapFactory.decodeResource(res, 315 R.drawable.ic_email_caret_double_important_unread); 316 IMPORTANT_TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, 317 R.drawable.ic_email_caret_single_important_unread); 318 IMPORTANT_TO_OTHERS = BitmapFactory.decodeResource(res, 319 R.drawable.ic_email_caret_none_important_unread); 320 ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attachment_holo_light); 321 MORE_FOLDERS = BitmapFactory.decodeResource(res, R.drawable.ic_folders_more); 322 DATE_BACKGROUND = BitmapFactory.decodeResource(res, R.drawable.folder_bg_holo_light); 323 STATE_REPLIED = 324 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_holo_light); 325 STATE_FORWARDED = 326 BitmapFactory.decodeResource(res, R.drawable.ic_badge_forward_holo_light); 327 STATE_REPLIED_AND_FORWARDED = 328 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_forward_holo_light); 329 STATE_CALENDAR_INVITE = 330 BitmapFactory.decodeResource(res, R.drawable.ic_badge_invite_holo_light); 331 332 // Initialize colors. 333 DEFAULT_TEXT_COLOR = res.getColor(R.color.default_text_color); 334 ACTIVATED_TEXT_COLOR = res.getColor(android.R.color.white); 335 LIGHT_TEXT_COLOR = res.getColor(R.color.light_text_color); 336 DRAFT_TEXT_COLOR = res.getColor(R.color.drafts); 337 SUBJECT_TEXT_COLOR_READ = res.getColor(R.color.subject_text_color_read); 338 SUBJECT_TEXT_COLOR_UNREAD = res.getColor(R.color.subject_text_color_unread); 339 SNIPPET_TEXT_COLOR_READ = res.getColor(R.color.snippet_text_color_read); 340 SNIPPET_TEXT_COLOR_UNREAD = res.getColor(R.color.snippet_text_color_unread); 341 SENDERS_TEXT_COLOR_READ = res.getColor(R.color.senders_text_color_read); 342 SENDERS_TEXT_COLOR_UNREAD = res.getColor(R.color.senders_text_color_unread); 343 DATE_TEXT_COLOR_READ = res.getColor(R.color.date_text_color_read); 344 DATE_TEXT_COLOR_UNREAD = res.getColor(R.color.date_text_color_unread); 345 DATE_BACKGROUND_PADDING_LEFT = res 346 .getDimensionPixelSize(R.dimen.date_background_padding_left); 347 TOUCH_SLOP = res.getDimensionPixelSize(R.dimen.touch_slop); 348 sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height); 349 sStandardScaledDimen = res.getDimensionPixelSize(R.dimen.standard_scaled_dimen); 350 sUndoAnimationDuration = res.getInteger(R.integer.undo_animation_duration); 351 352 // Initialize static color. 353 sNormalTextStyle = new StyleSpan(Typeface.NORMAL); 354 sLightTextStyle = new ForegroundColorSpan(LIGHT_TEXT_COLOR); 355 } 356 } 357 358 public void bind(Cursor cursor, ViewMode viewMode, ConversationSelectionSet set, 359 Folder folder) { 360 mViewMode = viewMode; 361 mHeader = ConversationItemViewModel.forCursor(cursor); 362 mSelectedConversationSet = set; 363 mDisplayedFolder = folder; 364 setContentDescription(mHeader.getContentDescription(mContext)); 365 requestLayout(); 366 } 367 368 /** 369 * Get the Conversation object associated with this view. 370 */ 371 public Conversation getConversation() { 372 return mHeader.conversation; 373 } 374 375 /** 376 * Sets the mode. Only used for testing. 377 */ 378 @VisibleForTesting 379 void setMode(int mode) { 380 mMode = mode; 381 mTesting = true; 382 } 383 384 private static void startTimer(String tag) { 385 if (sTimer != null) { 386 sTimer.start(tag); 387 } 388 } 389 390 private static void pauseTimer(String tag) { 391 if (sTimer != null) { 392 sTimer.pause(tag); 393 } 394 } 395 396 @Override 397 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 398 startTimer(PERF_TAG_LAYOUT); 399 400 super.onLayout(changed, left, top, right, bottom); 401 402 int width = right - left; 403 if (width != mViewWidth) { 404 mViewWidth = width; 405 if (!mTesting) { 406 mMode = ConversationItemViewCoordinates.getMode(mContext, mViewMode); 407 } 408 } 409 mHeader.viewWidth = mViewWidth; 410 Resources res = getResources(); 411 mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen); 412 if (mHeader.standardScaledDimen != sStandardScaledDimen) { 413 // Large Text has been toggle on/off. Update the static dimens. 414 sStandardScaledDimen = mHeader.standardScaledDimen; 415 ConversationItemViewCoordinates.refreshConversationHeights(mContext); 416 sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height); 417 } 418 mCoordinates = ConversationItemViewCoordinates.forWidth(mContext, mViewWidth, mMode, 419 mHeader.standardScaledDimen); 420 calculateTextsAndBitmaps(); 421 calculateCoordinates(); 422 mHeader.validate(mContext); 423 424 pauseTimer(PERF_TAG_LAYOUT); 425 if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) { 426 sTimer.dumpResults(); 427 sTimer = new Timer(); 428 sLayoutCount = 0; 429 } 430 } 431 432 @Override 433 public void setBackgroundResource(int resourceId) { 434 Drawable drawable = mBackgrounds.get(resourceId); 435 if (drawable == null) { 436 drawable = getResources().getDrawable(resourceId); 437 mBackgrounds.put(resourceId, drawable); 438 } 439 if (getBackground() != drawable) { 440 super.setBackgroundDrawable(drawable); 441 } 442 } 443 444 private void calculateTextsAndBitmaps() { 445 startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 446 if (mSelectedConversationSet != null) { 447 mChecked = mSelectedConversationSet.contains(mHeader.conversation); 448 } 449 // Update font color. 450 int fontColor = getFontColor(DEFAULT_TEXT_COLOR); 451 boolean fontChanged = false; 452 if (mHeader.fontColor != fontColor) { 453 fontChanged = true; 454 mHeader.fontColor = fontColor; 455 } 456 457 boolean isUnread = mHeader.unread; 458 459 final boolean checkboxEnabled = true; 460 if (mHeader.checkboxVisible != checkboxEnabled) { 461 mHeader.checkboxVisible = checkboxEnabled; 462 } 463 464 // Update background. 465 updateBackground(isUnread); 466 467 if (mHeader.isLayoutValid(mContext)) { 468 // Relayout subject if font color has changed. 469 if (fontChanged) { 470 createSubjectSpans(isUnread); 471 layoutSubject(); 472 } 473 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 474 return; 475 } 476 477 startTimer(PERF_TAG_CALCULATE_FOLDERS); 478 479 // Initialize folder displayer. 480 if (mCoordinates.showFolders) { 481 mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext); 482 mHeader.folderDisplayer.loadConversationFolders(mHeader.rawFolders, mDisplayedFolder); 483 } 484 485 pauseTimer(PERF_TAG_CALCULATE_FOLDERS); 486 487 // Star. 488 mHeader.starBitmap = mHeader.starred ? STAR_ON : STAR_OFF; 489 490 // Date. 491 mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext, 492 mHeader.conversation.dateMs).toString(); 493 494 // Paper clip icon. 495 mHeader.paperclip = null; 496 if (mHeader.conversation.hasAttachments) { 497 mHeader.paperclip = ATTACHMENT; 498 } 499 // Personal level. 500 mHeader.personalLevelBitmap = null; 501 if (mCoordinates.showPersonalLevel) { 502 int personalLevel = mHeader.personalLevel; 503 final boolean isImportant = 504 mHeader.priority == UIProvider.ConversationPriority.IMPORTANT; 505 // TODO(mindyp): get whether importance indicators are enabled 506 // mPriorityMarkersEnabled = 507 // persistence.getPriorityInboxArrowsEnabled(mContext, mAccount); 508 mPriorityMarkersEnabled = true; 509 boolean useImportantMarkers = isImportant && mPriorityMarkersEnabled; 510 511 if (personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) { 512 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME 513 : ONLY_TO_ME; 514 } else if (personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) { 515 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS 516 : TO_ME_AND_OTHERS; 517 } else if (useImportantMarkers) { 518 mHeader.personalLevelBitmap = IMPORTANT_TO_OTHERS; 519 } 520 } 521 522 startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 523 524 // Subject. 525 createSubjectSpans(isUnread); 526 527 // Parse senders fragments. 528 parseSendersFragments(isUnread); 529 530 pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 531 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 532 } 533 534 private void createSubjectSpans(boolean isUnread) { 535 final String subject = filterTag(mHeader.conversation.subject); 536 final String snippet = mHeader.conversation.snippet; 537 int subjectColor = isUnread ? SUBJECT_TEXT_COLOR_UNREAD : SUBJECT_TEXT_COLOR_READ; 538 int snippetColor = isUnread ? SNIPPET_TEXT_COLOR_UNREAD : SNIPPET_TEXT_COLOR_READ; 539 mHeader.subjectText = new SpannableStringBuilder(mContext.getString( 540 R.string.subject_and_snippet, subject, snippet)); 541 if (isUnread) { 542 mHeader.subjectText.setSpan(new StyleSpan(Typeface.BOLD), 0, subject.length(), 543 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 544 } 545 int fontColor = getFontColor(subjectColor); 546 mHeader.subjectText.setSpan(new ForegroundColorSpan(fontColor), 0, 547 subject.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 548 fontColor = getFontColor(snippetColor); 549 mHeader.subjectText.setSpan(new ForegroundColorSpan(fontColor), subject.length() + 1, 550 mHeader.subjectText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 551 } 552 553 private int getFontColor(int defaultColor) { 554 return isActivated() && mTabletDevice ? ACTIVATED_TEXT_COLOR 555 : defaultColor; 556 } 557 558 private void layoutSubject() { 559 sPaint.setTextSize(mCoordinates.subjectFontSize); 560 sPaint.setColor(mHeader.fontColor); 561 mHeader.subjectLayout = new StaticLayout(mHeader.subjectText, sPaint, 562 mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 563 if (mCoordinates.subjectLineCount < mHeader.subjectLayout.getLineCount()) { 564 int end = mHeader.subjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1); 565 mHeader.subjectLayout = new StaticLayout(mHeader.subjectText.subSequence(0, end), 566 sPaint, mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 567 } 568 } 569 570 /** 571 * Parses senders text into small fragments. 572 */ 573 private void parseSendersFragments(boolean isUnread) { 574 if (TextUtils.isEmpty(mHeader.conversation.senders)) { 575 return; 576 } 577 mHeader.sendersText = formatSenders(mHeader.conversation.senders); 578 mHeader.addSenderFragment(0, mHeader.sendersText.length(), sNormalTextStyle, true); 579 } 580 581 private String formatSenders(String sendersString) { 582 String[] senders = TextUtils.split(sendersString, Address.ADDRESS_DELIMETER); 583 String[] namesOnly = new String[senders.length]; 584 Rfc822Token[] senderTokens; 585 String display; 586 for (int i = 0; i < senders.length; i++) { 587 senderTokens = Rfc822Tokenizer.tokenize(senders[i]); 588 if (senderTokens != null && senderTokens.length > 0) { 589 display = senderTokens[0].getName(); 590 if (TextUtils.isEmpty(display)) { 591 display = senderTokens[0].getAddress(); 592 } 593 namesOnly[i] = display; 594 } 595 } 596 return TextUtils.join(Address.ADDRESS_DELIMETER + " ", namesOnly); 597 } 598 599 private boolean canFitFragment(int width, int line, int fixedWidth) { 600 if (line == mCoordinates.sendersLineCount) { 601 return width + fixedWidth <= mSendersWidth; 602 } else { 603 return width <= mSendersWidth; 604 } 605 } 606 607 private void calculateCoordinates() { 608 startTimer(PERF_TAG_CALCULATE_COORDINATES); 609 610 sPaint.setTextSize(mCoordinates.dateFontSize); 611 sPaint.setTypeface(Typeface.DEFAULT); 612 mDateX = mCoordinates.dateXEnd - (int) sPaint.measureText(mHeader.dateText); 613 614 mPaperclipX = mDateX - ATTACHMENT.getWidth(); 615 616 int cellWidth = mContext.getResources().getDimensionPixelSize(R.dimen.folder_cell_width); 617 618 if (ConversationItemViewCoordinates.isWideMode(mMode)) { 619 // Folders are displayed above the date. 620 mFoldersXEnd = mCoordinates.dateXEnd; 621 // In wide mode, the end of the senders should align with 622 // the start of the subject and is based on a max width. 623 mSendersWidth = mCoordinates.sendersWidth; 624 } else { 625 // In normal mode, the width is based on where the folders or date 626 // (or attachment icon) start. 627 if (mCoordinates.showFolders) { 628 if (mHeader.paperclip != null) { 629 mFoldersXEnd = mPaperclipX; 630 } else { 631 mFoldersXEnd = mDateX - cellWidth / 2; 632 } 633 mSendersWidth = mFoldersXEnd - mCoordinates.sendersX - 2 * cellWidth; 634 if (mHeader.folderDisplayer.hasVisibleFolders()) { 635 mSendersWidth -= ConversationItemViewCoordinates.getFoldersWidth(mContext, 636 mMode); 637 } 638 } else { 639 int dateAttachmentStart = 0; 640 // Have this end near the paperclip or date, not the folders. 641 if (mHeader.paperclip != null) { 642 dateAttachmentStart = mPaperclipX; 643 } else { 644 dateAttachmentStart = mDateX; 645 } 646 mSendersWidth = dateAttachmentStart - mCoordinates.sendersX - cellWidth; 647 } 648 } 649 650 if (mHeader.isLayoutValid(mContext)) { 651 pauseTimer(PERF_TAG_CALCULATE_COORDINATES); 652 return; 653 } 654 655 // Layout subject. 656 layoutSubject(); 657 658 // First pass to calculate width of each fragment. 659 int totalWidth = 0; 660 int fixedWidth = 0; 661 sPaint.setTextSize(mCoordinates.sendersFontSize); 662 sPaint.setTypeface(Typeface.DEFAULT); 663 for (SenderFragment senderFragment : mHeader.senderFragments) { 664 CharacterStyle style = senderFragment.style; 665 int start = senderFragment.start; 666 int end = senderFragment.end; 667 style.updateDrawState(sPaint); 668 senderFragment.width = (int) sPaint.measureText(mHeader.sendersText, start, end); 669 boolean isFixed = senderFragment.isFixed; 670 if (isFixed) { 671 fixedWidth += senderFragment.width; 672 } 673 totalWidth += senderFragment.width; 674 } 675 676 // Second pass to layout each fragment. 677 int sendersY = mCoordinates.sendersY - mCoordinates.sendersAscent; 678 if (!ConversationItemViewCoordinates.displaySendersInline(mMode)) { 679 sendersY += totalWidth <= mSendersWidth ? mCoordinates.sendersLineHeight / 2 : 0; 680 } 681 totalWidth = 0; 682 int currentLine = 1; 683 boolean ellipsize = false; 684 for (SenderFragment senderFragment : mHeader.senderFragments) { 685 CharacterStyle style = senderFragment.style; 686 int start = senderFragment.start; 687 int end = senderFragment.end; 688 int width = senderFragment.width; 689 boolean isFixed = senderFragment.isFixed; 690 style.updateDrawState(sPaint); 691 692 // No more width available, we'll only show fixed fragments. 693 if (ellipsize && !isFixed) { 694 senderFragment.shouldDisplay = false; 695 continue; 696 } 697 698 // New line and ellipsize text if needed. 699 senderFragment.ellipsizedText = null; 700 if (isFixed) { 701 fixedWidth -= width; 702 } 703 if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) { 704 // The text is too long, new line won't help. We have to 705 // ellipsize text. 706 if (totalWidth == 0) { 707 ellipsize = true; 708 } else { 709 // New line. 710 if (currentLine < mCoordinates.sendersLineCount) { 711 currentLine++; 712 sendersY += mCoordinates.sendersLineHeight; 713 totalWidth = 0; 714 // The text is still too long, we have to ellipsize 715 // text. 716 if (totalWidth + width > mSendersWidth) { 717 ellipsize = true; 718 } 719 } else { 720 ellipsize = true; 721 } 722 } 723 724 if (ellipsize) { 725 width = mSendersWidth - totalWidth; 726 // No more new line, we have to reserve width for fixed 727 // fragments. 728 if (currentLine == mCoordinates.sendersLineCount) { 729 width -= fixedWidth; 730 } 731 senderFragment.ellipsizedText = TextUtils.ellipsize( 732 mHeader.sendersText.substring(start, end), sPaint, width, 733 TruncateAt.END).toString(); 734 width = (int) sPaint.measureText(senderFragment.ellipsizedText); 735 } 736 } 737 senderFragment.x = mCoordinates.sendersX + totalWidth; 738 senderFragment.y = sendersY; 739 senderFragment.shouldDisplay = true; 740 totalWidth += width; 741 } 742 743 pauseTimer(PERF_TAG_CALCULATE_COORDINATES); 744 } 745 746 /** 747 * If the subject contains the tag of a mailing-list (text surrounded with 748 * []), return the subject with that tag ellipsized, e.g. 749 * "[android-gmail-team] Hello" -> "[andr...] Hello" 750 */ 751 private String filterTag(String subject) { 752 String result = subject; 753 String formatString = getContext().getResources().getString(R.string.filtered_tag); 754 if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') { 755 int end = subject.indexOf(']'); 756 if (end > 0) { 757 String tag = subject.substring(1, end); 758 result = String.format(formatString, Utils.ellipsize(tag, 7), 759 subject.substring(end + 1)); 760 } 761 } 762 return result; 763 } 764 765 @Override 766 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 767 if (mAnimatedHeight == -1) { 768 int width = measureWidth(widthMeasureSpec); 769 int height = measureHeight(heightMeasureSpec, 770 ConversationItemViewCoordinates.getMode(mContext, mViewMode)); 771 setMeasuredDimension(width, height); 772 } else { 773 setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mAnimatedHeight); 774 } 775 } 776 777 /** 778 * Determine the width of this view. 779 * 780 * @param measureSpec A measureSpec packed into an int 781 * @return The width of the view, honoring constraints from measureSpec 782 */ 783 private int measureWidth(int measureSpec) { 784 int result = 0; 785 int specMode = MeasureSpec.getMode(measureSpec); 786 int specSize = MeasureSpec.getSize(measureSpec); 787 788 if (specMode == MeasureSpec.EXACTLY) { 789 // We were told how big to be 790 result = specSize; 791 } else { 792 // Measure the text 793 result = mViewWidth; 794 if (specMode == MeasureSpec.AT_MOST) { 795 // Respect AT_MOST value if that was what is called for by 796 // measureSpec 797 result = Math.min(result, specSize); 798 } 799 } 800 return result; 801 } 802 803 /** 804 * Determine the height of this view. 805 * 806 * @param measureSpec A measureSpec packed into an int 807 * @param mode The current mode of this view 808 * @return The height of the view, honoring constraints from measureSpec 809 */ 810 private int measureHeight(int measureSpec, int mode) { 811 int result = 0; 812 int specMode = MeasureSpec.getMode(measureSpec); 813 int specSize = MeasureSpec.getSize(measureSpec); 814 815 if (specMode == MeasureSpec.EXACTLY) { 816 // We were told how big to be 817 result = specSize; 818 } else { 819 // Measure the text 820 result = ConversationItemViewCoordinates.getHeight(mContext, mode); 821 if (specMode == MeasureSpec.AT_MOST) { 822 // Respect AT_MOST value if that was what is called for by 823 // measureSpec 824 result = Math.min(result, specSize); 825 } 826 } 827 return result; 828 } 829 830 @Override 831 protected void onDraw(Canvas canvas) { 832 // Check mark. 833 if (mHeader.checkboxVisible) { 834 Bitmap checkmark = mChecked ? CHECKMARK_ON : CHECKMARK_OFF; 835 canvas.drawBitmap(checkmark, mCoordinates.checkmarkX, mCoordinates.checkmarkY, sPaint); 836 } 837 838 // Personal Level. 839 if (mCoordinates.showPersonalLevel && mHeader.personalLevelBitmap != null) { 840 canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalLevelX, 841 mCoordinates.personalLevelY, sPaint); 842 } 843 844 // Senders. 845 boolean isUnread = mHeader.unread; 846 sPaint.setTextSize(mCoordinates.sendersFontSize); 847 sPaint.setTypeface(isUnread ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT); 848 int sendersColor = getFontColor(isUnread ? SENDERS_TEXT_COLOR_UNREAD 849 : SENDERS_TEXT_COLOR_READ); 850 sPaint.setColor(sendersColor); 851 for (SenderFragment fragment : mHeader.senderFragments) { 852 if (fragment.shouldDisplay) { 853 fragment.style.updateDrawState(sPaint); 854 if (fragment.ellipsizedText != null) { 855 canvas.drawText(fragment.ellipsizedText, fragment.x, fragment.y, sPaint); 856 } else { 857 canvas.drawText(mHeader.sendersText, fragment.start, fragment.end, fragment.x, 858 fragment.y, sPaint); 859 } 860 } 861 } 862 863 // Subject. 864 sPaint.setTextSize(mCoordinates.subjectFontSize); 865 sPaint.setTypeface(Typeface.DEFAULT); 866 sPaint.setColor(mHeader.fontColor); 867 canvas.save(); 868 canvas.translate(mCoordinates.subjectX, 869 mCoordinates.subjectY + mHeader.subjectLayout.getTopPadding()); 870 mHeader.subjectLayout.draw(canvas); 871 canvas.restore(); 872 873 // Folders. 874 if (mCoordinates.showFolders) { 875 mHeader.folderDisplayer.drawFolders(canvas, mCoordinates, mFoldersXEnd, mMode); 876 } 877 878 // Date background: shown when there is an attachment or a visible 879 // folder. 880 if (!isActivated() 881 && mHeader.conversation.hasAttachments 882 && ConversationItemViewCoordinates.showAttachmentBackground(mMode)) { 883 mHeader.dateBackground = DATE_BACKGROUND; 884 int leftOffset = (mHeader.conversation.hasAttachments ? mPaperclipX : mDateX) 885 - DATE_BACKGROUND_PADDING_LEFT; 886 int top = mCoordinates.showFolders ? mCoordinates.foldersY : mCoordinates.dateY; 887 Rect src = new Rect(0, 0, mHeader.dateBackground.getWidth(), mHeader.dateBackground 888 .getHeight()); 889 Rect dst = new Rect(leftOffset, top, mViewWidth, top + sDateBackgroundHeight); 890 canvas.drawBitmap(mHeader.dateBackground, src, dst, sPaint); 891 } else { 892 mHeader.dateBackground = null; 893 } 894 895 // Draw the reply state. Draw nothing if neither replied nor forwarded. 896 if (mCoordinates.showReplyState) { 897 if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) { 898 canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX, 899 mCoordinates.replyStateY, null); 900 } else if (mHeader.hasBeenRepliedTo) { 901 canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX, 902 mCoordinates.replyStateY, null); 903 } else if (mHeader.hasBeenForwarded) { 904 canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX, 905 mCoordinates.replyStateY, null); 906 } else if (mHeader.isInvite) { 907 canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX, 908 mCoordinates.replyStateY, null); 909 } 910 } 911 912 // Date. 913 sPaint.setTextSize(mCoordinates.dateFontSize); 914 sPaint.setTypeface(Typeface.DEFAULT); 915 sPaint.setColor(isUnread ? DATE_TEXT_COLOR_UNREAD : DATE_TEXT_COLOR_READ); 916 drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateY - mCoordinates.dateAscent, 917 sPaint); 918 919 // Paper clip icon. 920 if (mHeader.paperclip != null) { 921 canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint); 922 } 923 924 if (mHeader.faded) { 925 int fadedColor = -1; 926 if (sFadedActivatedColor == -1) { 927 sFadedActivatedColor = mContext.getResources().getColor( 928 R.color.faded_activated_conversation_header); 929 } 930 fadedColor = sFadedActivatedColor; 931 int restoreState = canvas.save(); 932 Rect bounds = canvas.getClipBounds(); 933 canvas.clipRect(bounds.left, bounds.top, bounds.right 934 - mContext.getResources().getDimensionPixelSize(R.dimen.triangle_width), 935 bounds.bottom); 936 canvas.drawARGB(Color.alpha(fadedColor), Color.red(fadedColor), 937 Color.green(fadedColor), Color.blue(fadedColor)); 938 canvas.restoreToCount(restoreState); 939 } 940 941 // Star. 942 canvas.drawBitmap(mHeader.starBitmap, mCoordinates.starX, mCoordinates.starY, sPaint); 943 } 944 945 private void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) { 946 canvas.drawText(s, 0, s.length(), x, y, paint); 947 } 948 949 private void updateBackground(boolean isUnread) { 950 if (isUnread) { 951 if (mTabletDevice && mViewMode.getMode() == ViewMode.CONVERSATION_LIST) { 952 if (mChecked) { 953 setBackgroundResource(R.drawable.list_conversation_wide_unread_selected_holo); 954 } else { 955 setBackgroundResource(R.drawable.conversation_wide_unread_selector); 956 } 957 } else { 958 if (mChecked) { 959 setCheckedActivatedBackground(); 960 } else { 961 setBackgroundResource(R.drawable.conversation_unread_selector); 962 } 963 } 964 } else { 965 if (mTabletDevice && mViewMode.getMode() == ViewMode.CONVERSATION_LIST) { 966 if (mChecked) { 967 setBackgroundResource(R.drawable.list_conversation_wide_read_selected_holo); 968 } else { 969 setBackgroundResource(R.drawable.conversation_wide_read_selector); 970 } 971 } else { 972 if (mChecked) { 973 setCheckedActivatedBackground(); 974 } else { 975 setBackgroundResource(R.drawable.conversation_read_selector); 976 } 977 } 978 } 979 } 980 981 private void setCheckedActivatedBackground() { 982 if (isActivated() && mTabletDevice) { 983 setBackgroundResource(R.drawable.list_arrow_selected_holo); 984 } else { 985 setBackgroundResource(R.drawable.list_selected_holo); 986 } 987 } 988 989 /** 990 * Toggle the check mark on this view and update the conversation 991 */ 992 public void toggleCheckMark() { 993 mChecked = !mChecked; 994 Conversation conv = mHeader.conversation; 995 // Set the list position of this item in the conversation 996 conv.position = mChecked ? ((ListView)getParent()).getPositionForView(this) 997 : Conversation.NO_POSITION; 998 if (mSelectedConversationSet != null) { 999 mSelectedConversationSet.toggle(this, conv); 1000 } 1001 // We update the background after the checked state has changed now that 1002 // we have a selected background asset. Setting the background usually 1003 // waits for a layout pass, but we don't need a full layout, just an 1004 // update to the background. 1005 requestLayout(); 1006 } 1007 1008 /** 1009 * Return if the checkbox for this item is checked. 1010 */ 1011 public boolean isChecked() { 1012 return mChecked; 1013 } 1014 1015 /** 1016 * Toggle the star on this view and update the conversation. 1017 */ 1018 public void toggleStar() { 1019 mHeader.starred = !mHeader.starred; 1020 mHeader.starBitmap = mHeader.starred ? STAR_ON : STAR_OFF; 1021 postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX 1022 + mHeader.starBitmap.getWidth(), 1023 mCoordinates.starY + mHeader.starBitmap.getHeight()); 1024 // Generalize this... 1025 mHeader.conversation.updateBoolean(mContext, ConversationColumns.STARRED, mHeader.starred); 1026 } 1027 1028 private boolean isTouchInCheckmark(float x, float y) { 1029 // Everything before senders and include a touch slop. 1030 return mHeader.checkboxVisible && x < mCoordinates.sendersX + TOUCH_SLOP; 1031 } 1032 1033 private boolean isTouchInStar(float x, float y) { 1034 // Everything after the star and include a touch slop. 1035 return x > mCoordinates.starX - TOUCH_SLOP; 1036 } 1037 1038 /** 1039 * ConversationItemView is given the first chance to handle touch events. 1040 */ 1041 @Override 1042 public boolean onTouchEvent(MotionEvent event) { 1043 boolean handled = true; 1044 1045 int x = (int) event.getX(); 1046 int y = (int) event.getY(); 1047 switch (event.getAction()) { 1048 case MotionEvent.ACTION_DOWN: 1049 mDownEvent = true; 1050 // In order to allow the down event and subsequent move events 1051 // to bubble to the swipe handler, we need to return that all 1052 // down events are handled. 1053 handled = true; 1054 break; 1055 case MotionEvent.ACTION_CANCEL: 1056 mDownEvent = false; 1057 break; 1058 case MotionEvent.ACTION_UP: 1059 if (mDownEvent) { 1060 // ConversationItemView gets the first chance to handle up 1061 // events if there was a down event and there was no move 1062 // event in between. In this case, ConversationItemView 1063 // received the down event, and then an up event in the 1064 // same location (+/- slop). Treat this as a click on the 1065 // view or on a specific part of the view. 1066 if (isTouchInCheckmark(x, y)) { 1067 // Touch on the check mark 1068 toggleCheckMark(); 1069 } else if (isTouchInStar(x, y)) { 1070 // Touch on the star 1071 toggleStar(); 1072 } else { 1073 ListView list = (ListView)getParent(); 1074 int pos = list.getPositionForView(this); 1075 list.performItemClick(this, pos, mHeader.conversation.id); 1076 } 1077 handled = true; 1078 } else { 1079 // There was no down event that this view was made aware of, 1080 // therefore it cannot handle it. 1081 handled = false; 1082 } 1083 break; 1084 } 1085 1086 if (!handled) { 1087 // Let View try to handle it as well. 1088 handled = super.onTouchEvent(event); 1089 } 1090 1091 return handled; 1092 } 1093 1094 /** 1095 * Grow the height of the item and fade it in when bringing a conversation 1096 * back from a destructive action. 1097 * 1098 * @param listener 1099 */ 1100 public void startUndoAnimation(final AnimatorListener listener) { 1101 setMinimumHeight(140); 1102 final int start = 0 ; 1103 final int end = 140; 1104 ObjectAnimator undoAnimator = ObjectAnimator.ofInt(this, "animatedHeight", start, end); 1105 Animator fadeAnimator = ObjectAnimator.ofFloat(this, "itemAlpha", 0, 1.0f); 1106 mAnimatedHeight = start; 1107 undoAnimator.setInterpolator(new DecelerateInterpolator(2.0f)); 1108 undoAnimator.setDuration(sUndoAnimationDuration); 1109 AnimatorSet transitionSet = new AnimatorSet(); 1110 transitionSet.playTogether(undoAnimator, fadeAnimator); 1111 transitionSet.addListener(listener); 1112 transitionSet.start(); 1113 } 1114 1115 // Used by animator 1116 @SuppressWarnings("unused") 1117 public void setItemAlpha(float alpha) { 1118 setAlpha(alpha); 1119 invalidate(); 1120 } 1121 1122 // Used by animator 1123 @SuppressWarnings("unused") 1124 public void setAnimatedHeight(int height) { 1125 mAnimatedHeight = height; 1126 requestLayout(); 1127 } 1128 1129 /** 1130 * Get the current position of this conversation item in the list. 1131 */ 1132 public int getPosition() { 1133 return mHeader != null && mHeader.conversation != null ? 1134 mHeader.conversation.position : -1; 1135 } 1136} 1137