ConversationItemView.java revision 172f56a81bba3b2589026c7cded795010e7678ad
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 Bitmap sDateBackgroundAttachment; 169 private 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, AnimatedAdapter adapter) { 372 bind(ConversationItemViewModel.forCursor(mAccount, cursor), viewMode, set, folder, 373 checkboxesDisabled, swipeEnabled, adapter); 374 } 375 376 public void bind(Conversation conversation, ViewMode viewMode, ConversationSelectionSet set, 377 Folder folder, boolean checkboxesDisabled, boolean swipeEnabled, 378 AnimatedAdapter adapter) { 379 bind(ConversationItemViewModel.forConversation(mAccount, conversation), viewMode, set, 380 folder, checkboxesDisabled, swipeEnabled, adapter); 381 } 382 383 private void bind(ConversationItemViewModel header, ViewMode viewMode, 384 ConversationSelectionSet set, Folder folder, boolean checkboxesDisabled, 385 boolean swipeEnabled, AnimatedAdapter adapter) { 386 mViewMode = viewMode; 387 mHeader = header; 388 mSelectedConversationSet = set; 389 mDisplayedFolder = folder; 390 mCheckboxesEnabled = !checkboxesDisabled; 391 mSwipeEnabled = swipeEnabled; 392 mAdapter = adapter; 393 setContentDescription(mHeader.getContentDescription(mContext)); 394 requestLayout(); 395 } 396 397 /** 398 * Get the Conversation object associated with this view. 399 */ 400 public Conversation getConversation() { 401 return mHeader.conversation; 402 } 403 404 /** 405 * Sets the mode. Only used for testing. 406 */ 407 @VisibleForTesting 408 void setMode(int mode) { 409 mMode = mode; 410 mTesting = true; 411 } 412 413 private static void startTimer(String tag) { 414 if (sTimer != null) { 415 sTimer.start(tag); 416 } 417 } 418 419 private static void pauseTimer(String tag) { 420 if (sTimer != null) { 421 sTimer.pause(tag); 422 } 423 } 424 425 @Override 426 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 427 startTimer(PERF_TAG_LAYOUT); 428 429 super.onLayout(changed, left, top, right, bottom); 430 431 int width = right - left; 432 if (width != mViewWidth) { 433 mViewWidth = width; 434 if (!mTesting) { 435 mMode = ConversationItemViewCoordinates.getMode(mContext, mViewMode); 436 } 437 } 438 mHeader.viewWidth = mViewWidth; 439 Resources res = getResources(); 440 mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen); 441 if (mHeader.standardScaledDimen != sStandardScaledDimen) { 442 // Large Text has been toggle on/off. Update the static dimens. 443 sStandardScaledDimen = mHeader.standardScaledDimen; 444 ConversationItemViewCoordinates.refreshConversationHeights(mContext); 445 sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height); 446 } 447 mCoordinates = ConversationItemViewCoordinates.forWidth(mContext, mViewWidth, mMode, 448 mHeader.standardScaledDimen); 449 calculateTextsAndBitmaps(); 450 calculateCoordinates(); 451 mHeader.validate(mContext); 452 453 pauseTimer(PERF_TAG_LAYOUT); 454 if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) { 455 sTimer.dumpResults(); 456 sTimer = new Timer(); 457 sLayoutCount = 0; 458 } 459 } 460 461 @Override 462 public void setBackgroundResource(int resourceId) { 463 Drawable drawable = mBackgrounds.get(resourceId); 464 if (drawable == null) { 465 drawable = getResources().getDrawable(resourceId); 466 mBackgrounds.put(resourceId, drawable); 467 } 468 if (getBackground() != drawable) { 469 super.setBackgroundDrawable(drawable); 470 } 471 } 472 473 private void calculateTextsAndBitmaps() { 474 startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 475 if (mSelectedConversationSet != null) { 476 mChecked = mSelectedConversationSet.contains(mHeader.conversation); 477 } 478 // Update font color. 479 int fontColor = getFontColor(sDefaultTextColor); 480 boolean fontChanged = false; 481 if (mHeader.fontColor != fontColor) { 482 fontChanged = true; 483 mHeader.fontColor = fontColor; 484 } 485 486 boolean isUnread = mHeader.unread; 487 488 final boolean checkboxEnabled = mCheckboxesEnabled; 489 if (mHeader.checkboxVisible != checkboxEnabled) { 490 mHeader.checkboxVisible = checkboxEnabled; 491 } 492 493 // Update background. 494 updateBackground(isUnread); 495 496 if (mHeader.isLayoutValid(mContext)) { 497 // Relayout subject if font color has changed. 498 if (fontChanged) { 499 layoutSubjectSpans(isUnread); 500 layoutSubject(); 501 } 502 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 503 return; 504 } 505 506 startTimer(PERF_TAG_CALCULATE_FOLDERS); 507 508 // Initialize folder displayer. 509 if (mCoordinates.showFolders) { 510 mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext); 511 mHeader.folderDisplayer.loadConversationFolders(mHeader.conversation, mDisplayedFolder); 512 } 513 514 pauseTimer(PERF_TAG_CALCULATE_FOLDERS); 515 516 mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext, 517 mHeader.conversation.dateMs).toString(); 518 519 // Paper clip icon. 520 mHeader.paperclip = null; 521 if (mHeader.conversation.hasAttachments) { 522 mHeader.paperclip = ATTACHMENT; 523 } 524 // Personal level. 525 mHeader.personalLevelBitmap = null; 526 if (mCoordinates.showPersonalLevel) { 527 int personalLevel = mHeader.personalLevel; 528 final boolean isImportant = 529 mHeader.priority == UIProvider.ConversationPriority.IMPORTANT; 530 // TODO(mindyp): get whether importance indicators are enabled 531 // mPriorityMarkersEnabled = 532 // persistence.getPriorityInboxArrowsEnabled(mContext, mAccount); 533 mPriorityMarkersEnabled = true; 534 boolean useImportantMarkers = isImportant && mPriorityMarkersEnabled; 535 536 if (personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) { 537 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME 538 : ONLY_TO_ME; 539 } else if (personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) { 540 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS 541 : TO_ME_AND_OTHERS; 542 } else if (useImportantMarkers) { 543 mHeader.personalLevelBitmap = IMPORTANT_TO_OTHERS; 544 } 545 } 546 547 startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 548 549 // Subject. 550 layoutSubjectSpans(isUnread); 551 552 mHeader.sendersDisplayText = new SpannableStringBuilder(); 553 mHeader.styledSendersString = new SpannableStringBuilder(); 554 555 // Parse senders fragments. 556 if (mHeader.conversation.conversationInfo != null) { 557 Context context = getContext(); 558 mHeader.messageInfoString = SendersView.createMessageInfo(context, 559 mHeader.conversation.conversationInfo); 560 int maxChars = ConversationItemViewCoordinates.getSubjectLength(context, 561 ConversationItemViewCoordinates.getMode(context, mViewMode), 562 mHeader.folderDisplayer != null && mHeader.folderDisplayer.mFoldersCount > 0, 563 mHeader.conversation.hasAttachments); 564 mHeader.styledSenders = SendersView.format(context, 565 mHeader.conversation.conversationInfo, mHeader.messageInfoString.toString(), 566 maxChars); 567 } else { 568 mCoordinates.sendersView.formatSenders(mHeader, isUnread, mMode); 569 } 570 571 pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 572 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 573 } 574 575 private void layoutSubjectSpans(boolean isUnread) { 576 if (showActivatedText()) { 577 mHeader.subjectTextActivated = createSubject(isUnread, true); 578 } 579 mHeader.subjectText = createSubject(isUnread, false); 580 } 581 582 private SpannableStringBuilder createSubject(boolean isUnread, boolean activated) { 583 final String subject = filterTag(mHeader.conversation.subject); 584 final String snippet = mHeader.conversation.getSnippet(); 585 int subjectColor = activated ? sActivatedTextColor : isUnread ? sSubjectTextColorUnead 586 : sSubjectTextColorRead; 587 int snippetColor = activated ? sActivatedTextColor : isUnread ? sSnippetTextColorUnread 588 : sSnippetTextColorRead; 589 SpannableStringBuilder subjectText = new SpannableStringBuilder( 590 (snippet != null) ? mContext.getString(R.string.subject_and_snippet, subject, 591 snippet) : subject); 592 if (isUnread) { 593 subjectText.setSpan(new StyleSpan(Typeface.BOLD), 0, subject.length(), 594 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 595 } 596 subjectText.setSpan(new ForegroundColorSpan(subjectColor), 0, subject.length(), 597 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 598 if (snippet != null) { 599 subjectText.setSpan(new ForegroundColorSpan(snippetColor), subject.length() + 1, 600 subjectText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 601 } 602 return subjectText; 603 } 604 605 private int getFontColor(int defaultColor) { 606 return isActivated() && mTabletDevice ? sActivatedTextColor 607 : defaultColor; 608 } 609 610 private boolean showActivatedText() { 611 return mTabletDevice; 612 } 613 614 private void layoutSubject() { 615 if (showActivatedText()) { 616 mHeader.subjectLayoutActivated = 617 createSubjectLayout(true, mHeader.subjectTextActivated); 618 } 619 mHeader.subjectLayout = createSubjectLayout(false, mHeader.subjectText); 620 } 621 622 private StaticLayout createSubjectLayout(boolean activated, 623 SpannableStringBuilder subjectText) { 624 sPaint.setTextSize(mCoordinates.subjectFontSize); 625 sPaint.setColor(activated ? sActivatedTextColor 626 : mHeader.unread ? sSubjectTextColorUnead : sSubjectTextColorRead); 627 StaticLayout subjectLayout = new StaticLayout(subjectText, sPaint, 628 mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 629 int lineCount = subjectLayout.getLineCount(); 630 if (mCoordinates.subjectLineCount < lineCount) { 631 int end = subjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1); 632 subjectLayout = new StaticLayout(subjectText.subSequence(0, end), sPaint, 633 mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 634 } 635 return subjectLayout; 636 } 637 638 private boolean canFitFragment(int width, int line, int fixedWidth) { 639 if (line == mCoordinates.sendersLineCount) { 640 return width + fixedWidth <= mSendersWidth; 641 } else { 642 return width <= mSendersWidth; 643 } 644 } 645 646 private void calculateCoordinates() { 647 startTimer(PERF_TAG_CALCULATE_COORDINATES); 648 649 sPaint.setTextSize(mCoordinates.dateFontSize); 650 sPaint.setTypeface(Typeface.DEFAULT); 651 mDateX = mCoordinates.dateXEnd - (int) sPaint.measureText(mHeader.dateText); 652 653 mPaperclipX = mDateX - ATTACHMENT.getWidth(); 654 655 int cellWidth = mContext.getResources().getDimensionPixelSize(R.dimen.folder_cell_width); 656 657 if (ConversationItemViewCoordinates.isWideMode(mMode)) { 658 // Folders are displayed above the date. 659 mFoldersXEnd = mCoordinates.dateXEnd; 660 // In wide mode, the end of the senders should align with 661 // the start of the subject and is based on a max width. 662 mSendersWidth = mCoordinates.sendersWidth; 663 } else { 664 // In normal mode, the width is based on where the folders or date 665 // (or attachment icon) start. 666 if (mCoordinates.showFolders) { 667 if (mHeader.paperclip != null) { 668 mFoldersXEnd = mPaperclipX; 669 } else { 670 mFoldersXEnd = mDateX - cellWidth / 2; 671 } 672 mSendersWidth = mFoldersXEnd - mCoordinates.sendersX - 2 * cellWidth; 673 if (mHeader.folderDisplayer.hasVisibleFolders()) { 674 mSendersWidth -= ConversationItemViewCoordinates.getFoldersWidth(mContext, 675 mMode); 676 } 677 } else { 678 int dateAttachmentStart = 0; 679 // Have this end near the paperclip or date, not the folders. 680 if (mHeader.paperclip != null) { 681 dateAttachmentStart = mPaperclipX; 682 } else { 683 dateAttachmentStart = mDateX; 684 } 685 mSendersWidth = dateAttachmentStart - mCoordinates.sendersX - cellWidth; 686 } 687 } 688 689 if (mHeader.isLayoutValid(mContext)) { 690 pauseTimer(PERF_TAG_CALCULATE_COORDINATES); 691 return; 692 } 693 694 // Layout subject. 695 layoutSubject(); 696 697 // Second pass to layout each fragment. 698 int sendersY = mCoordinates.sendersY - mCoordinates.sendersAscent; 699 700 if (mHeader.styledSenders != null) { 701 ellipsizeStyledSenders(); 702 mHeader.sendersDisplayLayout = new StaticLayout(mHeader.styledSendersString, sPaint, 703 mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 704 } else { 705 // First pass to calculate width of each fragment. 706 int totalWidth = 0; 707 int fixedWidth = 0; 708 sPaint.setTextSize(mCoordinates.sendersFontSize); 709 sPaint.setTypeface(Typeface.DEFAULT); 710 for (SenderFragment senderFragment : mHeader.senderFragments) { 711 CharacterStyle style = senderFragment.style; 712 int start = senderFragment.start; 713 int end = senderFragment.end; 714 style.updateDrawState(sPaint); 715 senderFragment.width = (int) sPaint.measureText(mHeader.sendersText, start, end); 716 boolean isFixed = senderFragment.isFixed; 717 if (isFixed) { 718 fixedWidth += senderFragment.width; 719 } 720 totalWidth += senderFragment.width; 721 } 722 723 if (!ConversationItemViewCoordinates.displaySendersInline(mMode)) { 724 sendersY += totalWidth <= mSendersWidth ? mCoordinates.sendersLineHeight / 2 : 0; 725 } 726 totalWidth = ellipsize(fixedWidth, sendersY); 727 mHeader.sendersDisplayLayout = new StaticLayout(mHeader.sendersDisplayText, sPaint, 728 mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 729 } 730 731 sPaint.setTextSize(mCoordinates.sendersFontSize); 732 sPaint.setTypeface(Typeface.DEFAULT); 733 if (mSendersWidth < 0) { 734 mSendersWidth = 0; 735 } 736 737 pauseTimer(PERF_TAG_CALCULATE_COORDINATES); 738 } 739 740 // The rules for displaying ellipsized senders are as follows: 741 // 1) If there is message info (either a COUNT or DRAFT info to display), it MUST be shown 742 // 2) If senders do not fit, ellipsize the last one that does fit, and stop 743 // appending new senders 744 private int ellipsizeStyledSenders() { 745 SpannableStringBuilder builder = new SpannableStringBuilder(); 746 int totalWidth = 0; 747 int currentLine = 1; 748 boolean ellipsize = false; 749 SpannableString ellipsizedText; 750 int width; 751 SpannableStringBuilder messageInfoString = mHeader.messageInfoString; 752 // Paint the message info string to see if we lose space. 753 float messageInfoWidth = sPaint.measureText(messageInfoString.toString()); 754 totalWidth += messageInfoWidth; 755 SpannableString prevSender = null; 756 for (SpannableString sender : mHeader.styledSenders) { 757 // No more width available, we'll only show fixed fragments. 758 if (ellipsize) { 759 break; 760 } 761 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 762 // There is only 1 character style span. 763 if (spans.length > 0) { 764 spans[0].updateDrawState(sPaint); 765 } 766 // If there are already senders present in this string, we need to 767 // make sure we prepend the dividing token 768 if (SendersView.sElidedString.equals(sender.toString())) { 769 prevSender = sender; 770 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken); 771 } else if (builder.length() > 0 772 && (prevSender == null || !SendersView.sElidedString.equals(prevSender 773 .toString()))) { 774 prevSender = sender; 775 sender = copyStyles(spans, sSendersSplitToken + sender); 776 } else { 777 prevSender = sender; 778 } 779 // Measure the width of the current sender and make sure we have space 780 width = (int) sPaint.measureText(sender.toString()); 781 if (width + totalWidth > mSendersWidth) { 782 // The text is too long, new line won't help. We have to 783 // ellipsize text. 784 ellipsize = true; 785 width = mSendersWidth - totalWidth; 786 ellipsizedText = copyStyles(spans, 787 TextUtils.ellipsize(sender, sPaint, width, TruncateAt.END)); 788 width = (int) sPaint.measureText(ellipsizedText.toString()); 789 } else { 790 ellipsizedText = null; 791 } 792 totalWidth += width; 793 794 final CharSequence fragmentDisplayText; 795 if (ellipsizedText != null) { 796 fragmentDisplayText = ellipsizedText; 797 } else { 798 fragmentDisplayText = sender; 799 } 800 builder.append(fragmentDisplayText); 801 } 802 if (messageInfoString.length() > 0) { 803 builder.append(messageInfoString); 804 } 805 mHeader.styledSendersString = builder; 806 return totalWidth; 807 } 808 809 private SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 810 SpannableString s = new SpannableString(newText); 811 if (spans != null && spans.length > 0) { 812 s.setSpan(spans[0], 0, s.length(), 0); 813 } 814 return s; 815 } 816 817 private int ellipsize(int fixedWidth, int sendersY) { 818 int totalWidth = 0; 819 int currentLine = 1; 820 boolean ellipsize = false; 821 for (SenderFragment senderFragment : mHeader.senderFragments) { 822 CharacterStyle style = senderFragment.style; 823 int start = senderFragment.start; 824 int end = senderFragment.end; 825 int width = senderFragment.width; 826 boolean isFixed = senderFragment.isFixed; 827 style.updateDrawState(sPaint); 828 829 // No more width available, we'll only show fixed fragments. 830 if (ellipsize && !isFixed) { 831 senderFragment.shouldDisplay = false; 832 continue; 833 } 834 835 // New line and ellipsize text if needed. 836 senderFragment.ellipsizedText = null; 837 if (isFixed) { 838 fixedWidth -= width; 839 } 840 if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) { 841 // The text is too long, new line won't help. We have to 842 // ellipsize text. 843 if (totalWidth == 0) { 844 ellipsize = true; 845 } else { 846 // New line. 847 if (currentLine < mCoordinates.sendersLineCount) { 848 currentLine++; 849 sendersY += mCoordinates.sendersLineHeight; 850 totalWidth = 0; 851 // The text is still too long, we have to ellipsize 852 // text. 853 if (totalWidth + width > mSendersWidth) { 854 ellipsize = true; 855 } 856 } else { 857 ellipsize = true; 858 } 859 } 860 861 if (ellipsize) { 862 width = mSendersWidth - totalWidth; 863 // No more new line, we have to reserve width for fixed 864 // fragments. 865 if (currentLine == mCoordinates.sendersLineCount) { 866 width -= fixedWidth; 867 } 868 senderFragment.ellipsizedText = TextUtils.ellipsize( 869 mHeader.sendersText.substring(start, end), sPaint, width, 870 TruncateAt.END).toString(); 871 width = (int) sPaint.measureText(senderFragment.ellipsizedText); 872 } 873 } 874 senderFragment.shouldDisplay = true; 875 totalWidth += width; 876 877 final CharSequence fragmentDisplayText; 878 if (senderFragment.ellipsizedText != null) { 879 fragmentDisplayText = senderFragment.ellipsizedText; 880 } else { 881 fragmentDisplayText = mHeader.sendersText.substring(start, end); 882 } 883 final int spanStart = mHeader.sendersDisplayText.length(); 884 mHeader.sendersDisplayText.append(fragmentDisplayText); 885 mHeader.sendersDisplayText.setSpan(senderFragment.style, spanStart, 886 mHeader.sendersDisplayText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 887 } 888 return totalWidth; 889 } 890 891 /** 892 * If the subject contains the tag of a mailing-list (text surrounded with 893 * []), return the subject with that tag ellipsized, e.g. 894 * "[android-gmail-team] Hello" -> "[andr...] Hello" 895 */ 896 private String filterTag(String subject) { 897 String result = subject; 898 String formatString = getContext().getResources().getString(R.string.filtered_tag); 899 if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') { 900 int end = subject.indexOf(']'); 901 if (end > 0) { 902 String tag = subject.substring(1, end); 903 result = String.format(formatString, Utils.ellipsize(tag, 7), 904 subject.substring(end + 1)); 905 } 906 } 907 return result; 908 } 909 910 @Override 911 protected void onDraw(Canvas canvas) { 912 // Check mark. 913 if (mHeader.checkboxVisible) { 914 Bitmap checkmark = mChecked ? CHECKMARK_ON : CHECKMARK_OFF; 915 canvas.drawBitmap(checkmark, mCoordinates.checkmarkX, mCoordinates.checkmarkY, sPaint); 916 } 917 918 // Personal Level. 919 if (mCoordinates.showPersonalLevel && mHeader.personalLevelBitmap != null) { 920 canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalLevelX, 921 mCoordinates.personalLevelY, sPaint); 922 } 923 924 // Senders. 925 boolean isUnread = mHeader.unread; 926 // Old style senders; apply text colors/ sizes/ styling. 927 if (mHeader.styledSendersString == null) { 928 sPaint.setTextSize(mCoordinates.sendersFontSize); 929 sPaint.setTypeface(mCoordinates.sendersView.getTypeface(isUnread)); 930 int sendersColor = getFontColor(isUnread ? sSendersTextColorUnread 931 : sSendersTextColorRead); 932 sPaint.setColor(sendersColor); 933 } 934 canvas.save(); 935 canvas.translate(mCoordinates.sendersX, 936 mCoordinates.sendersY + mHeader.sendersDisplayLayout.getTopPadding()); 937 mHeader.sendersDisplayLayout.draw(canvas); 938 canvas.restore(); 939 940 // Subject. 941 sPaint.setTextSize(mCoordinates.subjectFontSize); 942 sPaint.setTypeface(Typeface.DEFAULT); 943 canvas.save(); 944 if (isActivated() && showActivatedText()) { 945 if (mHeader.subjectLayoutActivated != null) { 946 canvas.translate(mCoordinates.subjectX, mCoordinates.subjectY 947 + mHeader.subjectLayoutActivated.getTopPadding()); 948 mHeader.subjectLayoutActivated.draw(canvas); 949 } 950 } else if (mHeader.subjectLayout != null) { 951 canvas.translate(mCoordinates.subjectX, 952 mCoordinates.subjectY + mHeader.subjectLayout.getTopPadding()); 953 mHeader.subjectLayout.draw(canvas); 954 } 955 canvas.restore(); 956 957 // Folders. 958 if (mCoordinates.showFolders) { 959 mHeader.folderDisplayer.drawFolders(canvas, mCoordinates, mFoldersXEnd, mMode); 960 } 961 962 // If this folder has a color (combined view/Email), show it here 963 if (mHeader.conversation.color != 0) { 964 sFoldersPaint.setColor(mHeader.conversation.color); 965 sFoldersPaint.setStyle(Paint.Style.FILL); 966 int width = ConversationItemViewCoordinates.getColorBlockWidth(mContext); 967 int height = ConversationItemViewCoordinates.getColorBlockHeight(mContext); 968 canvas.drawRect(mCoordinates.dateXEnd - width, 0, mCoordinates.dateXEnd, 969 height, sFoldersPaint); 970 } 971 972 // Date background: shown when there is an attachment or a visible 973 // folder. 974 if (!isActivated() 975 && (mHeader.conversation.hasAttachments || 976 (mHeader.folderDisplayer != null 977 && mHeader.folderDisplayer.hasVisibleFolders())) 978 && ConversationItemViewCoordinates.showAttachmentBackground(mMode)) { 979 int leftOffset = (mHeader.conversation.hasAttachments ? mPaperclipX : mDateX) 980 - sDateBackgroundPaddingLeft; 981 int top = mCoordinates.showFolders ? mCoordinates.foldersY : mCoordinates.dateY; 982 mHeader.dateBackground = getDateBackground(mHeader.conversation.hasAttachments); 983 canvas.drawBitmap(mHeader.dateBackground, leftOffset, top, sPaint); 984 } else { 985 mHeader.dateBackground = null; 986 } 987 988 // Draw the reply state. Draw nothing if neither replied nor forwarded. 989 if (mCoordinates.showReplyState) { 990 if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) { 991 canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX, 992 mCoordinates.replyStateY, null); 993 } else if (mHeader.hasBeenRepliedTo) { 994 canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX, 995 mCoordinates.replyStateY, null); 996 } else if (mHeader.hasBeenForwarded) { 997 canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX, 998 mCoordinates.replyStateY, null); 999 } else if (mHeader.isInvite) { 1000 canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX, 1001 mCoordinates.replyStateY, null); 1002 } 1003 } 1004 1005 // Date. 1006 sPaint.setTextSize(mCoordinates.dateFontSize); 1007 sPaint.setTypeface(Typeface.DEFAULT); 1008 sPaint.setColor(sDateTextColor); 1009 drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateY - mCoordinates.dateAscent, 1010 sPaint); 1011 1012 // Paper clip icon. 1013 if (mHeader.paperclip != null) { 1014 canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint); 1015 } 1016 1017 if (mHeader.faded) { 1018 int fadedColor = -1; 1019 if (sFadedActivatedColor == -1) { 1020 sFadedActivatedColor = mContext.getResources().getColor( 1021 R.color.faded_activated_conversation_header); 1022 } 1023 fadedColor = sFadedActivatedColor; 1024 int restoreState = canvas.save(); 1025 Rect bounds = canvas.getClipBounds(); 1026 canvas.clipRect(bounds.left, bounds.top, bounds.right 1027 - mContext.getResources().getDimensionPixelSize(R.dimen.triangle_width), 1028 bounds.bottom); 1029 canvas.drawARGB(Color.alpha(fadedColor), Color.red(fadedColor), 1030 Color.green(fadedColor), Color.blue(fadedColor)); 1031 canvas.restoreToCount(restoreState); 1032 } 1033 1034 // Star. 1035 canvas.drawBitmap(getStarBitmap(), mCoordinates.starX, mCoordinates.starY, sPaint); 1036 } 1037 1038 private Bitmap getStarBitmap() { 1039 return mHeader.conversation.starred ? STAR_ON : STAR_OFF; 1040 } 1041 1042 private Bitmap getDateBackground(boolean hasAttachments) { 1043 int leftOffset = (hasAttachments ? mPaperclipX : mDateX) - sDateBackgroundPaddingLeft; 1044 if (hasAttachments) { 1045 if (sDateBackgroundAttachment == null) { 1046 sDateBackgroundAttachment = Bitmap.createScaledBitmap(DATE_BACKGROUND, mViewWidth 1047 - leftOffset, sDateBackgroundHeight, false); 1048 } 1049 return sDateBackgroundAttachment; 1050 } else { 1051 if (sDateBackgroundNoAttachment == null) { 1052 sDateBackgroundNoAttachment = Bitmap.createScaledBitmap(DATE_BACKGROUND, mViewWidth 1053 - leftOffset, sDateBackgroundHeight, false); 1054 } 1055 return sDateBackgroundNoAttachment; 1056 } 1057 } 1058 1059 private void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) { 1060 canvas.drawText(s, 0, s.length(), x, y, paint); 1061 } 1062 1063 private void updateBackground(boolean isUnread) { 1064 if (isUnread) { 1065 if (mTabletDevice && mViewMode.isListMode()) { 1066 if (mChecked) { 1067 setBackgroundResource(R.drawable.list_conversation_wide_unread_selected_holo); 1068 } else { 1069 setBackgroundResource(R.drawable.conversation_wide_unread_selector); 1070 } 1071 } else { 1072 if (mChecked) { 1073 setCheckedActivatedBackground(); 1074 } else { 1075 setBackgroundResource(R.drawable.conversation_unread_selector); 1076 } 1077 } 1078 } else { 1079 if (mTabletDevice && mViewMode.isListMode()) { 1080 if (mChecked) { 1081 setBackgroundResource(R.drawable.list_conversation_wide_read_selected_holo); 1082 } else { 1083 setBackgroundResource(R.drawable.conversation_wide_read_selector); 1084 } 1085 } else { 1086 if (mChecked) { 1087 setCheckedActivatedBackground(); 1088 } else { 1089 setBackgroundResource(R.drawable.conversation_read_selector); 1090 } 1091 } 1092 } 1093 } 1094 1095 private void setCheckedActivatedBackground() { 1096 if (isActivated() && mTabletDevice) { 1097 setBackgroundResource(R.drawable.list_arrow_selected_holo); 1098 } else { 1099 setBackgroundResource(R.drawable.list_selected_holo); 1100 } 1101 } 1102 1103 /** 1104 * Toggle the check mark on this view and update the conversation 1105 */ 1106 public void toggleCheckMark() { 1107 if (mHeader != null && mHeader.conversation != null) { 1108 mChecked = !mChecked; 1109 Conversation conv = mHeader.conversation; 1110 // Set the list position of this item in the conversation 1111 ListView listView = getListView(); 1112 conv.position = mChecked && listView != null ? listView.getPositionForView(this) 1113 : Conversation.NO_POSITION; 1114 if (mSelectedConversationSet != null) { 1115 mSelectedConversationSet.toggle(this, conv); 1116 } 1117 // We update the background after the checked state has changed now 1118 // that 1119 // we have a selected background asset. Setting the background 1120 // usually 1121 // waits for a layout pass, but we don't need a full layout, just an 1122 // update to the background. 1123 requestLayout(); 1124 } 1125 } 1126 1127 /** 1128 * Return if the checkbox for this item is checked. 1129 */ 1130 public boolean isChecked() { 1131 return mChecked; 1132 } 1133 1134 /** 1135 * Toggle the star on this view and update the conversation. 1136 */ 1137 public void toggleStar() { 1138 mHeader.conversation.starred = !mHeader.conversation.starred; 1139 Bitmap starBitmap = getStarBitmap(); 1140 postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX 1141 + starBitmap.getWidth(), 1142 mCoordinates.starY + starBitmap.getHeight()); 1143 ConversationCursor cursor = (ConversationCursor)mAdapter.getCursor(); 1144 cursor.updateBoolean(mContext, mHeader.conversation, ConversationColumns.STARRED, 1145 mHeader.conversation.starred); 1146 } 1147 1148 private boolean isTouchInCheckmark(float x, float y) { 1149 // Everything before senders and include a touch slop. 1150 return mHeader.checkboxVisible && x < mCoordinates.sendersX + sTouchSlop; 1151 } 1152 1153 private boolean isTouchInStar(float x, float y) { 1154 // Everything after the star and include a touch slop. 1155 return x > mCoordinates.starX - sTouchSlop; 1156 } 1157 1158 /** 1159 * Cancel any potential tap handling on this view. 1160 */ 1161 @Override 1162 public void cancelTap() { 1163 removeCallbacks(mPendingCheckForTap); 1164 removeCallbacks(mPendingCheckForLongPress); 1165 } 1166 1167 /** 1168 * ConversationItemView is given the first chance to handle touch events. 1169 */ 1170 @Override 1171 public boolean onTouchEvent(MotionEvent event) { 1172 mLastTouchX = (int) event.getX(); 1173 mLastTouchY = (int) event.getY(); 1174 if (!mSwipeEnabled) { 1175 return onTouchEventNoSwipe(event); 1176 } 1177 boolean handled = true; 1178 1179 int x = mLastTouchX; 1180 int y = mLastTouchY; 1181 switch (event.getAction()) { 1182 case MotionEvent.ACTION_DOWN: 1183 mDownEvent = true; 1184 // In order to allow the down event and subsequent move events 1185 // to bubble to the swipe handler, we need to return that all 1186 // down events are handled. 1187 handled = true; 1188 // TODO (mindyp) Debounce 1189 if (mPendingCheckForTap == null) { 1190 mPendingCheckForTap = new CheckForTap(); 1191 } 1192 postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout()); 1193 break; 1194 case MotionEvent.ACTION_CANCEL: 1195 mDownEvent = false; 1196 break; 1197 case MotionEvent.ACTION_UP: 1198 cancelTap(); 1199 if (mDownEvent) { 1200 // ConversationItemView gets the first chance to handle up 1201 // events if there was a down event and there was no move 1202 // event in between. In this case, ConversationItemView 1203 // received the down event, and then an up event in the 1204 // same location (+/- slop). Treat this as a click on the 1205 // view or on a specific part of the view. 1206 if (isTouchInCheckmark(x, y)) { 1207 // Touch on the check mark 1208 toggleCheckMark(); 1209 handled = true; 1210 } else if (isTouchInStar(x, y)) { 1211 // Touch on the star 1212 toggleStar(); 1213 handled = true; 1214 } else { 1215 ListView list = getListView(); 1216 if (!isChecked() && list != null) { 1217 int pos = list.getPositionForView(this); 1218 list.performItemClick(this, pos, mHeader.conversation.id); 1219 } 1220 } 1221 handled = true; 1222 } else { 1223 // There was no down event that this view was made aware of, 1224 // therefore it cannot handle it. 1225 handled = false; 1226 } 1227 break; 1228 } 1229 1230 if (!handled) { 1231 // Let View try to handle it as well. 1232 handled = super.onTouchEvent(event); 1233 } 1234 1235 return handled; 1236 } 1237 1238 private ListView getListView() { 1239 return ((SwipeableConversationItemView) getParent()).getListView(); 1240 } 1241 1242 private boolean onTouchEventNoSwipe(MotionEvent event) { 1243 boolean handled = true; 1244 1245 int x = (int) event.getX(); 1246 int y = (int) event.getY(); 1247 switch (event.getAction()) { 1248 case MotionEvent.ACTION_DOWN: 1249 mDownEvent = true; 1250 // In order to allow the down event and subsequent move events 1251 // to bubble to the swipe handler, we need to return that all 1252 // down events are handled. 1253 handled = isTouchInCheckmark(x, y) || isTouchInStar(x, y); 1254 break; 1255 case MotionEvent.ACTION_CANCEL: 1256 mDownEvent = false; 1257 break; 1258 case MotionEvent.ACTION_UP: 1259 if (mDownEvent) { 1260 // ConversationItemView gets the first chance to handle up 1261 // events if there was a down event and there was no move 1262 // event in between. In this case, ConversationItemView 1263 // received the down event, and then an up event in the 1264 // same location (+/- slop). Treat this as a click on the 1265 // view or on a specific part of the view. 1266 if (isTouchInCheckmark(x, y)) { 1267 // Touch on the check mark 1268 toggleCheckMark(); 1269 } else if (isTouchInStar(x, y)) { 1270 // Touch on the star 1271 toggleStar(); 1272 } 1273 handled = true; 1274 } else { 1275 // There was no down event that this view was made aware of, 1276 // therefore it cannot handle it. 1277 handled = false; 1278 } 1279 break; 1280 } 1281 1282 if (!handled) { 1283 // Let View try to handle it as well. 1284 handled = super.onTouchEvent(event); 1285 } 1286 1287 return handled; 1288 } 1289 1290 /** 1291 * Return if this item should respond to long clicks. 1292 */ 1293 @Override 1294 public boolean isLongClickable() { 1295 return true; 1296 } 1297 1298 final class CheckForTap implements Runnable { 1299 @Override 1300 public void run() { 1301 // refreshDrawableState(); 1302 final int longPressTimeout = ViewConfiguration.getLongPressTimeout(); 1303 final boolean longClickable = isLongClickable(); 1304 1305 if (longClickable) { 1306 if (mPendingCheckForLongPress == null) { 1307 mPendingCheckForLongPress = new CheckForLongPress(); 1308 } 1309 postDelayed(mPendingCheckForLongPress, longPressTimeout); 1310 } 1311 } 1312 } 1313 1314 private class CheckForLongPress implements Runnable { 1315 @Override 1316 public void run() { 1317 ConversationItemView.this.toggleSelectionOrBeginDrag(); 1318 performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); 1319 } 1320 } 1321 1322 /** 1323 * Grow the height of the item and fade it in when bringing a conversation 1324 * back from a destructive action. 1325 * @param listener 1326 */ 1327 public void startSwipeUndoAnimation(ViewMode viewMode, final AnimatorListener listener) { 1328 final int start = sUndoAnimationOffset; 1329 final int end = 0; 1330 ObjectAnimator undoAnimator = ObjectAnimator.ofFloat(this, "translationX", start, end); 1331 undoAnimator.setInterpolator(new DecelerateInterpolator(2.0f)); 1332 undoAnimator.addListener(listener); 1333 undoAnimator.setDuration(sUndoAnimationDuration); 1334 undoAnimator.start(); 1335 } 1336 1337 /** 1338 * Grow the height of the item and fade it in when bringing a conversation 1339 * back from a destructive action. 1340 * @param listener 1341 */ 1342 public void startUndoAnimation(ViewMode viewMode, final AnimatorListener listener) { 1343 int minHeight = ConversationItemViewCoordinates.getMinHeight(mContext, viewMode); 1344 setMinimumHeight(minHeight); 1345 final int start = 0; 1346 final int end = minHeight; 1347 ObjectAnimator undoAnimator = ObjectAnimator.ofInt(this, "animatedHeight", start, end); 1348 Animator fadeAnimator = ObjectAnimator.ofFloat(this, "itemAlpha", 0, 1.0f); 1349 mAnimatedHeight = start; 1350 undoAnimator.setInterpolator(new DecelerateInterpolator(2.0f)); 1351 undoAnimator.addListener(listener); 1352 undoAnimator.setDuration(sUndoAnimationDuration); 1353 AnimatorSet transitionSet = new AnimatorSet(); 1354 transitionSet.playTogether(undoAnimator, fadeAnimator); 1355 transitionSet.start(); 1356 } 1357 1358 // Used by animator 1359 @SuppressWarnings("unused") 1360 public void setItemAlpha(float alpha) { 1361 setAlpha(alpha); 1362 invalidate(); 1363 } 1364 1365 // Used by animator 1366 @SuppressWarnings("unused") 1367 public void setAnimatedHeight(int height) { 1368 mAnimatedHeight = height; 1369 requestLayout(); 1370 } 1371 1372 @Override 1373 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1374 if (mAnimatedHeight == -1) { 1375 int height = measureHeight(heightMeasureSpec, 1376 ConversationItemViewCoordinates.getMode(mContext, mViewMode)); 1377 setMeasuredDimension(widthMeasureSpec, height); 1378 } else { 1379 setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mAnimatedHeight); 1380 } 1381 } 1382 1383 /** 1384 * Determine the height of this view. 1385 * @param measureSpec A measureSpec packed into an int 1386 * @param mode The current mode of this view 1387 * @return The height of the view, honoring constraints from measureSpec 1388 */ 1389 private int measureHeight(int measureSpec, int mode) { 1390 int result = 0; 1391 int specMode = MeasureSpec.getMode(measureSpec); 1392 int specSize = MeasureSpec.getSize(measureSpec); 1393 1394 if (specMode == MeasureSpec.EXACTLY) { 1395 // We were told how big to be 1396 result = specSize; 1397 } else { 1398 // Measure the text 1399 result = ConversationItemViewCoordinates.getHeight(mContext, mode); 1400 if (specMode == MeasureSpec.AT_MOST) { 1401 // Respect AT_MOST value if that was what is called for by 1402 // measureSpec 1403 result = Math.min(result, specSize); 1404 } 1405 } 1406 return result; 1407 } 1408 1409 /** 1410 * Get the current position of this conversation item in the list. 1411 */ 1412 public int getPosition() { 1413 return mHeader != null && mHeader.conversation != null ? 1414 mHeader.conversation.position : -1; 1415 } 1416 1417 /** 1418 * Select the current conversation. 1419 */ 1420 private void selectConversation() { 1421 if (!mSelectedConversationSet.containsKey(mHeader.conversation.id)) { 1422 toggleCheckMark(); 1423 } 1424 } 1425 1426 @Override 1427 public View getView() { 1428 return this; 1429 } 1430 1431 /** 1432 * With two pane mode and mailboxes in one pane (tablet), add the 1433 * conversation to the selected set and start drag mode. In two pane mode 1434 * when viewing conversations (tablet), toggle selection. In one pane mode 1435 * (phone, and portrait mode on tablet), toggle selection. 1436 */ 1437 public void toggleSelectionOrBeginDrag() { 1438 // If we are in one pane mode, or we are looking at conversations, drag 1439 // and drop is meaningless. Toggle checkmark and return early. 1440 if (!Utils.useTabletUI(mContext) 1441 || !mViewMode.isListMode()) { 1442 toggleCheckMark(); 1443 return; 1444 } 1445 1446 // Begin drag mode. Keep the conversation selected (NOT toggle 1447 // selection) and start drag. 1448 selectConversation(); 1449 1450 // Clip data has form: [conversations_uri, conversationId1, 1451 // maxMessageId1, label1, conversationId2, maxMessageId2, label2, ...] 1452 int count = mSelectedConversationSet.size(); 1453 String description = Utils.formatPlural(mContext, R.plurals.move_conversation, count); 1454 1455 final ClipData data = ClipData.newUri(mContext.getContentResolver(), description, 1456 Conversation.MOVE_CONVERSATIONS_URI); 1457 for (Conversation conversation : mSelectedConversationSet.values()) { 1458 data.addItem(new Item(String.valueOf(conversation.position))); 1459 } 1460 // Protect against non-existent views: only happens for monkeys 1461 final int width = this.getWidth(); 1462 final int height = this.getHeight(); 1463 final boolean isDimensionNegative = (width < 0) || (height < 0); 1464 if (isDimensionNegative) { 1465 LogUtils.e(LOG_TAG, "ConversationItemView: dimension is negative: " 1466 + "width=%d, height=%d", width, height); 1467 return; 1468 } 1469 // Start drag mode 1470 startDrag(data, new ShadowBuilder(this, count, mLastTouchX, mLastTouchY), null, 0); 1471 } 1472 1473 private class ShadowBuilder extends DragShadowBuilder { 1474 private final Drawable mBackground; 1475 1476 private final View mView; 1477 private final String mDragDesc; 1478 private final int mTouchX; 1479 private final int mTouchY; 1480 private int mDragDescX; 1481 private int mDragDescY; 1482 1483 public ShadowBuilder(View view, int count, int touchX, int touchY) { 1484 super(view); 1485 mView = view; 1486 mBackground = mView.getResources().getDrawable(R.drawable.list_pressed_holo); 1487 mDragDesc = Utils.formatPlural(mView.getContext(), R.plurals.move_conversation, count); 1488 mTouchX = touchX; 1489 mTouchY = touchY; 1490 } 1491 1492 @Override 1493 public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) { 1494 int width = mView.getWidth(); 1495 int height = mView.getHeight(); 1496 mDragDescX = mCoordinates.sendersX; 1497 mDragDescY = getPadding(height, mCoordinates.subjectFontSize) 1498 - mCoordinates.subjectAscent; 1499 shadowSize.set(width, height); 1500 shadowTouchPoint.set(mTouchX, mTouchY); 1501 } 1502 1503 @Override 1504 public void onDrawShadow(Canvas canvas) { 1505 mBackground.setBounds(0, 0, mView.getWidth(), mView.getHeight()); 1506 mBackground.draw(canvas); 1507 sPaint.setTextSize(mCoordinates.subjectFontSize); 1508 canvas.drawText(mDragDesc, mDragDescX, mDragDescY, sPaint); 1509 } 1510 } 1511} 1512