ConversationItemView.java revision 1152c02a415f5b93bdecc5733a7372dca7d9ecd3
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 android.animation.Animator; 21import android.animation.Animator.AnimatorListener; 22import android.animation.AnimatorSet; 23import android.animation.ObjectAnimator; 24import android.content.ClipData; 25import android.content.ClipData.Item; 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.Point; 36import android.graphics.Rect; 37import android.graphics.Shader; 38import android.graphics.Typeface; 39import android.graphics.drawable.Drawable; 40import android.text.Layout.Alignment; 41import android.text.Spannable; 42import android.text.SpannableString; 43import android.text.SpannableStringBuilder; 44import android.text.StaticLayout; 45import android.text.TextPaint; 46import android.text.TextUtils; 47import android.text.TextUtils.TruncateAt; 48import android.text.format.DateUtils; 49import android.text.style.CharacterStyle; 50import android.text.style.ForegroundColorSpan; 51import android.text.style.StyleSpan; 52import android.util.SparseArray; 53import android.view.HapticFeedbackConstants; 54import android.view.MotionEvent; 55import android.view.View; 56import android.view.ViewConfiguration; 57import android.view.animation.DecelerateInterpolator; 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.Conversation; 64import com.android.mail.providers.Folder; 65import com.android.mail.providers.UIProvider; 66import com.android.mail.providers.UIProvider.ConversationColumns; 67import com.android.mail.ui.AnimatedAdapter; 68import com.android.mail.ui.ConversationSelectionSet; 69import com.android.mail.ui.FolderDisplayer; 70import com.android.mail.ui.SwipeableItemView; 71import com.android.mail.ui.ViewMode; 72import com.android.mail.utils.LogTag; 73import com.android.mail.utils.LogUtils; 74import com.android.mail.utils.Utils; 75import com.google.common.annotations.VisibleForTesting; 76 77public class ConversationItemView extends View implements SwipeableItemView { 78 // Timer. 79 private static int sLayoutCount = 0; 80 private static Timer sTimer; // Create the sTimer here if you need to do 81 // perf analysis. 82 private static final int PERF_LAYOUT_ITERATIONS = 50; 83 private static final String PERF_TAG_LAYOUT = "CCHV.layout"; 84 private static final String PERF_TAG_CALCULATE_TEXTS_BITMAPS = "CCHV.txtsbmps"; 85 private static final String PERF_TAG_CALCULATE_SENDER_SUBJECT = "CCHV.sendersubj"; 86 private static final String PERF_TAG_CALCULATE_FOLDERS = "CCHV.folders"; 87 private static final String PERF_TAG_CALCULATE_COORDINATES = "CCHV.coordinates"; 88 private static final String LOG_TAG = LogTag.getLogTag(); 89 90 // Static bitmaps. 91 private static Bitmap CHECKMARK_OFF; 92 private static Bitmap CHECKMARK_ON; 93 private static Bitmap STAR_OFF; 94 private static Bitmap STAR_ON; 95 private static Bitmap ATTACHMENT; 96 private static Bitmap ONLY_TO_ME; 97 private static Bitmap TO_ME_AND_OTHERS; 98 private static Bitmap IMPORTANT_ONLY_TO_ME; 99 private static Bitmap IMPORTANT_TO_ME_AND_OTHERS; 100 private static Bitmap IMPORTANT_TO_OTHERS; 101 private static Bitmap DATE_BACKGROUND; 102 private static Bitmap STATE_REPLIED; 103 private static Bitmap STATE_FORWARDED; 104 private static Bitmap STATE_REPLIED_AND_FORWARDED; 105 private static Bitmap STATE_CALENDAR_INVITE; 106 107 private static String SENDERS_SPLIT_TOKEN; 108 109 // Static colors. 110 private static int DEFAULT_TEXT_COLOR; 111 private static int ACTIVATED_TEXT_COLOR; 112 private static int SUBJECT_TEXT_COLOR_READ; 113 private static int SUBJECT_TEXT_COLOR_UNREAD; 114 private static int SNIPPET_TEXT_COLOR_READ; 115 private static int SNIPPET_TEXT_COLOR_UNREAD; 116 private static int SENDERS_TEXT_COLOR_READ; 117 private static int SENDERS_TEXT_COLOR_UNREAD; 118 private static int DATE_TEXT_COLOR_READ; 119 private static int DATE_TEXT_COLOR_UNREAD; 120 private static int DATE_BACKGROUND_PADDING_LEFT; 121 private static int TOUCH_SLOP; 122 private static int sDateBackgroundHeight; 123 private static int sStandardScaledDimen; 124 private static int sUndoAnimationDuration; 125 protected static CharacterStyle sNormalTextStyle; 126 127 // Static paints. 128 private static TextPaint sPaint = new TextPaint(); 129 private static TextPaint sFoldersPaint = new TextPaint(); 130 131 // Backgrounds for different states. 132 private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>(); 133 134 // Dimensions and coordinates. 135 private int mViewWidth = -1; 136 private int mMode = -1; 137 private int mDateX; 138 private int mPaperclipX; 139 private int mFoldersXEnd; 140 private int mSendersWidth; 141 142 /** Whether we're running under test mode. */ 143 private boolean mTesting = false; 144 /** Whether we are on a tablet device or not */ 145 private final boolean mTabletDevice; 146 147 @VisibleForTesting 148 ConversationItemViewCoordinates mCoordinates; 149 150 private final Context mContext; 151 152 public ConversationItemViewModel mHeader; 153 private ViewMode mViewMode; 154 private boolean mDownEvent; 155 private boolean mChecked = false; 156 private static int sFadedActivatedColor = -1; 157 private ConversationSelectionSet mSelectedConversationSet; 158 private Folder mDisplayedFolder; 159 private boolean mPriorityMarkersEnabled; 160 private boolean mCheckboxesEnabled; 161 private CheckForTap mPendingCheckForTap; 162 private CheckForLongPress mPendingCheckForLongPress; 163 private boolean mSwipeEnabled; 164 private int mLastTouchX; 165 private int mLastTouchY; 166 private AnimatedAdapter mAdapter; 167 private int mAnimatedHeight = -1; 168 private String mAccount; 169 private Bitmap sDateBackgroundAttachment; 170 private Bitmap sDateBackgroundNoAttachment; 171 private static int sUndoAnimationOffset; 172 private static CharSequence sDraftSingularString; 173 private static CharSequence sDraftPluralString; 174 private static String sDraftCountFormatString; 175 private static ForegroundColorSpan sDraftsStyleSpan; 176 private static Bitmap MORE_FOLDERS; 177 178 static { 179 sPaint.setAntiAlias(true); 180 sFoldersPaint.setAntiAlias(true); 181 } 182 183 /** 184 * Handles displaying folders in a conversation header view. 185 */ 186 static class ConversationItemFolderDisplayer extends FolderDisplayer { 187 // Maximum number of folders to be displayed. 188 private static final int MAX_DISPLAYED_FOLDERS_COUNT = 4; 189 190 private int mFoldersCount; 191 private boolean mHasMoreFolders; 192 193 public ConversationItemFolderDisplayer(Context context) { 194 super(context); 195 } 196 197 @Override 198 public void loadConversationFolders(Conversation conv, Folder ignoreFolder) { 199 super.loadConversationFolders(conv, ignoreFolder); 200 201 mFoldersCount = mFoldersSortedSet.size(); 202 mHasMoreFolders = mFoldersCount > MAX_DISPLAYED_FOLDERS_COUNT; 203 mFoldersCount = Math.min(mFoldersCount, MAX_DISPLAYED_FOLDERS_COUNT); 204 } 205 206 public boolean hasVisibleFolders() { 207 return mFoldersCount > 0; 208 } 209 210 private int measureFolders(int mode) { 211 int availableSpace = ConversationItemViewCoordinates.getFoldersWidth(mContext, mode); 212 int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext, mode, 213 mFoldersCount); 214 215 int totalWidth = 0; 216 for (Folder f : mFoldersSortedSet) { 217 final String folderString = f.name; 218 int width = (int) sFoldersPaint.measureText(folderString) + cellSize; 219 if (width % cellSize != 0) { 220 width += cellSize - (width % cellSize); 221 } 222 totalWidth += width; 223 if (totalWidth > availableSpace) { 224 break; 225 } 226 } 227 228 return totalWidth; 229 } 230 231 public void drawFolders(Canvas canvas, ConversationItemViewCoordinates coordinates, 232 int foldersXEnd, int mode) { 233 if (mFoldersCount == 0) { 234 return; 235 } 236 237 int xEnd = foldersXEnd; 238 int y = coordinates.foldersY - coordinates.foldersAscent; 239 int height = coordinates.foldersHeight; 240 int topPadding = coordinates.foldersTopPadding; 241 int ascent = coordinates.foldersAscent; 242 sFoldersPaint.setTextSize(coordinates.foldersFontSize); 243 244 // Initialize space and cell size based on the current mode. 245 int availableSpace = ConversationItemViewCoordinates.getFoldersWidth(mContext, mode); 246 int averageWidth = availableSpace / mFoldersCount; 247 int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext, mode, 248 mFoldersCount); 249 250 // First pass to calculate the starting point. 251 int totalWidth = measureFolders(mode); 252 int xStart = xEnd - Math.min(availableSpace, totalWidth); 253 254 // Second pass to draw folders. 255 for (Folder f : mFoldersSortedSet) { 256 final String folderString = f.name; 257 final int fgColor = f.getForegroundColor(mDefaultFgColor); 258 final int bgColor = f.getBackgroundColor(mDefaultBgColor); 259 int width = cellSize; 260 boolean labelTooLong = false; 261 width = (int) sFoldersPaint.measureText(folderString) + cellSize; 262 if (width % cellSize != 0) { 263 width += cellSize - (width % cellSize); 264 } 265 if (totalWidth > availableSpace && width > averageWidth) { 266 width = averageWidth; 267 labelTooLong = true; 268 } 269 270 // TODO (mindyp): how to we get this? 271 final boolean isMuted = false; 272 // labelValues.folderId == 273 // sGmail.getFolderMap(mAccount).getFolderIdIgnored(); 274 275 // Draw the box. 276 sFoldersPaint.setColor(bgColor); 277 sFoldersPaint.setStyle(isMuted ? Paint.Style.STROKE : Paint.Style.FILL_AND_STROKE); 278 canvas.drawRect(xStart, y + ascent, xStart + width, y + ascent + height, 279 sFoldersPaint); 280 281 // Draw the text. 282 int padding = getPadding(width, (int) sFoldersPaint.measureText(folderString)); 283 if (labelTooLong) { 284 TextPaint shortPaint = new TextPaint(); 285 shortPaint.setColor(fgColor); 286 shortPaint.setTextSize(coordinates.foldersFontSize); 287 padding = cellSize / 2; 288 int rightBorder = xStart + width - padding; 289 Shader shader = new LinearGradient(rightBorder - padding, y, rightBorder, y, 290 fgColor, Utils.getTransparentColor(fgColor), Shader.TileMode.CLAMP); 291 shortPaint.setShader(shader); 292 canvas.drawText(folderString, xStart + padding, y + topPadding, shortPaint); 293 } else { 294 sFoldersPaint.setColor(fgColor); 295 canvas.drawText(folderString, xStart + padding, y + topPadding, sFoldersPaint); 296 } 297 298 availableSpace -= width; 299 xStart += width; 300 if (availableSpace <= 0 && mHasMoreFolders) { 301 canvas.drawBitmap(MORE_FOLDERS, xEnd, y + ascent, sFoldersPaint); 302 return; 303 } 304 } 305 } 306 } 307 308 /** 309 * Helpers function to align an element in the center of a space. 310 */ 311 private static int getPadding(int space, int length) { 312 return (space - length) / 2; 313 } 314 315 public ConversationItemView(Context context, String account) { 316 super(context); 317 mContext = context.getApplicationContext(); 318 mTabletDevice = Utils.useTabletUI(mContext); 319 mAccount = account; 320 Resources res = mContext.getResources(); 321 322 if (CHECKMARK_OFF == null) { 323 // Initialize static bitmaps. 324 CHECKMARK_OFF = BitmapFactory.decodeResource(res, 325 R.drawable.btn_check_off_normal_holo_light); 326 CHECKMARK_ON = BitmapFactory.decodeResource(res, 327 R.drawable.btn_check_on_normal_holo_light); 328 STAR_OFF = BitmapFactory.decodeResource(res, 329 R.drawable.btn_star_off_normal_email_holo_light); 330 STAR_ON = BitmapFactory.decodeResource(res, 331 R.drawable.btn_star_on_normal_email_holo_light); 332 ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double); 333 TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single); 334 IMPORTANT_ONLY_TO_ME = BitmapFactory.decodeResource(res, 335 R.drawable.ic_email_caret_double_important_unread); 336 IMPORTANT_TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, 337 R.drawable.ic_email_caret_single_important_unread); 338 IMPORTANT_TO_OTHERS = BitmapFactory.decodeResource(res, 339 R.drawable.ic_email_caret_none_important_unread); 340 ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attachment_holo_light); 341 MORE_FOLDERS = BitmapFactory.decodeResource(res, R.drawable.ic_folders_more); 342 DATE_BACKGROUND = BitmapFactory.decodeResource(res, R.drawable.folder_bg_holo_light); 343 STATE_REPLIED = 344 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_holo_light); 345 STATE_FORWARDED = 346 BitmapFactory.decodeResource(res, R.drawable.ic_badge_forward_holo_light); 347 STATE_REPLIED_AND_FORWARDED = 348 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_forward_holo_light); 349 STATE_CALENDAR_INVITE = 350 BitmapFactory.decodeResource(res, R.drawable.ic_badge_invite_holo_light); 351 352 // Initialize colors. 353 DEFAULT_TEXT_COLOR = res.getColor(R.color.default_text_color); 354 ACTIVATED_TEXT_COLOR = res.getColor(android.R.color.white); 355 SUBJECT_TEXT_COLOR_READ = res.getColor(R.color.subject_text_color_read); 356 SUBJECT_TEXT_COLOR_UNREAD = res.getColor(R.color.subject_text_color_unread); 357 SNIPPET_TEXT_COLOR_READ = res.getColor(R.color.snippet_text_color_read); 358 SNIPPET_TEXT_COLOR_UNREAD = res.getColor(R.color.snippet_text_color_unread); 359 SENDERS_TEXT_COLOR_READ = res.getColor(R.color.senders_text_color_read); 360 SENDERS_TEXT_COLOR_UNREAD = res.getColor(R.color.senders_text_color_unread); 361 DATE_TEXT_COLOR_READ = res.getColor(R.color.date_text_color_read); 362 DATE_TEXT_COLOR_UNREAD = res.getColor(R.color.date_text_color_unread); 363 DATE_BACKGROUND_PADDING_LEFT = res 364 .getDimensionPixelSize(R.dimen.date_background_padding_left); 365 TOUCH_SLOP = res.getDimensionPixelSize(R.dimen.touch_slop); 366 sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height); 367 sStandardScaledDimen = res.getDimensionPixelSize(R.dimen.standard_scaled_dimen); 368 sUndoAnimationDuration = res.getInteger(R.integer.undo_animation_duration); 369 sUndoAnimationOffset = res.getDimensionPixelOffset(R.dimen.undo_animation_offset); 370 // Initialize static color. 371 sNormalTextStyle = new StyleSpan(Typeface.NORMAL); 372 SENDERS_SPLIT_TOKEN = res.getString(R.string.senders_split_token); 373 } 374 } 375 376 public void bind(Cursor cursor, ViewMode viewMode, ConversationSelectionSet set, Folder folder, 377 boolean checkboxesDisabled, boolean swipeEnabled, AnimatedAdapter adapter) { 378 bind(ConversationItemViewModel.forCursor(mAccount, cursor), viewMode, set, folder, 379 checkboxesDisabled, swipeEnabled, adapter); 380 } 381 382 public void bind(Conversation conversation, ViewMode viewMode, ConversationSelectionSet set, 383 Folder folder, boolean checkboxesDisabled, boolean swipeEnabled, 384 AnimatedAdapter adapter) { 385 bind(ConversationItemViewModel.forConversation(mAccount, conversation), viewMode, set, 386 folder, checkboxesDisabled, swipeEnabled, adapter); 387 } 388 389 private void bind(ConversationItemViewModel header, ViewMode viewMode, 390 ConversationSelectionSet set, Folder folder, boolean checkboxesDisabled, 391 boolean swipeEnabled, AnimatedAdapter adapter) { 392 mViewMode = viewMode; 393 mHeader = header; 394 mSelectedConversationSet = set; 395 mDisplayedFolder = folder; 396 mCheckboxesEnabled = !checkboxesDisabled; 397 mSwipeEnabled = swipeEnabled; 398 mAdapter = adapter; 399 setContentDescription(mHeader.getContentDescription(mContext)); 400 requestLayout(); 401 } 402 403 /** 404 * Get the Conversation object associated with this view. 405 */ 406 public Conversation getConversation() { 407 return mHeader.conversation; 408 } 409 410 /** 411 * Sets the mode. Only used for testing. 412 */ 413 @VisibleForTesting 414 void setMode(int mode) { 415 mMode = mode; 416 mTesting = true; 417 } 418 419 private static void startTimer(String tag) { 420 if (sTimer != null) { 421 sTimer.start(tag); 422 } 423 } 424 425 private static void pauseTimer(String tag) { 426 if (sTimer != null) { 427 sTimer.pause(tag); 428 } 429 } 430 431 @Override 432 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 433 startTimer(PERF_TAG_LAYOUT); 434 435 super.onLayout(changed, left, top, right, bottom); 436 437 int width = right - left; 438 if (width != mViewWidth) { 439 mViewWidth = width; 440 if (!mTesting) { 441 mMode = ConversationItemViewCoordinates.getMode(mContext, mViewMode); 442 } 443 } 444 mHeader.viewWidth = mViewWidth; 445 Resources res = getResources(); 446 mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen); 447 if (mHeader.standardScaledDimen != sStandardScaledDimen) { 448 // Large Text has been toggle on/off. Update the static dimens. 449 sStandardScaledDimen = mHeader.standardScaledDimen; 450 ConversationItemViewCoordinates.refreshConversationHeights(mContext); 451 sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height); 452 } 453 mCoordinates = ConversationItemViewCoordinates.forWidth(mContext, mViewWidth, mMode, 454 mHeader.standardScaledDimen); 455 calculateTextsAndBitmaps(); 456 calculateCoordinates(); 457 mHeader.validate(mContext); 458 459 pauseTimer(PERF_TAG_LAYOUT); 460 if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) { 461 sTimer.dumpResults(); 462 sTimer = new Timer(); 463 sLayoutCount = 0; 464 } 465 } 466 467 @Override 468 public void setBackgroundResource(int resourceId) { 469 Drawable drawable = mBackgrounds.get(resourceId); 470 if (drawable == null) { 471 drawable = getResources().getDrawable(resourceId); 472 mBackgrounds.put(resourceId, drawable); 473 } 474 if (getBackground() != drawable) { 475 super.setBackgroundDrawable(drawable); 476 } 477 } 478 479 private void calculateTextsAndBitmaps() { 480 startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 481 if (mSelectedConversationSet != null) { 482 mChecked = mSelectedConversationSet.contains(mHeader.conversation); 483 } 484 // Update font color. 485 int fontColor = getFontColor(DEFAULT_TEXT_COLOR); 486 boolean fontChanged = false; 487 if (mHeader.fontColor != fontColor) { 488 fontChanged = true; 489 mHeader.fontColor = fontColor; 490 } 491 492 boolean isUnread = mHeader.unread; 493 494 final boolean checkboxEnabled = mCheckboxesEnabled; 495 if (mHeader.checkboxVisible != checkboxEnabled) { 496 mHeader.checkboxVisible = checkboxEnabled; 497 } 498 499 // Update background. 500 updateBackground(isUnread); 501 502 if (mHeader.isLayoutValid(mContext)) { 503 // Relayout subject if font color has changed. 504 if (fontChanged) { 505 layoutSubjectSpans(isUnread); 506 layoutSubject(); 507 } 508 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 509 return; 510 } 511 512 startTimer(PERF_TAG_CALCULATE_FOLDERS); 513 514 // Initialize folder displayer. 515 if (mCoordinates.showFolders) { 516 mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext); 517 mHeader.folderDisplayer.loadConversationFolders(mHeader.conversation, mDisplayedFolder); 518 } 519 520 pauseTimer(PERF_TAG_CALCULATE_FOLDERS); 521 522 mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext, 523 mHeader.conversation.dateMs).toString(); 524 525 // Paper clip icon. 526 mHeader.paperclip = null; 527 if (mHeader.conversation.hasAttachments) { 528 mHeader.paperclip = ATTACHMENT; 529 } 530 // Personal level. 531 mHeader.personalLevelBitmap = null; 532 if (mCoordinates.showPersonalLevel) { 533 int personalLevel = mHeader.personalLevel; 534 final boolean isImportant = 535 mHeader.priority == UIProvider.ConversationPriority.IMPORTANT; 536 // TODO(mindyp): get whether importance indicators are enabled 537 // mPriorityMarkersEnabled = 538 // persistence.getPriorityInboxArrowsEnabled(mContext, mAccount); 539 mPriorityMarkersEnabled = true; 540 boolean useImportantMarkers = isImportant && mPriorityMarkersEnabled; 541 542 if (personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) { 543 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME 544 : ONLY_TO_ME; 545 } else if (personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) { 546 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS 547 : TO_ME_AND_OTHERS; 548 } else if (useImportantMarkers) { 549 mHeader.personalLevelBitmap = IMPORTANT_TO_OTHERS; 550 } 551 } 552 553 startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 554 555 // Subject. 556 layoutSubjectSpans(isUnread); 557 558 mHeader.sendersDisplayText = new SpannableStringBuilder(); 559 mHeader.styledSendersString = new SpannableStringBuilder(); 560 561 // Parse senders fragments. 562 if (mHeader.conversation.conversationInfo != null) { 563 mHeader.styledSenders = SendersView.format(getContext(), 564 mHeader.conversation.conversationInfo); 565 } else { 566 mCoordinates.sendersView.formatSenders(mHeader, isUnread, mMode); 567 } 568 569 pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 570 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 571 } 572 573 private void layoutSubjectSpans(boolean isUnread) { 574 if (showActivatedText()) { 575 mHeader.subjectTextActivated = createSubject(isUnread, true); 576 } 577 mHeader.subjectText = createSubject(isUnread, false); 578 } 579 580 private SpannableStringBuilder createSubject(boolean isUnread, boolean activated) { 581 final String subject = filterTag(mHeader.conversation.subject); 582 final String snippet = mHeader.conversation.getSnippet(); 583 int subjectColor = activated ? ACTIVATED_TEXT_COLOR : isUnread ? SUBJECT_TEXT_COLOR_UNREAD 584 : SUBJECT_TEXT_COLOR_READ; 585 int snippetColor = activated ? ACTIVATED_TEXT_COLOR : isUnread ? SNIPPET_TEXT_COLOR_UNREAD 586 : SNIPPET_TEXT_COLOR_READ; 587 SpannableStringBuilder subjectText = new SpannableStringBuilder( 588 (snippet != null) ? mContext.getString(R.string.subject_and_snippet, subject, 589 snippet) : subject); 590 if (isUnread) { 591 subjectText.setSpan(new StyleSpan(Typeface.BOLD), 0, subject.length(), 592 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 593 } 594 subjectText.setSpan(new ForegroundColorSpan(subjectColor), 0, subject.length(), 595 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 596 if (snippet != null) { 597 subjectText.setSpan(new ForegroundColorSpan(snippetColor), subject.length() + 1, 598 subjectText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 599 } 600 return subjectText; 601 } 602 603 private int getFontColor(int defaultColor) { 604 return isActivated() && mTabletDevice ? ACTIVATED_TEXT_COLOR 605 : defaultColor; 606 } 607 608 private boolean showActivatedText() { 609 return mTabletDevice; 610 } 611 612 private void layoutSubject() { 613 if (showActivatedText()) { 614 mHeader.subjectLayoutActivated = 615 createSubjectLayout(true, mHeader.subjectTextActivated); 616 } 617 mHeader.subjectLayout = createSubjectLayout(false, mHeader.subjectText); 618 } 619 620 private StaticLayout createSubjectLayout(boolean activated, 621 SpannableStringBuilder subjectText) { 622 sPaint.setTextSize(mCoordinates.subjectFontSize); 623 sPaint.setColor(activated ? ACTIVATED_TEXT_COLOR 624 : mHeader.unread ? SUBJECT_TEXT_COLOR_UNREAD : SUBJECT_TEXT_COLOR_READ); 625 StaticLayout subjectLayout = new StaticLayout(subjectText, sPaint, 626 mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 627 int lineCount = subjectLayout.getLineCount(); 628 if (mCoordinates.subjectLineCount < lineCount) { 629 int end = subjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1); 630 subjectLayout = new StaticLayout(subjectText.subSequence(0, end), sPaint, 631 mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 632 } 633 return subjectLayout; 634 } 635 636 private boolean canFitFragment(int width, int line, int fixedWidth) { 637 if (line == mCoordinates.sendersLineCount) { 638 return width + fixedWidth <= mSendersWidth; 639 } else { 640 return width <= mSendersWidth; 641 } 642 } 643 644 private void calculateCoordinates() { 645 startTimer(PERF_TAG_CALCULATE_COORDINATES); 646 647 sPaint.setTextSize(mCoordinates.dateFontSize); 648 sPaint.setTypeface(Typeface.DEFAULT); 649 mDateX = mCoordinates.dateXEnd - (int) sPaint.measureText(mHeader.dateText); 650 651 mPaperclipX = mDateX - ATTACHMENT.getWidth(); 652 653 int cellWidth = mContext.getResources().getDimensionPixelSize(R.dimen.folder_cell_width); 654 655 if (ConversationItemViewCoordinates.isWideMode(mMode)) { 656 // Folders are displayed above the date. 657 mFoldersXEnd = mCoordinates.dateXEnd; 658 // In wide mode, the end of the senders should align with 659 // the start of the subject and is based on a max width. 660 mSendersWidth = mCoordinates.sendersWidth; 661 } else { 662 // In normal mode, the width is based on where the folders or date 663 // (or attachment icon) start. 664 if (mCoordinates.showFolders) { 665 if (mHeader.paperclip != null) { 666 mFoldersXEnd = mPaperclipX; 667 } else { 668 mFoldersXEnd = mDateX - cellWidth / 2; 669 } 670 mSendersWidth = mFoldersXEnd - mCoordinates.sendersX - 2 * cellWidth; 671 if (mHeader.folderDisplayer.hasVisibleFolders()) { 672 mSendersWidth -= ConversationItemViewCoordinates.getFoldersWidth(mContext, 673 mMode); 674 } 675 } else { 676 int dateAttachmentStart = 0; 677 // Have this end near the paperclip or date, not the folders. 678 if (mHeader.paperclip != null) { 679 dateAttachmentStart = mPaperclipX; 680 } else { 681 dateAttachmentStart = mDateX; 682 } 683 mSendersWidth = dateAttachmentStart - mCoordinates.sendersX - cellWidth; 684 } 685 } 686 687 if (mHeader.isLayoutValid(mContext)) { 688 pauseTimer(PERF_TAG_CALCULATE_COORDINATES); 689 return; 690 } 691 692 // Layout subject. 693 layoutSubject(); 694 695 // First pass to calculate width of each fragment. 696 int totalWidth = 0; 697 int fixedWidth = 0; 698 sPaint.setTextSize(mCoordinates.sendersFontSize); 699 sPaint.setTypeface(Typeface.DEFAULT); 700 for (SenderFragment senderFragment : mHeader.senderFragments) { 701 CharacterStyle style = senderFragment.style; 702 int start = senderFragment.start; 703 int end = senderFragment.end; 704 style.updateDrawState(sPaint); 705 senderFragment.width = (int) sPaint.measureText(mHeader.sendersText, start, end); 706 boolean isFixed = senderFragment.isFixed; 707 if (isFixed) { 708 fixedWidth += senderFragment.width; 709 } 710 totalWidth += senderFragment.width; 711 } 712 713 // Second pass to layout each fragment. 714 int sendersY = mCoordinates.sendersY - mCoordinates.sendersAscent; 715 if (!ConversationItemViewCoordinates.displaySendersInline(mMode)) { 716 sendersY += totalWidth <= mSendersWidth ? mCoordinates.sendersLineHeight / 2 : 0; 717 } 718 if (mHeader.styledSenders != null) { 719 totalWidth = ellipsizeStyledSenders(fixedWidth, sendersY); 720 mHeader.sendersDisplayLayout = new StaticLayout(mHeader.styledSendersString, sPaint, 721 mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 722 } else { 723 totalWidth = ellipsize(fixedWidth, sendersY); 724 mHeader.sendersDisplayLayout = new StaticLayout(mHeader.sendersDisplayText, sPaint, 725 mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 726 } 727 728 sPaint.setTextSize(mCoordinates.sendersFontSize); 729 sPaint.setTypeface(Typeface.DEFAULT); 730 if (mSendersWidth < 0) { 731 mSendersWidth = 0; 732 } 733 734 pauseTimer(PERF_TAG_CALCULATE_COORDINATES); 735 } 736 737 // The rules for displaying message info are as follows: 738 // 1) Any DRAFT text is always preceded by ", " 739 // 2) Any COUNT of messages > 1 is always preceded by " " 740 // 3) If there is a COUNT > 1, it is always displayed before DRAFT text 741 private SpannableStringBuilder createMessageInfo() { 742 SpannableStringBuilder messageInfo = new SpannableStringBuilder(); 743 if (mHeader.conversation.conversationInfo != null) { 744 int count = mHeader.conversation.conversationInfo.messageCount; 745 int draftCount = mHeader.conversation.conversationInfo.draftCount; 746 if (count > 0 || draftCount <= 0) { 747 messageInfo.append(" "); 748 } 749 if (count > 1) { 750 messageInfo.append(count + ""); 751 } 752 if (draftCount > 0) { 753 getDraftResources(); 754 messageInfo.append(SENDERS_SPLIT_TOKEN); 755 SpannableStringBuilder draftString = new SpannableStringBuilder(); 756 if (draftCount == 1) { 757 draftString.append(sDraftSingularString); 758 } else { 759 draftString.append(sDraftPluralString 760 + String.format(sDraftCountFormatString, draftCount)); 761 } 762 draftString.setSpan(CharacterStyle.wrap(sDraftsStyleSpan), 0, draftString.length(), 763 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 764 messageInfo.append(draftString); 765 } 766 } 767 return messageInfo; 768 } 769 770 private void getDraftResources() { 771 Resources res = getContext().getResources(); 772 if (sDraftSingularString == null) { 773 sDraftSingularString = res.getQuantityText(R.plurals.draft, 1); 774 sDraftPluralString = res.getQuantityText(R.plurals.draft, 2); 775 sDraftCountFormatString = res.getString(R.string.draft_count_format); 776 sDraftsStyleSpan = new ForegroundColorSpan(res.getColor(R.color.drafts)); 777 } 778 } 779 780 // The rules for displaying ellipsized senders are as follows: 781 // 1) If there is message info (either a COUNT or DRAFT info to display), it MUST be shown 782 // 2) If senders do not fit, ellipsize the last one that does fit, and stop 783 // appending new senders 784 private int ellipsizeStyledSenders(int fixedWidth, int sendersY) { 785 SpannableStringBuilder builder = new SpannableStringBuilder(); 786 int totalWidth = 0; 787 int currentLine = 1; 788 boolean ellipsize = false; 789 SpannableString ellipsizedText; 790 int width; 791 SpannableStringBuilder messageInfoString = createMessageInfo(); 792 // Paint the message info string to see if we lose space. 793 float messageInfoWidth = sPaint.measureText(messageInfoString.toString()); 794 totalWidth += messageInfoWidth; 795 796 for (SpannableString sender : mHeader.styledSenders) { 797 // No more width available, we'll only show fixed fragments. 798 if (ellipsize) { 799 break; 800 } 801 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 802 // There is only 1 character style span. 803 if (spans.length > 0) { 804 spans[0].updateDrawState(sPaint); 805 } 806 // If there are already senders present in this string, we need to 807 // make sure we prepend the dividing token 808 if (builder.length() > 0) { 809 sender = copyStyles(spans, SENDERS_SPLIT_TOKEN + sender); 810 } 811 // Measure the width of the current sender and make sure we have space 812 width = (int) sPaint.measureText(sender.toString()); 813 if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) { 814 // The text is too long, new line won't help. We have to 815 // ellipsize text. 816 ellipsize = true; 817 width = mSendersWidth - totalWidth; 818 // No more new line, we have to reserve width for fixed 819 // fragments. 820 if (currentLine == mCoordinates.sendersLineCount) { 821 width -= fixedWidth; 822 } 823 ellipsizedText = copyStyles(spans, 824 TextUtils.ellipsize(sender, sPaint, width, TruncateAt.END)); 825 width = (int) sPaint.measureText(ellipsizedText.toString()); 826 } else { 827 ellipsizedText = null; 828 } 829 totalWidth += width; 830 831 final CharSequence fragmentDisplayText; 832 if (ellipsizedText != null) { 833 fragmentDisplayText = ellipsizedText; 834 } else { 835 fragmentDisplayText = sender; 836 } 837 builder.append(fragmentDisplayText); 838 } 839 if (messageInfoString.length() > 0) { 840 builder.append(messageInfoString); 841 } 842 mHeader.styledSendersString = builder; 843 return totalWidth; 844 } 845 846 private SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 847 SpannableString s = new SpannableString(newText); 848 if (spans != null && spans.length > 0) { 849 s.setSpan(spans[0], 0, s.length(), 0); 850 } 851 return s; 852 } 853 854 private int ellipsize(int fixedWidth, int sendersY) { 855 int totalWidth = 0; 856 int currentLine = 1; 857 boolean ellipsize = false; 858 for (SenderFragment senderFragment : mHeader.senderFragments) { 859 CharacterStyle style = senderFragment.style; 860 int start = senderFragment.start; 861 int end = senderFragment.end; 862 int width = senderFragment.width; 863 boolean isFixed = senderFragment.isFixed; 864 style.updateDrawState(sPaint); 865 866 // No more width available, we'll only show fixed fragments. 867 if (ellipsize && !isFixed) { 868 senderFragment.shouldDisplay = false; 869 continue; 870 } 871 872 // New line and ellipsize text if needed. 873 senderFragment.ellipsizedText = null; 874 if (isFixed) { 875 fixedWidth -= width; 876 } 877 if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) { 878 // The text is too long, new line won't help. We have to 879 // ellipsize text. 880 if (totalWidth == 0) { 881 ellipsize = true; 882 } else { 883 // New line. 884 if (currentLine < mCoordinates.sendersLineCount) { 885 currentLine++; 886 sendersY += mCoordinates.sendersLineHeight; 887 totalWidth = 0; 888 // The text is still too long, we have to ellipsize 889 // text. 890 if (totalWidth + width > mSendersWidth) { 891 ellipsize = true; 892 } 893 } else { 894 ellipsize = true; 895 } 896 } 897 898 if (ellipsize) { 899 width = mSendersWidth - totalWidth; 900 // No more new line, we have to reserve width for fixed 901 // fragments. 902 if (currentLine == mCoordinates.sendersLineCount) { 903 width -= fixedWidth; 904 } 905 senderFragment.ellipsizedText = TextUtils.ellipsize( 906 mHeader.sendersText.substring(start, end), sPaint, width, 907 TruncateAt.END).toString(); 908 width = (int) sPaint.measureText(senderFragment.ellipsizedText); 909 } 910 } 911 senderFragment.shouldDisplay = true; 912 totalWidth += width; 913 914 final CharSequence fragmentDisplayText; 915 if (senderFragment.ellipsizedText != null) { 916 fragmentDisplayText = senderFragment.ellipsizedText; 917 } else { 918 fragmentDisplayText = mHeader.sendersText.substring(start, end); 919 } 920 final int spanStart = mHeader.sendersDisplayText.length(); 921 mHeader.sendersDisplayText.append(fragmentDisplayText); 922 mHeader.sendersDisplayText.setSpan(senderFragment.style, spanStart, 923 mHeader.sendersDisplayText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 924 } 925 return totalWidth; 926 } 927 928 /** 929 * If the subject contains the tag of a mailing-list (text surrounded with 930 * []), return the subject with that tag ellipsized, e.g. 931 * "[android-gmail-team] Hello" -> "[andr...] Hello" 932 */ 933 private String filterTag(String subject) { 934 String result = subject; 935 String formatString = getContext().getResources().getString(R.string.filtered_tag); 936 if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') { 937 int end = subject.indexOf(']'); 938 if (end > 0) { 939 String tag = subject.substring(1, end); 940 result = String.format(formatString, Utils.ellipsize(tag, 7), 941 subject.substring(end + 1)); 942 } 943 } 944 return result; 945 } 946 947 @Override 948 protected void onDraw(Canvas canvas) { 949 // Check mark. 950 if (mHeader.checkboxVisible) { 951 Bitmap checkmark = mChecked ? CHECKMARK_ON : CHECKMARK_OFF; 952 canvas.drawBitmap(checkmark, mCoordinates.checkmarkX, mCoordinates.checkmarkY, sPaint); 953 } 954 955 // Personal Level. 956 if (mCoordinates.showPersonalLevel && mHeader.personalLevelBitmap != null) { 957 canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalLevelX, 958 mCoordinates.personalLevelY, sPaint); 959 } 960 961 // Senders. 962 boolean isUnread = mHeader.unread; 963 sPaint.setTextSize(mCoordinates.sendersFontSize); 964 sPaint.setTypeface(mCoordinates.sendersView.getTypeface(isUnread)); 965 int sendersColor = getFontColor(isUnread ? SENDERS_TEXT_COLOR_UNREAD 966 : SENDERS_TEXT_COLOR_READ); 967 sPaint.setColor(sendersColor); 968 canvas.save(); 969 canvas.translate(mCoordinates.sendersX, 970 mCoordinates.sendersY + mHeader.sendersDisplayLayout.getTopPadding()); 971 mHeader.sendersDisplayLayout.draw(canvas); 972 canvas.restore(); 973 974 // Subject. 975 sPaint.setTextSize(mCoordinates.subjectFontSize); 976 sPaint.setTypeface(Typeface.DEFAULT); 977 canvas.save(); 978 if (isActivated() && showActivatedText()) { 979 if (mHeader.subjectLayoutActivated != null) { 980 canvas.translate(mCoordinates.subjectX, mCoordinates.subjectY 981 + mHeader.subjectLayoutActivated.getTopPadding()); 982 mHeader.subjectLayoutActivated.draw(canvas); 983 } 984 } else if (mHeader.subjectLayout != null) { 985 canvas.translate(mCoordinates.subjectX, 986 mCoordinates.subjectY + mHeader.subjectLayout.getTopPadding()); 987 mHeader.subjectLayout.draw(canvas); 988 } 989 canvas.restore(); 990 991 // Folders. 992 if (mCoordinates.showFolders) { 993 mHeader.folderDisplayer.drawFolders(canvas, mCoordinates, mFoldersXEnd, mMode); 994 } 995 996 // If this folder has a color (combined view/Email), show it here 997 if (mHeader.conversation.color != 0) { 998 sFoldersPaint.setColor(mHeader.conversation.color); 999 sFoldersPaint.setStyle(Paint.Style.FILL); 1000 int width = ConversationItemViewCoordinates.getColorBlockWidth(mContext); 1001 int height = ConversationItemViewCoordinates.getColorBlockHeight(mContext); 1002 canvas.drawRect(mCoordinates.dateXEnd - width, 0, mCoordinates.dateXEnd, 1003 height, sFoldersPaint); 1004 } 1005 1006 // Date background: shown when there is an attachment or a visible 1007 // folder. 1008 if (!isActivated() 1009 && (mHeader.conversation.hasAttachments || 1010 (mHeader.folderDisplayer != null 1011 && mHeader.folderDisplayer.hasVisibleFolders())) 1012 && ConversationItemViewCoordinates.showAttachmentBackground(mMode)) { 1013 int leftOffset = (mHeader.conversation.hasAttachments ? mPaperclipX : mDateX) 1014 - DATE_BACKGROUND_PADDING_LEFT; 1015 int top = mCoordinates.showFolders ? mCoordinates.foldersY : mCoordinates.dateY; 1016 mHeader.dateBackground = getDateBackground(mHeader.conversation.hasAttachments); 1017 canvas.drawBitmap(mHeader.dateBackground, leftOffset, top, sPaint); 1018 } else { 1019 mHeader.dateBackground = null; 1020 } 1021 1022 // Draw the reply state. Draw nothing if neither replied nor forwarded. 1023 if (mCoordinates.showReplyState) { 1024 if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) { 1025 canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX, 1026 mCoordinates.replyStateY, null); 1027 } else if (mHeader.hasBeenRepliedTo) { 1028 canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX, 1029 mCoordinates.replyStateY, null); 1030 } else if (mHeader.hasBeenForwarded) { 1031 canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX, 1032 mCoordinates.replyStateY, null); 1033 } else if (mHeader.isInvite) { 1034 canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX, 1035 mCoordinates.replyStateY, null); 1036 } 1037 } 1038 1039 // Date. 1040 sPaint.setTextSize(mCoordinates.dateFontSize); 1041 sPaint.setTypeface(Typeface.DEFAULT); 1042 sPaint.setColor(isUnread ? DATE_TEXT_COLOR_UNREAD : DATE_TEXT_COLOR_READ); 1043 drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateY - mCoordinates.dateAscent, 1044 sPaint); 1045 1046 // Paper clip icon. 1047 if (mHeader.paperclip != null) { 1048 canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint); 1049 } 1050 1051 if (mHeader.faded) { 1052 int fadedColor = -1; 1053 if (sFadedActivatedColor == -1) { 1054 sFadedActivatedColor = mContext.getResources().getColor( 1055 R.color.faded_activated_conversation_header); 1056 } 1057 fadedColor = sFadedActivatedColor; 1058 int restoreState = canvas.save(); 1059 Rect bounds = canvas.getClipBounds(); 1060 canvas.clipRect(bounds.left, bounds.top, bounds.right 1061 - mContext.getResources().getDimensionPixelSize(R.dimen.triangle_width), 1062 bounds.bottom); 1063 canvas.drawARGB(Color.alpha(fadedColor), Color.red(fadedColor), 1064 Color.green(fadedColor), Color.blue(fadedColor)); 1065 canvas.restoreToCount(restoreState); 1066 } 1067 1068 // Star. 1069 canvas.drawBitmap(getStarBitmap(), mCoordinates.starX, mCoordinates.starY, sPaint); 1070 } 1071 1072 private Bitmap getStarBitmap() { 1073 return mHeader.conversation.starred ? STAR_ON : STAR_OFF; 1074 } 1075 1076 private Bitmap getDateBackground(boolean hasAttachments) { 1077 int leftOffset = (hasAttachments ? mPaperclipX : mDateX) - DATE_BACKGROUND_PADDING_LEFT; 1078 if (hasAttachments) { 1079 if (sDateBackgroundAttachment == null) { 1080 sDateBackgroundAttachment = Bitmap.createScaledBitmap(DATE_BACKGROUND, mViewWidth 1081 - leftOffset, sDateBackgroundHeight, false); 1082 } 1083 return sDateBackgroundAttachment; 1084 } else { 1085 if (sDateBackgroundNoAttachment == null) { 1086 sDateBackgroundNoAttachment = Bitmap.createScaledBitmap(DATE_BACKGROUND, mViewWidth 1087 - leftOffset, sDateBackgroundHeight, false); 1088 } 1089 return sDateBackgroundNoAttachment; 1090 } 1091 } 1092 1093 private void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) { 1094 canvas.drawText(s, 0, s.length(), x, y, paint); 1095 } 1096 1097 private void updateBackground(boolean isUnread) { 1098 if (isUnread) { 1099 if (mTabletDevice && mViewMode.isListMode()) { 1100 if (mChecked) { 1101 setBackgroundResource(R.drawable.list_conversation_wide_unread_selected_holo); 1102 } else { 1103 setBackgroundResource(R.drawable.conversation_wide_unread_selector); 1104 } 1105 } else { 1106 if (mChecked) { 1107 setCheckedActivatedBackground(); 1108 } else { 1109 setBackgroundResource(R.drawable.conversation_unread_selector); 1110 } 1111 } 1112 } else { 1113 if (mTabletDevice && mViewMode.isListMode()) { 1114 if (mChecked) { 1115 setBackgroundResource(R.drawable.list_conversation_wide_read_selected_holo); 1116 } else { 1117 setBackgroundResource(R.drawable.conversation_wide_read_selector); 1118 } 1119 } else { 1120 if (mChecked) { 1121 setCheckedActivatedBackground(); 1122 } else { 1123 setBackgroundResource(R.drawable.conversation_read_selector); 1124 } 1125 } 1126 } 1127 } 1128 1129 private void setCheckedActivatedBackground() { 1130 if (isActivated() && mTabletDevice) { 1131 setBackgroundResource(R.drawable.list_arrow_selected_holo); 1132 } else { 1133 setBackgroundResource(R.drawable.list_selected_holo); 1134 } 1135 } 1136 1137 /** 1138 * Toggle the check mark on this view and update the conversation 1139 */ 1140 public void toggleCheckMark() { 1141 if (mHeader != null && mHeader.conversation != null) { 1142 mChecked = !mChecked; 1143 Conversation conv = mHeader.conversation; 1144 // Set the list position of this item in the conversation 1145 ListView listView = getListView(); 1146 conv.position = mChecked && listView != null ? listView.getPositionForView(this) 1147 : Conversation.NO_POSITION; 1148 if (mSelectedConversationSet != null) { 1149 mSelectedConversationSet.toggle(this, conv); 1150 } 1151 // We update the background after the checked state has changed now 1152 // that 1153 // we have a selected background asset. Setting the background 1154 // usually 1155 // waits for a layout pass, but we don't need a full layout, just an 1156 // update to the background. 1157 requestLayout(); 1158 } 1159 } 1160 1161 /** 1162 * Return if the checkbox for this item is checked. 1163 */ 1164 public boolean isChecked() { 1165 return mChecked; 1166 } 1167 1168 /** 1169 * Toggle the star on this view and update the conversation. 1170 */ 1171 public void toggleStar() { 1172 mHeader.conversation.starred = !mHeader.conversation.starred; 1173 Bitmap starBitmap = getStarBitmap(); 1174 postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX 1175 + starBitmap.getWidth(), 1176 mCoordinates.starY + starBitmap.getHeight()); 1177 ConversationCursor cursor = (ConversationCursor)mAdapter.getCursor(); 1178 cursor.updateBoolean(mContext, mHeader.conversation, ConversationColumns.STARRED, 1179 mHeader.conversation.starred); 1180 } 1181 1182 private boolean isTouchInCheckmark(float x, float y) { 1183 // Everything before senders and include a touch slop. 1184 return mHeader.checkboxVisible && x < mCoordinates.sendersX + TOUCH_SLOP; 1185 } 1186 1187 private boolean isTouchInStar(float x, float y) { 1188 // Everything after the star and include a touch slop. 1189 return x > mCoordinates.starX - TOUCH_SLOP; 1190 } 1191 1192 /** 1193 * Cancel any potential tap handling on this view. 1194 */ 1195 @Override 1196 public void cancelTap() { 1197 removeCallbacks(mPendingCheckForTap); 1198 removeCallbacks(mPendingCheckForLongPress); 1199 } 1200 1201 /** 1202 * ConversationItemView is given the first chance to handle touch events. 1203 */ 1204 @Override 1205 public boolean onTouchEvent(MotionEvent event) { 1206 mLastTouchX = (int) event.getX(); 1207 mLastTouchY = (int) event.getY(); 1208 if (!mSwipeEnabled) { 1209 return onTouchEventNoSwipe(event); 1210 } 1211 boolean handled = true; 1212 1213 int x = mLastTouchX; 1214 int y = mLastTouchY; 1215 switch (event.getAction()) { 1216 case MotionEvent.ACTION_DOWN: 1217 mDownEvent = true; 1218 // In order to allow the down event and subsequent move events 1219 // to bubble to the swipe handler, we need to return that all 1220 // down events are handled. 1221 handled = true; 1222 // TODO (mindyp) Debounce 1223 if (mPendingCheckForTap == null) { 1224 mPendingCheckForTap = new CheckForTap(); 1225 } 1226 postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout()); 1227 break; 1228 case MotionEvent.ACTION_CANCEL: 1229 mDownEvent = false; 1230 break; 1231 case MotionEvent.ACTION_UP: 1232 cancelTap(); 1233 if (mDownEvent) { 1234 // ConversationItemView gets the first chance to handle up 1235 // events if there was a down event and there was no move 1236 // event in between. In this case, ConversationItemView 1237 // received the down event, and then an up event in the 1238 // same location (+/- slop). Treat this as a click on the 1239 // view or on a specific part of the view. 1240 if (isTouchInCheckmark(x, y)) { 1241 // Touch on the check mark 1242 toggleCheckMark(); 1243 handled = true; 1244 } else if (isTouchInStar(x, y)) { 1245 // Touch on the star 1246 toggleStar(); 1247 handled = true; 1248 } else { 1249 ListView list = getListView(); 1250 if (!isChecked() && list != null) { 1251 int pos = list.getPositionForView(this); 1252 list.performItemClick(this, pos, mHeader.conversation.id); 1253 } 1254 } 1255 handled = true; 1256 } else { 1257 // There was no down event that this view was made aware of, 1258 // therefore it cannot handle it. 1259 handled = false; 1260 } 1261 break; 1262 } 1263 1264 if (!handled) { 1265 // Let View try to handle it as well. 1266 handled = super.onTouchEvent(event); 1267 } 1268 1269 return handled; 1270 } 1271 1272 private ListView getListView() { 1273 return ((SwipeableConversationItemView) getParent()).getListView(); 1274 } 1275 1276 private boolean onTouchEventNoSwipe(MotionEvent event) { 1277 boolean handled = true; 1278 1279 int x = (int) event.getX(); 1280 int y = (int) event.getY(); 1281 switch (event.getAction()) { 1282 case MotionEvent.ACTION_DOWN: 1283 mDownEvent = true; 1284 // In order to allow the down event and subsequent move events 1285 // to bubble to the swipe handler, we need to return that all 1286 // down events are handled. 1287 handled = isTouchInCheckmark(x, y) || isTouchInStar(x, y); 1288 break; 1289 case MotionEvent.ACTION_CANCEL: 1290 mDownEvent = false; 1291 break; 1292 case MotionEvent.ACTION_UP: 1293 if (mDownEvent) { 1294 // ConversationItemView gets the first chance to handle up 1295 // events if there was a down event and there was no move 1296 // event in between. In this case, ConversationItemView 1297 // received the down event, and then an up event in the 1298 // same location (+/- slop). Treat this as a click on the 1299 // view or on a specific part of the view. 1300 if (isTouchInCheckmark(x, y)) { 1301 // Touch on the check mark 1302 toggleCheckMark(); 1303 } else if (isTouchInStar(x, y)) { 1304 // Touch on the star 1305 toggleStar(); 1306 } 1307 handled = true; 1308 } else { 1309 // There was no down event that this view was made aware of, 1310 // therefore it cannot handle it. 1311 handled = false; 1312 } 1313 break; 1314 } 1315 1316 if (!handled) { 1317 // Let View try to handle it as well. 1318 handled = super.onTouchEvent(event); 1319 } 1320 1321 return handled; 1322 } 1323 1324 /** 1325 * Return if this item should respond to long clicks. 1326 */ 1327 @Override 1328 public boolean isLongClickable() { 1329 return true; 1330 } 1331 1332 final class CheckForTap implements Runnable { 1333 @Override 1334 public void run() { 1335 // refreshDrawableState(); 1336 final int longPressTimeout = ViewConfiguration.getLongPressTimeout(); 1337 final boolean longClickable = isLongClickable(); 1338 1339 if (longClickable) { 1340 if (mPendingCheckForLongPress == null) { 1341 mPendingCheckForLongPress = new CheckForLongPress(); 1342 } 1343 postDelayed(mPendingCheckForLongPress, longPressTimeout); 1344 } 1345 } 1346 } 1347 1348 private class CheckForLongPress implements Runnable { 1349 @Override 1350 public void run() { 1351 ConversationItemView.this.toggleSelectionOrBeginDrag(); 1352 performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); 1353 } 1354 } 1355 1356 /** 1357 * Grow the height of the item and fade it in when bringing a conversation 1358 * back from a destructive action. 1359 * @param listener 1360 */ 1361 public void startSwipeUndoAnimation(ViewMode viewMode, final AnimatorListener listener) { 1362 final int start = sUndoAnimationOffset; 1363 final int end = 0; 1364 ObjectAnimator undoAnimator = ObjectAnimator.ofFloat(this, "translationX", start, end); 1365 undoAnimator.setInterpolator(new DecelerateInterpolator(2.0f)); 1366 undoAnimator.addListener(listener); 1367 undoAnimator.setDuration(sUndoAnimationDuration); 1368 undoAnimator.start(); 1369 } 1370 1371 /** 1372 * Grow the height of the item and fade it in when bringing a conversation 1373 * back from a destructive action. 1374 * @param listener 1375 */ 1376 public void startUndoAnimation(ViewMode viewMode, final AnimatorListener listener) { 1377 int minHeight = ConversationItemViewCoordinates.getMinHeight(mContext, viewMode); 1378 setMinimumHeight(minHeight); 1379 final int start = 0; 1380 final int end = minHeight; 1381 ObjectAnimator undoAnimator = ObjectAnimator.ofInt(this, "animatedHeight", start, end); 1382 Animator fadeAnimator = ObjectAnimator.ofFloat(this, "itemAlpha", 0, 1.0f); 1383 mAnimatedHeight = start; 1384 undoAnimator.setInterpolator(new DecelerateInterpolator(2.0f)); 1385 undoAnimator.addListener(listener); 1386 undoAnimator.setDuration(sUndoAnimationDuration); 1387 AnimatorSet transitionSet = new AnimatorSet(); 1388 transitionSet.playTogether(undoAnimator, fadeAnimator); 1389 transitionSet.start(); 1390 } 1391 1392 // Used by animator 1393 @SuppressWarnings("unused") 1394 public void setItemAlpha(float alpha) { 1395 setAlpha(alpha); 1396 invalidate(); 1397 } 1398 1399 // Used by animator 1400 @SuppressWarnings("unused") 1401 public void setAnimatedHeight(int height) { 1402 mAnimatedHeight = height; 1403 requestLayout(); 1404 } 1405 1406 @Override 1407 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1408 if (mAnimatedHeight == -1) { 1409 int height = measureHeight(heightMeasureSpec, 1410 ConversationItemViewCoordinates.getMode(mContext, mViewMode)); 1411 setMeasuredDimension(widthMeasureSpec, height); 1412 } else { 1413 setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mAnimatedHeight); 1414 } 1415 } 1416 1417 /** 1418 * Determine the height of this view. 1419 * @param measureSpec A measureSpec packed into an int 1420 * @param mode The current mode of this view 1421 * @return The height of the view, honoring constraints from measureSpec 1422 */ 1423 private int measureHeight(int measureSpec, int mode) { 1424 int result = 0; 1425 int specMode = MeasureSpec.getMode(measureSpec); 1426 int specSize = MeasureSpec.getSize(measureSpec); 1427 1428 if (specMode == MeasureSpec.EXACTLY) { 1429 // We were told how big to be 1430 result = specSize; 1431 } else { 1432 // Measure the text 1433 result = ConversationItemViewCoordinates.getHeight(mContext, mode); 1434 if (specMode == MeasureSpec.AT_MOST) { 1435 // Respect AT_MOST value if that was what is called for by 1436 // measureSpec 1437 result = Math.min(result, specSize); 1438 } 1439 } 1440 return result; 1441 } 1442 1443 /** 1444 * Get the current position of this conversation item in the list. 1445 */ 1446 public int getPosition() { 1447 return mHeader != null && mHeader.conversation != null ? 1448 mHeader.conversation.position : -1; 1449 } 1450 1451 /** 1452 * Select the current conversation. 1453 */ 1454 private void selectConversation() { 1455 if (!mSelectedConversationSet.containsKey(mHeader.conversation.id)) { 1456 toggleCheckMark(); 1457 } 1458 } 1459 1460 @Override 1461 public View getView() { 1462 return this; 1463 } 1464 1465 /** 1466 * With two pane mode and mailboxes in one pane (tablet), add the 1467 * conversation to the selected set and start drag mode. In two pane mode 1468 * when viewing conversations (tablet), toggle selection. In one pane mode 1469 * (phone, and portrait mode on tablet), toggle selection. 1470 */ 1471 public void toggleSelectionOrBeginDrag() { 1472 // If we are in one pane mode, or we are looking at conversations, drag 1473 // and drop is meaningless. Toggle checkmark and return early. 1474 if (!Utils.useTabletUI(mContext) 1475 || !mViewMode.isListMode()) { 1476 toggleCheckMark(); 1477 return; 1478 } 1479 1480 // Begin drag mode. Keep the conversation selected (NOT toggle 1481 // selection) and start drag. 1482 selectConversation(); 1483 1484 // Clip data has form: [conversations_uri, conversationId1, 1485 // maxMessageId1, label1, conversationId2, maxMessageId2, label2, ...] 1486 int count = mSelectedConversationSet.size(); 1487 String description = Utils.formatPlural(mContext, R.plurals.move_conversation, count); 1488 1489 final ClipData data = ClipData.newUri(mContext.getContentResolver(), description, 1490 Conversation.MOVE_CONVERSATIONS_URI); 1491 for (Conversation conversation : mSelectedConversationSet.values()) { 1492 data.addItem(new Item(String.valueOf(conversation.position))); 1493 } 1494 // Protect against non-existent views: only happens for monkeys 1495 final int width = this.getWidth(); 1496 final int height = this.getHeight(); 1497 final boolean isDimensionNegative = (width < 0) || (height < 0); 1498 if (isDimensionNegative) { 1499 LogUtils.e(LOG_TAG, "ConversationItemView: dimension is negative: " 1500 + "width=%d, height=%d", width, height); 1501 return; 1502 } 1503 // Start drag mode 1504 startDrag(data, new ShadowBuilder(this, count, mLastTouchX, mLastTouchY), null, 0); 1505 } 1506 1507 private class ShadowBuilder extends DragShadowBuilder { 1508 private final Drawable mBackground; 1509 1510 private final View mView; 1511 private final String mDragDesc; 1512 private final int mTouchX; 1513 private final int mTouchY; 1514 private int mDragDescX; 1515 private int mDragDescY; 1516 1517 public ShadowBuilder(View view, int count, int touchX, int touchY) { 1518 super(view); 1519 mView = view; 1520 mBackground = mView.getResources().getDrawable(R.drawable.list_pressed_holo); 1521 mDragDesc = Utils.formatPlural(mView.getContext(), R.plurals.move_conversation, count); 1522 mTouchX = touchX; 1523 mTouchY = touchY; 1524 } 1525 1526 @Override 1527 public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) { 1528 int width = mView.getWidth(); 1529 int height = mView.getHeight(); 1530 mDragDescX = mCoordinates.sendersX; 1531 mDragDescY = getPadding(height, mCoordinates.subjectFontSize) 1532 - mCoordinates.subjectAscent; 1533 shadowSize.set(width, height); 1534 shadowTouchPoint.set(mTouchX, mTouchY); 1535 } 1536 1537 @Override 1538 public void onDrawShadow(Canvas canvas) { 1539 mBackground.setBounds(0, 0, mView.getWidth(), mView.getHeight()); 1540 mBackground.draw(canvas); 1541 sPaint.setTextSize(mCoordinates.subjectFontSize); 1542 canvas.drawText(mDragDesc, mDragDescX, mDragDescY, sPaint); 1543 } 1544 } 1545} 1546