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