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