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