ConversationItemView.java revision d94312288286017a38603cb10becd2ad52580524
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.AnimatorSet; 22import android.animation.ObjectAnimator; 23import android.content.ClipData; 24import android.content.ClipData.Item; 25import android.content.Context; 26import android.content.res.Resources; 27import android.database.Cursor; 28import android.graphics.Bitmap; 29import android.graphics.BitmapFactory; 30import android.graphics.Canvas; 31import android.graphics.LinearGradient; 32import android.graphics.Paint; 33import android.graphics.Point; 34import android.graphics.Shader; 35import android.graphics.Typeface; 36import android.graphics.drawable.Drawable; 37import android.net.Uri; 38import android.text.Layout.Alignment; 39import android.text.Spannable; 40import android.text.SpannableString; 41import android.text.SpannableStringBuilder; 42import android.text.StaticLayout; 43import android.text.TextPaint; 44import android.text.TextUtils; 45import android.text.TextUtils.TruncateAt; 46import android.text.format.DateUtils; 47import android.text.style.CharacterStyle; 48import android.text.style.ForegroundColorSpan; 49import android.text.style.TextAppearanceSpan; 50import android.util.SparseArray; 51import android.util.TypedValue; 52import android.view.DragEvent; 53import android.view.MotionEvent; 54import android.view.View; 55import android.view.ViewGroup; 56import android.view.ViewParent; 57import android.view.animation.DecelerateInterpolator; 58import android.widget.TextView; 59 60import com.android.mail.R; 61import com.android.mail.browse.ConversationItemViewModel.SenderFragment; 62import com.android.mail.perf.Timer; 63import com.android.mail.photomanager.ContactPhotoManager; 64import com.android.mail.photomanager.ContactPhotoManager.ContactIdentifier; 65import com.android.mail.photomanager.PhotoManager.PhotoIdentifier; 66import com.android.mail.providers.Conversation; 67import com.android.mail.providers.Folder; 68import com.android.mail.providers.UIProvider; 69import com.android.mail.providers.UIProvider.ConversationColumns; 70import com.android.mail.providers.UIProvider.ConversationListIcon; 71import com.android.mail.providers.UIProvider.FolderType; 72import com.android.mail.ui.AnimatedAdapter; 73import com.android.mail.ui.ControllableActivity; 74import com.android.mail.ui.ConversationSelectionSet; 75import com.android.mail.ui.DividedImageCanvas; 76import com.android.mail.ui.DividedImageCanvas.InvalidateCallback; 77import com.android.mail.ui.FolderDisplayer; 78import com.android.mail.ui.SwipeableItemView; 79import com.android.mail.ui.SwipeableListView; 80import com.android.mail.ui.ViewMode; 81import com.android.mail.utils.HardwareLayerEnabler; 82import com.android.mail.utils.LogTag; 83import com.android.mail.utils.LogUtils; 84import com.android.mail.utils.Utils; 85import com.google.common.annotations.VisibleForTesting; 86 87import java.util.ArrayList; 88 89public class ConversationItemView extends View implements SwipeableItemView, ToggleableItem, 90 InvalidateCallback { 91 // Timer. 92 private static int sLayoutCount = 0; 93 private static Timer sTimer; // Create the sTimer here if you need to do 94 // perf analysis. 95 private static final int PERF_LAYOUT_ITERATIONS = 50; 96 private static final String PERF_TAG_LAYOUT = "CCHV.layout"; 97 private static final String PERF_TAG_CALCULATE_TEXTS_BITMAPS = "CCHV.txtsbmps"; 98 private static final String PERF_TAG_CALCULATE_SENDER_SUBJECT = "CCHV.sendersubj"; 99 private static final String PERF_TAG_CALCULATE_FOLDERS = "CCHV.folders"; 100 private static final String PERF_TAG_CALCULATE_COORDINATES = "CCHV.coordinates"; 101 private static final String LOG_TAG = LogTag.getLogTag(); 102 103 // Static bitmaps. 104 private static Bitmap STAR_OFF; 105 private static Bitmap STAR_ON; 106 private static Bitmap ATTACHMENT; 107 private static Bitmap ONLY_TO_ME; 108 private static Bitmap TO_ME_AND_OTHERS; 109 private static Bitmap IMPORTANT_ONLY_TO_ME; 110 private static Bitmap IMPORTANT_TO_ME_AND_OTHERS; 111 private static Bitmap IMPORTANT_TO_OTHERS; 112 private static Bitmap STATE_REPLIED; 113 private static Bitmap STATE_FORWARDED; 114 private static Bitmap STATE_REPLIED_AND_FORWARDED; 115 private static Bitmap STATE_CALENDAR_INVITE; 116 117 private static String sSendersSplitToken; 118 private static String sElidedPaddingToken; 119 120 // Static colors. 121 private static int sActivatedTextColor; 122 private static int sSendersTextColorRead; 123 private static int sSendersTextColorUnread; 124 private static int sDateTextColor; 125 private static int sTouchSlop; 126 @Deprecated 127 private static int sStandardScaledDimen; 128 private static int sShrinkAnimationDuration; 129 private static int sSlideAnimationDuration; 130 private static int sAnimatingBackgroundColor; 131 132 // Static paints. 133 private static TextPaint sPaint = new TextPaint(); 134 private static TextPaint sFoldersPaint = new TextPaint(); 135 136 // Backgrounds for different states. 137 private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>(); 138 139 // Dimensions and coordinates. 140 private int mViewWidth = -1; 141 /** The view mode at which we calculated mViewWidth previously. */ 142 private int mPreviousMode; 143 private int mDateX; 144 private int mPaperclipX; 145 private int mSendersWidth; 146 147 /** Whether we're running under test mode. */ 148 private boolean mTesting = false; 149 /** Whether we are on a tablet device or not */ 150 private final boolean mTabletDevice; 151 152 @VisibleForTesting 153 ConversationItemViewCoordinates mCoordinates; 154 155 private ConversationItemViewCoordinates.Config mConfig; 156 157 private final Context mContext; 158 159 public ConversationItemViewModel mHeader; 160 private boolean mDownEvent; 161 private boolean mSelected = false; 162 private ConversationSelectionSet mSelectedConversationSet; 163 private Folder mDisplayedFolder; 164 private boolean mStarEnabled; 165 private boolean mSwipeEnabled; 166 private int mLastTouchX; 167 private int mLastTouchY; 168 private AnimatedAdapter mAdapter; 169 private float mAnimatedHeightFraction = 1.0f; 170 private final String mAccount; 171 private ControllableActivity mActivity; 172 private final TextView mSubjectTextView; 173 private final TextView mSendersTextView; 174 private int mGadgetMode; 175 private final DividedImageCanvas mContactImagesHolder; 176 private int mAttachmentPreviewMode; 177 private final DividedImageCanvas mAttachmentPreviewsCanvas; 178 179 private static int sFoldersLeftPadding; 180 private static TextAppearanceSpan sSubjectTextUnreadSpan; 181 private static TextAppearanceSpan sSubjectTextReadSpan; 182 private static ForegroundColorSpan sSnippetTextUnreadSpan; 183 private static ForegroundColorSpan sSnippetTextReadSpan; 184 private static int sScrollSlop; 185 private static CharacterStyle sActivatedTextSpan; 186 private static ContactPhotoManager sContactPhotoManager; 187 private static ContactPhotoManager sAttachmentPreviewsManager; 188 private static final String EMPTY_SNIPPET = ""; 189 190 static { 191 sPaint.setAntiAlias(true); 192 sFoldersPaint.setAntiAlias(true); 193 } 194 195 /** 196 * Handles displaying folders in a conversation header view. 197 */ 198 static class ConversationItemFolderDisplayer extends FolderDisplayer { 199 200 private int mFoldersCount; 201 202 public ConversationItemFolderDisplayer(Context context) { 203 super(context); 204 } 205 206 @Override 207 public void loadConversationFolders(Conversation conv, final Uri ignoreFolderUri, 208 final int ignoreFolderType) { 209 super.loadConversationFolders(conv, ignoreFolderUri, ignoreFolderType); 210 mFoldersCount = mFoldersSortedSet.size(); 211 } 212 213 @Override 214 public void reset() { 215 super.reset(); 216 mFoldersCount = 0; 217 } 218 219 public boolean hasVisibleFolders() { 220 return mFoldersCount > 0; 221 } 222 223 private int measureFolders(int availableSpace, int cellSize) { 224 int totalWidth = 0; 225 boolean firstTime = true; 226 for (Folder f : mFoldersSortedSet) { 227 final String folderString = f.name; 228 int width = (int) sFoldersPaint.measureText(folderString) + cellSize; 229 if (firstTime) { 230 firstTime = false; 231 } else { 232 width += sFoldersLeftPadding; 233 } 234 totalWidth += width; 235 if (totalWidth > availableSpace) { 236 break; 237 } 238 } 239 240 return totalWidth; 241 } 242 243 public void drawFolders(Canvas canvas, ConversationItemViewCoordinates coordinates) { 244 if (mFoldersCount == 0) { 245 return; 246 } 247 final int xMinStart = coordinates.foldersX; 248 final int xEnd = coordinates.foldersXEnd; 249 final int y = coordinates.foldersY; 250 final int height = coordinates.foldersHeight; 251 final int ascent = coordinates.foldersAscent; 252 int textBottomPadding = coordinates.foldersTextBottomPadding; 253 254 sFoldersPaint.setTextSize(coordinates.foldersFontSize); 255 sFoldersPaint.setTypeface(coordinates.foldersTypeface); 256 257 // Initialize space and cell size based on the current mode. 258 int availableSpace = xEnd - xMinStart; 259 int averageWidth = availableSpace / mFoldersCount; 260 int cellSize = coordinates.getFolderCellWidth(); 261 262 // TODO(ath): sFoldersPaint.measureText() is done 3x in this method. stop that. 263 // Extra credit: maybe cache results across items as long as font size doesn't change. 264 265 final int totalWidth = measureFolders(availableSpace, cellSize); 266 int xStart = xEnd - Math.min(availableSpace, totalWidth); 267 final boolean overflow = totalWidth > availableSpace; 268 269 // Second pass to draw folders. 270 int i = 0; 271 for (Folder f : mFoldersSortedSet) { 272 if (availableSpace <= 0) { 273 break; 274 } 275 final String folderString = f.name; 276 final int fgColor = f.getForegroundColor(mDefaultFgColor); 277 final int bgColor = f.getBackgroundColor(mDefaultBgColor); 278 boolean labelTooLong = false; 279 final int textW = (int) sFoldersPaint.measureText(folderString); 280 int width = textW + cellSize + sFoldersLeftPadding; 281 282 if (overflow && width > averageWidth) { 283 if (i < mFoldersCount - 1) { 284 width = averageWidth; 285 } else { 286 // allow the last label to take all remaining space 287 // (and don't let it make room for padding) 288 width = availableSpace + sFoldersLeftPadding; 289 } 290 labelTooLong = true; 291 } 292 293 // TODO (mindyp): how to we get this? 294 final boolean isMuted = false; 295 // labelValues.folderId == 296 // sGmail.getFolderMap(mAccount).getFolderIdIgnored(); 297 298 // Draw the box. 299 sFoldersPaint.setColor(bgColor); 300 sFoldersPaint.setStyle(Paint.Style.FILL); 301 canvas.drawRect(xStart, y, xStart + width - sFoldersLeftPadding, 302 y + height, sFoldersPaint); 303 304 // Draw the text. 305 final int padding = cellSize / 2; 306 sFoldersPaint.setColor(fgColor); 307 sFoldersPaint.setStyle(Paint.Style.FILL); 308 if (labelTooLong) { 309 final int rightBorder = xStart + width - sFoldersLeftPadding - padding; 310 final Shader shader = new LinearGradient(rightBorder - padding, y, rightBorder, 311 y, fgColor, Utils.getTransparentColor(fgColor), Shader.TileMode.CLAMP); 312 sFoldersPaint.setShader(shader); 313 } 314 canvas.drawText(folderString, xStart + padding, y + height - textBottomPadding, 315 sFoldersPaint); 316 if (labelTooLong) { 317 sFoldersPaint.setShader(null); 318 } 319 320 availableSpace -= width; 321 xStart += width; 322 i++; 323 } 324 } 325 } 326 327 /** 328 * Helpers function to align an element in the center of a space. 329 */ 330 private static int getPadding(int space, int length) { 331 return (space - length) / 2; 332 } 333 334 public ConversationItemView(Context context, String account) { 335 super(context); 336 setClickable(true); 337 setLongClickable(true); 338 mContext = context.getApplicationContext(); 339 final Resources res = mContext.getResources(); 340 mTabletDevice = Utils.useTabletUI(res); 341 mAccount = account; 342 343 if (STAR_OFF == null) { 344 // Initialize static bitmaps. 345 STAR_OFF = BitmapFactory.decodeResource(res, 346 R.drawable.btn_star_off_normal_email_holo_light); 347 STAR_ON = BitmapFactory.decodeResource(res, 348 R.drawable.btn_star_on_normal_email_holo_light); 349 ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attachment_holo_light); 350 ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double); 351 TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single); 352 IMPORTANT_ONLY_TO_ME = BitmapFactory.decodeResource(res, 353 R.drawable.ic_email_caret_double_important_unread); 354 IMPORTANT_TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, 355 R.drawable.ic_email_caret_single_important_unread); 356 IMPORTANT_TO_OTHERS = BitmapFactory.decodeResource(res, 357 R.drawable.ic_email_caret_none_important_unread); 358 STATE_REPLIED = 359 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_holo_light); 360 STATE_FORWARDED = 361 BitmapFactory.decodeResource(res, R.drawable.ic_badge_forward_holo_light); 362 STATE_REPLIED_AND_FORWARDED = 363 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_forward_holo_light); 364 STATE_CALENDAR_INVITE = 365 BitmapFactory.decodeResource(res, R.drawable.ic_badge_invite_holo_light); 366 367 // Initialize colors. 368 sActivatedTextColor = res.getColor(android.R.color.white); 369 sActivatedTextSpan = CharacterStyle.wrap(new ForegroundColorSpan(sActivatedTextColor)); 370 sSendersTextColorRead = res.getColor(R.color.senders_text_color_read); 371 sSendersTextColorUnread = res.getColor(R.color.senders_text_color_unread); 372 sSubjectTextUnreadSpan = new TextAppearanceSpan(mContext, 373 R.style.SubjectAppearanceUnreadStyle); 374 sSubjectTextReadSpan = new TextAppearanceSpan(mContext, 375 R.style.SubjectAppearanceReadStyle); 376 sSnippetTextUnreadSpan = 377 new ForegroundColorSpan(res.getColor(R.color.snippet_text_color_unread)); 378 sSnippetTextReadSpan = 379 new ForegroundColorSpan(res.getColor(R.color.snippet_text_color_read)); 380 sDateTextColor = res.getColor(R.color.date_text_color); 381 sTouchSlop = res.getDimensionPixelSize(R.dimen.touch_slop); 382 sStandardScaledDimen = res.getDimensionPixelSize(R.dimen.standard_scaled_dimen); 383 sShrinkAnimationDuration = res.getInteger(R.integer.shrink_animation_duration); 384 sSlideAnimationDuration = res.getInteger(R.integer.slide_animation_duration); 385 // Initialize static color. 386 sSendersSplitToken = res.getString(R.string.senders_split_token); 387 sElidedPaddingToken = res.getString(R.string.elided_padding_token); 388 sAnimatingBackgroundColor = res.getColor(R.color.animating_item_background_color); 389 sScrollSlop = res.getInteger(R.integer.swipeScrollSlop); 390 sFoldersLeftPadding = res.getDimensionPixelOffset(R.dimen.folders_left_padding); 391 sContactPhotoManager = ContactPhotoManager.createContactPhotoManager(context); 392 sAttachmentPreviewsManager = ContactPhotoManager.createContactPhotoManager(context); 393 } 394 395 mSendersTextView = new TextView(mContext); 396 mSendersTextView.setEllipsize(TextUtils.TruncateAt.END); 397 mSendersTextView.setIncludeFontPadding(false); 398 399 mSubjectTextView = new TextView(mContext); 400 mSubjectTextView.setEllipsize(TextUtils.TruncateAt.END); 401 mSubjectTextView.setIncludeFontPadding(false); 402 403 mContactImagesHolder = new DividedImageCanvas(context, new InvalidateCallback() { 404 @Override 405 public void invalidate() { 406 if (mCoordinates == null) { 407 return; 408 } 409 ConversationItemView.this.invalidate(mCoordinates.contactImagesX, 410 mCoordinates.contactImagesY, 411 mCoordinates.contactImagesX + mCoordinates.contactImagesWidth, 412 mCoordinates.contactImagesY + mCoordinates.contactImagesHeight); 413 } 414 }); 415 mAttachmentPreviewsCanvas = new DividedImageCanvas(context, this); 416 } 417 418 public void bind(Cursor cursor, ControllableActivity activity, ConversationSelectionSet set, 419 Folder folder, int checkboxOrSenderImage, boolean swipeEnabled, 420 boolean priorityArrowEnabled, AnimatedAdapter adapter) { 421 bind(ConversationItemViewModel.forCursor(mAccount, cursor), activity, set, folder, 422 checkboxOrSenderImage, swipeEnabled, priorityArrowEnabled, adapter); 423 } 424 425 public void bind(Conversation conversation, ControllableActivity activity, 426 ConversationSelectionSet set, Folder folder, int checkboxOrSenderImage, 427 boolean swipeEnabled, boolean priorityArrowEnabled, AnimatedAdapter adapter) { 428 bind(ConversationItemViewModel.forConversation(mAccount, conversation), activity, set, 429 folder, checkboxOrSenderImage, swipeEnabled, priorityArrowEnabled, adapter); 430 } 431 432 private void bind(ConversationItemViewModel header, ControllableActivity activity, 433 ConversationSelectionSet set, Folder folder, int checkboxOrSenderImage, 434 boolean swipeEnabled, boolean priorityArrowEnabled, AnimatedAdapter adapter) { 435 // If this was previously bound to a conversation, remove any contact 436 // photo manager requests. 437 // TODO:MARKWEI attachment previews 438 if (mHeader != null) { 439 final ArrayList<String> divisionIds = mContactImagesHolder.getDivisionIds(); 440 if (divisionIds != null) { 441 mContactImagesHolder.reset(); 442 for (int pos = 0; pos < divisionIds.size(); pos++) { 443 sContactPhotoManager.removePhoto(DividedImageCanvas.generateHash( 444 mContactImagesHolder, pos, divisionIds.get(pos))); 445 } 446 } 447 } 448 mCoordinates = null; 449 mHeader = header; 450 mActivity = activity; 451 mSelectedConversationSet = set; 452 mDisplayedFolder = folder; 453 mStarEnabled = folder != null && !folder.isTrash(); 454 mSwipeEnabled = swipeEnabled; 455 mAdapter = adapter; 456 if (mHeader.conversation.getAttachmentsCount() == 0) { 457 mAttachmentPreviewMode = ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_NONE; 458 } else { 459 mAttachmentPreviewMode = mHeader.conversation.read ? 460 ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_SHORT 461 : ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_TALL; 462 } 463 464 if (checkboxOrSenderImage == ConversationListIcon.SENDER_IMAGE) { 465 mGadgetMode = ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO; 466 } else { 467 mGadgetMode = ConversationItemViewCoordinates.GADGET_NONE; 468 } 469 470 // Initialize folder displayer. 471 if (mHeader.folderDisplayer == null) { 472 mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext); 473 } else { 474 mHeader.folderDisplayer.reset(); 475 } 476 477 final int ignoreFolderType; 478 if (mDisplayedFolder.isInbox()) { 479 ignoreFolderType = FolderType.INBOX; 480 } else { 481 ignoreFolderType = -1; 482 } 483 484 mHeader.folderDisplayer.loadConversationFolders(mHeader.conversation, mDisplayedFolder.uri, 485 ignoreFolderType); 486 487 mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext, 488 mHeader.conversation.dateMs); 489 490 mConfig = new ConversationItemViewCoordinates.Config() 491 .withGadget(mGadgetMode) 492 .withAttachmentPreviews(mAttachmentPreviewMode); 493 if (header.folderDisplayer.hasVisibleFolders()) { 494 mConfig.showFolders(); 495 } 496 if (header.hasBeenForwarded || header.hasBeenRepliedTo || header.isInvite) { 497 mConfig.showReplyState(); 498 } 499 if (mHeader.conversation.color != 0) { 500 mConfig.showColorBlock(); 501 } 502 // Personal level. 503 mHeader.personalLevelBitmap = null; 504 if (true) { // TODO: hook this up to a setting 505 final int personalLevel = mHeader.conversation.personalLevel; 506 final boolean isImportant = 507 mHeader.conversation.priority == UIProvider.ConversationPriority.IMPORTANT; 508 final boolean useImportantMarkers = isImportant && priorityArrowEnabled; 509 510 if (personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) { 511 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME 512 : ONLY_TO_ME; 513 } else if (personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) { 514 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS 515 : TO_ME_AND_OTHERS; 516 } else if (useImportantMarkers) { 517 mHeader.personalLevelBitmap = IMPORTANT_TO_OTHERS; 518 } 519 } 520 if (mHeader.personalLevelBitmap != null) { 521 mConfig.showPersonalIndicator(); 522 } 523 524 setContentDescription(); 525 requestLayout(); 526 } 527 528 /** 529 * Get the Conversation object associated with this view. 530 */ 531 public Conversation getConversation() { 532 return mHeader.conversation; 533 } 534 535 private static void startTimer(String tag) { 536 if (sTimer != null) { 537 sTimer.start(tag); 538 } 539 } 540 541 private static void pauseTimer(String tag) { 542 if (sTimer != null) { 543 sTimer.pause(tag); 544 } 545 } 546 547 @Override 548 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 549 final int wSize = MeasureSpec.getSize(widthMeasureSpec); 550 551 final int currentMode = mActivity.getViewMode().getMode(); 552 if (wSize != mViewWidth || mPreviousMode != currentMode) { 553 mViewWidth = wSize; 554 mPreviousMode = currentMode; 555 } 556 mHeader.viewWidth = mViewWidth; 557 558 mConfig.updateWidth(wSize).setViewMode(currentMode); 559 560 Resources res = getResources(); 561 mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen); 562 if (mHeader.standardScaledDimen != sStandardScaledDimen) { 563 // Large Text has been toggle on/off. Update the static dimens. 564 sStandardScaledDimen = mHeader.standardScaledDimen; 565 ConversationItemViewCoordinates.refreshConversationDimens(mContext); 566 } 567 568 mCoordinates = ConversationItemViewCoordinates.forConfig(mContext, mConfig, 569 mAdapter.getCoordinatesCache()); 570 571 final int h = (mAnimatedHeightFraction != 1.0f) ? 572 Math.round(mAnimatedHeightFraction * mCoordinates.height) : mCoordinates.height; 573 setMeasuredDimension(mConfig.getWidth(), h); 574 } 575 576 @Override 577 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 578 startTimer(PERF_TAG_LAYOUT); 579 580 super.onLayout(changed, left, top, right, bottom); 581 582 calculateTextsAndBitmaps(); 583 calculateCoordinates(); 584 585 // Subject. 586 createSubject(mHeader.unread); 587 588 if (!mHeader.isLayoutValid(mContext)) { 589 setContentDescription(); 590 } 591 mHeader.validate(mContext); 592 593 pauseTimer(PERF_TAG_LAYOUT); 594 if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) { 595 sTimer.dumpResults(); 596 sTimer = new Timer(); 597 sLayoutCount = 0; 598 } 599 } 600 601 private void setContentDescription() { 602 if (mActivity.isAccessibilityEnabled()) { 603 mHeader.resetContentDescription(); 604 setContentDescription(mHeader.getContentDescription(mContext)); 605 } 606 } 607 608 @Override 609 public void setBackgroundResource(int resourceId) { 610 Drawable drawable = mBackgrounds.get(resourceId); 611 if (drawable == null) { 612 drawable = getResources().getDrawable(resourceId); 613 mBackgrounds.put(resourceId, drawable); 614 } 615 if (getBackground() != drawable) { 616 super.setBackgroundDrawable(drawable); 617 } 618 } 619 620 private void calculateTextsAndBitmaps() { 621 startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 622 623 if (mSelectedConversationSet != null) { 624 mSelected = mSelectedConversationSet.contains(mHeader.conversation); 625 } 626 mHeader.gadgetMode = mGadgetMode; 627 628 final boolean isUnread = mHeader.unread; 629 updateBackground(isUnread); 630 631 mHeader.sendersDisplayText = new SpannableStringBuilder(); 632 mHeader.styledSendersString = new SpannableStringBuilder(); 633 634 // Parse senders fragments. 635 if (mHeader.conversation.conversationInfo != null) { 636 // This is Gmail 637 Context context = getContext(); 638 mHeader.messageInfoString = SendersView 639 .createMessageInfo(context, mHeader.conversation, true); 640 int maxChars = ConversationItemViewCoordinates.getSendersLength(context, 641 mCoordinates.getMode(), mHeader.conversation.hasAttachments); 642 mHeader.displayableSenderEmails = new ArrayList<String>(); 643 mHeader.displayableSenderNames = new ArrayList<String>(); 644 mHeader.styledSenders = new ArrayList<SpannableString>(); 645 SendersView.format(context, mHeader.conversation.conversationInfo, 646 mHeader.messageInfoString.toString(), maxChars, mHeader.styledSenders, 647 mHeader.displayableSenderNames, mHeader.displayableSenderEmails, mAccount, 648 true); 649 // If we have displayable senders, load their thumbnails 650 loadSenderImages(); 651 } else { 652 // This is Email 653 SendersView.formatSenders(mHeader, getContext(), true); 654 if (mHeader.conversation.senders != null) { 655 mHeader.displayableSenderEmails = new ArrayList<String>(); 656 mHeader.displayableSenderEmails.add(mHeader.conversation.senders); 657 mHeader.displayableSenderNames = new ArrayList<String>(); 658 // Does Email have display name for sender? 659 mHeader.displayableSenderNames.add(mHeader.conversation.senders); 660 loadSenderImages(); 661 } 662 } 663 664 if (mAttachmentPreviewMode != ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_NONE) { 665 loadAttachmentPreviews(); 666 } 667 668 if (mHeader.isLayoutValid(mContext)) { 669 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 670 return; 671 } 672 startTimer(PERF_TAG_CALCULATE_FOLDERS); 673 674 675 pauseTimer(PERF_TAG_CALCULATE_FOLDERS); 676 677 // Paper clip icon. 678 mHeader.paperclip = null; 679 if (mHeader.conversation.hasAttachments) { 680 mHeader.paperclip = ATTACHMENT; 681 } 682 683 startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 684 685 pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 686 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 687 } 688 689 // FIXME(ath): maybe move this to bind(). the only dependency on layout is on tile W/H, which 690 // is immutable. 691 private void loadSenderImages() { 692 if (mGadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO 693 && mHeader.displayableSenderEmails != null 694 && mHeader.displayableSenderEmails.size() > 0) { 695 if (mCoordinates.contactImagesWidth <= 0 || mCoordinates.contactImagesHeight <= 0) { 696 LogUtils.w(LOG_TAG, 697 "Contact image width(%d) or height(%d) is 0 for mode: (%d).", 698 mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight, 699 mCoordinates.getMode()); 700 return; 701 } 702 mContactImagesHolder.setDimensions(mCoordinates.contactImagesWidth, 703 mCoordinates.contactImagesHeight); 704 mContactImagesHolder.setDivisionIds(mHeader.displayableSenderEmails); 705 int size = mHeader.displayableSenderEmails.size(); 706 String emailAddress; 707 for (int i = 0; i < DividedImageCanvas.MAX_DIVISIONS && i < size; i++) { 708 emailAddress = mHeader.displayableSenderEmails.get(i); 709 PhotoIdentifier photoIdentifier = new ContactIdentifier( 710 mHeader.displayableSenderNames.get(i), emailAddress, i); 711 sContactPhotoManager.loadThumbnail(photoIdentifier, mContactImagesHolder); 712 } 713 } 714 } 715 716 private void loadAttachmentPreviews() { 717 if (mAttachmentPreviewMode != ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_NONE) { 718 final int attachmentPreviewsHeight = ConversationItemViewCoordinates 719 .getAttachmentPreviewsHeight(mContext, mAttachmentPreviewMode); 720 if (mCoordinates.attachmentPreviewsWidth <= 0 || attachmentPreviewsHeight <= 0) { 721 LogUtils.w(LOG_TAG, 722 "Attachment preview width(%d) or height(%d) is 0 for mode: (%d,%d).", 723 mCoordinates.attachmentPreviewsWidth, attachmentPreviewsHeight, 724 mCoordinates.getMode(), mAttachmentPreviewMode); 725 return; 726 } 727 mAttachmentPreviewsCanvas.setDimensions(mCoordinates.attachmentPreviewsWidth, 728 attachmentPreviewsHeight); 729 ArrayList<String> attachments = mHeader.conversation.getAttachments(); 730 mAttachmentPreviewsCanvas.setDivisionIds(attachments); 731 int size = attachments.size(); 732 for (int i = 0; i < DividedImageCanvas.MAX_DIVISIONS && i < size; i++) { 733 String attachment = attachments.get(i); 734 PhotoIdentifier photoIdentifier = new ContactIdentifier( 735 attachment, attachment, i); 736 sAttachmentPreviewsManager.loadThumbnail( 737 photoIdentifier, mAttachmentPreviewsCanvas); 738 } 739 } 740 } 741 742 private static int makeExactSpecForSize(int size) { 743 return MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY); 744 } 745 746 private static void layoutViewExactly(View v, int w, int h) { 747 v.measure(makeExactSpecForSize(w), makeExactSpecForSize(h)); 748 v.layout(0, 0, w, h); 749 } 750 751 private void layoutSenders() { 752 if (mHeader.styledSendersString != null) { 753 if (isActivated() && showActivatedText()) { 754 mHeader.styledSendersString.setSpan(sActivatedTextSpan, 0, 755 mHeader.styledMessageInfoStringOffset, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 756 } else { 757 mHeader.styledSendersString.removeSpan(sActivatedTextSpan); 758 } 759 760 final int w = mSendersWidth; 761 final int h = mCoordinates.sendersHeight; 762 mSendersTextView.setLayoutParams(new ViewGroup.LayoutParams(w, h)); 763 mSendersTextView.setMaxLines(mCoordinates.sendersLineCount); 764 mSendersTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.sendersFontSize); 765 layoutViewExactly(mSendersTextView, w, h); 766 767 mSendersTextView.setText(mHeader.styledSendersString); 768 } 769 } 770 771 private void createSubject(final boolean isUnread) { 772 final String subject = filterTag(mHeader.conversation.subject); 773 final String snippet = mHeader.conversation.getSnippet(); 774 final SpannableStringBuilder displayedStringBuilder = new SpannableStringBuilder( 775 Conversation.getSubjectAndSnippetForDisplay(mContext, subject, snippet)); 776 777 // since spans affect text metrics, add spans to the string before measure/layout or fancy 778 // ellipsizing 779 final int subjectTextLength = (subject != null) ? subject.length() : 0; 780 if (!TextUtils.isEmpty(subject)) { 781 displayedStringBuilder.setSpan(TextAppearanceSpan.wrap( 782 isUnread ? sSubjectTextUnreadSpan : sSubjectTextReadSpan), 0, subjectTextLength, 783 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 784 } 785 if (!TextUtils.isEmpty(snippet)) { 786 final int startOffset = subjectTextLength; 787 // Start after the end of the subject text; since the subject may be 788 // "" or null, this could start at the 0th character in the subjectText string 789 displayedStringBuilder.setSpan(ForegroundColorSpan.wrap( 790 isUnread ? sSnippetTextUnreadSpan : sSnippetTextReadSpan), startOffset, 791 displayedStringBuilder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 792 } 793 if (isActivated() && showActivatedText()) { 794 displayedStringBuilder.setSpan(sActivatedTextSpan, 0, displayedStringBuilder.length(), 795 Spannable.SPAN_INCLUSIVE_INCLUSIVE); 796 } 797 798 final int subjectWidth = mCoordinates.subjectWidth; 799 final int subjectHeight = mCoordinates.subjectHeight; 800 mSubjectTextView.setLayoutParams(new ViewGroup.LayoutParams(subjectWidth, subjectHeight)); 801 mSubjectTextView.setMaxLines(mCoordinates.subjectLineCount); 802 mSubjectTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.subjectFontSize); 803 layoutViewExactly(mSubjectTextView, subjectWidth, subjectHeight); 804 805 mSubjectTextView.setText(displayedStringBuilder); 806 } 807 808 /** 809 * Returns the resource for the text color depending on whether the element is activated or not. 810 * @param defaultColor 811 */ 812 private int getFontColor(int defaultColor) { 813 final boolean isBackGroundBlue = isActivated() && showActivatedText(); 814 return isBackGroundBlue ? sActivatedTextColor : defaultColor; 815 } 816 817 private boolean showActivatedText() { 818 // For activated elements in tablet in conversation mode, we show an activated color, since 819 // the background is dark blue for activated versus gray for non-activated. 820 final boolean isListCollapsed = mContext.getResources().getBoolean(R.bool.list_collapsed); 821 return mTabletDevice && !isListCollapsed; 822 } 823 824 private boolean canFitFragment(int width, int line, int fixedWidth) { 825 if (line == mCoordinates.sendersLineCount) { 826 return width + fixedWidth <= mSendersWidth; 827 } else { 828 return width <= mSendersWidth; 829 } 830 } 831 832 private void calculateCoordinates() { 833 startTimer(PERF_TAG_CALCULATE_COORDINATES); 834 835 sPaint.setTextSize(mCoordinates.dateFontSize); 836 sPaint.setTypeface(Typeface.DEFAULT); 837 mDateX = mCoordinates.dateXEnd - (int) sPaint.measureText( 838 mHeader.dateText != null ? mHeader.dateText.toString() : ""); 839 840 mPaperclipX = mDateX - ATTACHMENT.getWidth() - mCoordinates.datePaddingLeft; 841 842 if (mCoordinates.isWide()) { 843 // In wide mode, the end of the senders should align with 844 // the start of the subject and is based on a max width. 845 mSendersWidth = mCoordinates.sendersWidth; 846 } else { 847 // In normal mode, the width is based on where the date/attachment icon start. 848 final int dateAttachmentStart; 849 // Have this end near the paperclip or date, not the folders. 850 if (mHeader.paperclip != null) { 851 dateAttachmentStart = mPaperclipX - mCoordinates.paperclipPaddingLeft; 852 } else { 853 dateAttachmentStart = mDateX - mCoordinates.datePaddingLeft; 854 } 855 mSendersWidth = dateAttachmentStart - mCoordinates.sendersX; 856 } 857 858 // Second pass to layout each fragment. 859 int sendersY = mCoordinates.sendersY - mCoordinates.sendersAscent; 860 861 if (mHeader.styledSenders != null) { 862 ellipsizeStyledSenders(); 863 layoutSenders(); 864 } else { 865 // First pass to calculate width of each fragment. 866 int totalWidth = 0; 867 int fixedWidth = 0; 868 sPaint.setTextSize(mCoordinates.sendersFontSize); 869 sPaint.setTypeface(Typeface.DEFAULT); 870 for (SenderFragment senderFragment : mHeader.senderFragments) { 871 CharacterStyle style = senderFragment.style; 872 int start = senderFragment.start; 873 int end = senderFragment.end; 874 style.updateDrawState(sPaint); 875 senderFragment.width = (int) sPaint.measureText(mHeader.sendersText, start, end); 876 boolean isFixed = senderFragment.isFixed; 877 if (isFixed) { 878 fixedWidth += senderFragment.width; 879 } 880 totalWidth += senderFragment.width; 881 } 882 883 if (!ConversationItemViewCoordinates.displaySendersInline(mCoordinates.getMode())) { 884 sendersY += totalWidth <= mSendersWidth ? mCoordinates.sendersLineHeight / 2 : 0; 885 } 886 if (mSendersWidth < 0) { 887 mSendersWidth = 0; 888 } 889 totalWidth = ellipsize(fixedWidth); 890 mHeader.sendersDisplayLayout = new StaticLayout(mHeader.sendersDisplayText, sPaint, 891 mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 892 } 893 894 sPaint.setTextSize(mCoordinates.sendersFontSize); 895 sPaint.setTypeface(Typeface.DEFAULT); 896 if (mSendersWidth < 0) { 897 mSendersWidth = 0; 898 } 899 900 pauseTimer(PERF_TAG_CALCULATE_COORDINATES); 901 } 902 903 // The rules for displaying ellipsized senders are as follows: 904 // 1) If there is message info (either a COUNT or DRAFT info to display), it MUST be shown 905 // 2) If senders do not fit, ellipsize the last one that does fit, and stop 906 // appending new senders 907 private int ellipsizeStyledSenders() { 908 SpannableStringBuilder builder = new SpannableStringBuilder(); 909 float totalWidth = 0; 910 boolean ellipsize = false; 911 float width; 912 SpannableStringBuilder messageInfoString = mHeader.messageInfoString; 913 if (messageInfoString.length() > 0) { 914 CharacterStyle[] spans = messageInfoString.getSpans(0, messageInfoString.length(), 915 CharacterStyle.class); 916 // There is only 1 character style span; make sure we apply all the 917 // styles to the paint object before measuring. 918 if (spans.length > 0) { 919 spans[0].updateDrawState(sPaint); 920 } 921 // Paint the message info string to see if we lose space. 922 float messageInfoWidth = sPaint.measureText(messageInfoString.toString()); 923 totalWidth += messageInfoWidth; 924 } 925 SpannableString prevSender = null; 926 SpannableString ellipsizedText; 927 for (SpannableString sender : mHeader.styledSenders) { 928 // There may be null sender strings if there were dupes we had to remove. 929 if (sender == null) { 930 continue; 931 } 932 // No more width available, we'll only show fixed fragments. 933 if (ellipsize) { 934 break; 935 } 936 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 937 // There is only 1 character style span. 938 if (spans.length > 0) { 939 spans[0].updateDrawState(sPaint); 940 } 941 // If there are already senders present in this string, we need to 942 // make sure we prepend the dividing token 943 if (SendersView.sElidedString.equals(sender.toString())) { 944 prevSender = sender; 945 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken); 946 } else if (builder.length() > 0 947 && (prevSender == null || !SendersView.sElidedString.equals(prevSender 948 .toString()))) { 949 prevSender = sender; 950 sender = copyStyles(spans, sSendersSplitToken + sender); 951 } else { 952 prevSender = sender; 953 } 954 if (spans.length > 0) { 955 spans[0].updateDrawState(sPaint); 956 } 957 // Measure the width of the current sender and make sure we have space 958 width = (int) sPaint.measureText(sender.toString()); 959 if (width + totalWidth > mSendersWidth) { 960 // The text is too long, new line won't help. We have to 961 // ellipsize text. 962 ellipsize = true; 963 width = mSendersWidth - totalWidth; // ellipsis width? 964 ellipsizedText = copyStyles(spans, 965 TextUtils.ellipsize(sender, sPaint, width, TruncateAt.END)); 966 width = (int) sPaint.measureText(ellipsizedText.toString()); 967 } else { 968 ellipsizedText = null; 969 } 970 totalWidth += width; 971 972 final CharSequence fragmentDisplayText; 973 if (ellipsizedText != null) { 974 fragmentDisplayText = ellipsizedText; 975 } else { 976 fragmentDisplayText = sender; 977 } 978 builder.append(fragmentDisplayText); 979 } 980 mHeader.styledMessageInfoStringOffset = builder.length(); 981 if (messageInfoString != null) { 982 builder.append(messageInfoString); 983 } 984 mHeader.styledSendersString = builder; 985 return (int)totalWidth; 986 } 987 988 private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 989 SpannableString s = new SpannableString(newText); 990 if (spans != null && spans.length > 0) { 991 s.setSpan(spans[0], 0, s.length(), 0); 992 } 993 return s; 994 } 995 996 private int ellipsize(int fixedWidth) { 997 int totalWidth = 0; 998 int currentLine = 1; 999 boolean ellipsize = false; 1000 for (SenderFragment senderFragment : mHeader.senderFragments) { 1001 CharacterStyle style = senderFragment.style; 1002 int start = senderFragment.start; 1003 int end = senderFragment.end; 1004 int width = senderFragment.width; 1005 boolean isFixed = senderFragment.isFixed; 1006 style.updateDrawState(sPaint); 1007 1008 // No more width available, we'll only show fixed fragments. 1009 if (ellipsize && !isFixed) { 1010 senderFragment.shouldDisplay = false; 1011 continue; 1012 } 1013 1014 // New line and ellipsize text if needed. 1015 senderFragment.ellipsizedText = null; 1016 if (isFixed) { 1017 fixedWidth -= width; 1018 } 1019 if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) { 1020 // The text is too long, new line won't help. We have to 1021 // ellipsize text. 1022 if (totalWidth == 0) { 1023 ellipsize = true; 1024 } else { 1025 // New line. 1026 if (currentLine < mCoordinates.sendersLineCount) { 1027 currentLine++; 1028 totalWidth = 0; 1029 // The text is still too long, we have to ellipsize 1030 // text. 1031 if (totalWidth + width > mSendersWidth) { 1032 ellipsize = true; 1033 } 1034 } else { 1035 ellipsize = true; 1036 } 1037 } 1038 1039 if (ellipsize) { 1040 width = mSendersWidth - totalWidth; 1041 // No more new line, we have to reserve width for fixed 1042 // fragments. 1043 if (currentLine == mCoordinates.sendersLineCount) { 1044 width -= fixedWidth; 1045 } 1046 senderFragment.ellipsizedText = TextUtils.ellipsize( 1047 mHeader.sendersText.substring(start, end), sPaint, width, 1048 TruncateAt.END).toString(); 1049 width = (int) sPaint.measureText(senderFragment.ellipsizedText); 1050 } 1051 } 1052 senderFragment.shouldDisplay = true; 1053 totalWidth += width; 1054 1055 final CharSequence fragmentDisplayText; 1056 if (senderFragment.ellipsizedText != null) { 1057 fragmentDisplayText = senderFragment.ellipsizedText; 1058 } else { 1059 fragmentDisplayText = mHeader.sendersText.substring(start, end); 1060 } 1061 final int spanStart = mHeader.sendersDisplayText.length(); 1062 mHeader.sendersDisplayText.append(fragmentDisplayText); 1063 mHeader.sendersDisplayText.setSpan(senderFragment.style, spanStart, 1064 mHeader.sendersDisplayText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1065 } 1066 return totalWidth; 1067 } 1068 1069 /** 1070 * If the subject contains the tag of a mailing-list (text surrounded with 1071 * []), return the subject with that tag ellipsized, e.g. 1072 * "[android-gmail-team] Hello" -> "[andr...] Hello" 1073 */ 1074 private String filterTag(String subject) { 1075 String result = subject; 1076 String formatString = getContext().getResources().getString(R.string.filtered_tag); 1077 if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') { 1078 int end = subject.indexOf(']'); 1079 if (end > 0) { 1080 String tag = subject.substring(1, end); 1081 result = String.format(formatString, Utils.ellipsize(tag, 7), 1082 subject.substring(end + 1)); 1083 } 1084 } 1085 return result; 1086 } 1087 1088 @Override 1089 protected void onDraw(Canvas canvas) { 1090 // Contact photo 1091 if (mGadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO) { 1092 canvas.save(); 1093 drawContactImages(canvas); 1094 canvas.restore(); 1095 } 1096 // Senders. 1097 boolean isUnread = mHeader.unread; 1098 // Old style senders; apply text colors/ sizes/ styling. 1099 canvas.save(); 1100 if (mHeader.sendersDisplayLayout != null) { 1101 sPaint.setTextSize(mCoordinates.sendersFontSize); 1102 sPaint.setTypeface(SendersView.getTypeface(isUnread)); 1103 sPaint.setColor(getFontColor(isUnread ? 1104 sSendersTextColorUnread : sSendersTextColorRead)); 1105 canvas.translate(mCoordinates.sendersX, mCoordinates.sendersY 1106 + mHeader.sendersDisplayLayout.getTopPadding()); 1107 mHeader.sendersDisplayLayout.draw(canvas); 1108 } else { 1109 drawSenders(canvas); 1110 } 1111 canvas.restore(); 1112 1113 1114 // Subject. 1115 sPaint.setTypeface(Typeface.DEFAULT); 1116 canvas.save(); 1117 drawSubject(canvas); 1118 canvas.restore(); 1119 1120 // Folders. 1121 if (mConfig.areFoldersVisible()) { 1122 mHeader.folderDisplayer.drawFolders(canvas, mCoordinates); 1123 } 1124 1125 // If this folder has a color (combined view/Email), show it here 1126 if (mConfig.isColorBlockVisible()) { 1127 sFoldersPaint.setColor(mHeader.conversation.color); 1128 sFoldersPaint.setStyle(Paint.Style.FILL); 1129 canvas.drawRect(mCoordinates.colorBlockX, mCoordinates.colorBlockY, 1130 mCoordinates.colorBlockX + mCoordinates.colorBlockWidth, 1131 mCoordinates.colorBlockY + mCoordinates.colorBlockHeight, sFoldersPaint); 1132 } 1133 1134 // Draw the reply state. Draw nothing if neither replied nor forwarded. 1135 if (mConfig.isReplyStateVisible()) { 1136 if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) { 1137 canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX, 1138 mCoordinates.replyStateY, null); 1139 } else if (mHeader.hasBeenRepliedTo) { 1140 canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX, 1141 mCoordinates.replyStateY, null); 1142 } else if (mHeader.hasBeenForwarded) { 1143 canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX, 1144 mCoordinates.replyStateY, null); 1145 } else if (mHeader.isInvite) { 1146 canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX, 1147 mCoordinates.replyStateY, null); 1148 } 1149 } 1150 1151 if (mConfig.isPersonalIndicatorVisible()) { 1152 canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalIndicatorX, 1153 mCoordinates.personalIndicatorY, null); 1154 } 1155 1156 // Date. 1157 sPaint.setTextSize(mCoordinates.dateFontSize); 1158 sPaint.setTypeface(Typeface.DEFAULT); 1159 sPaint.setColor(sDateTextColor); 1160 drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateYBaseline, 1161 sPaint); 1162 1163 // Paper clip icon. 1164 if (mHeader.paperclip != null) { 1165 canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint); 1166 } 1167 1168 if (mStarEnabled) { 1169 // Star. 1170 canvas.drawBitmap(getStarBitmap(), mCoordinates.starX, mCoordinates.starY, sPaint); 1171 } 1172 1173 // Attachment previews 1174 if (mAttachmentPreviewMode != ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_NONE) { 1175 canvas.save(); 1176 drawAttachmentPreviews(canvas); 1177 canvas.restore(); 1178 } 1179 } 1180 1181 private void drawContactImages(Canvas canvas) { 1182 canvas.translate(mCoordinates.contactImagesX, mCoordinates.contactImagesY); 1183 mContactImagesHolder.draw(canvas); 1184 } 1185 1186 private void drawAttachmentPreviews(Canvas canvas) { 1187 canvas.translate(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY); 1188 mAttachmentPreviewsCanvas.draw(canvas); 1189 } 1190 1191 private void drawSubject(Canvas canvas) { 1192 canvas.translate(mCoordinates.subjectX, mCoordinates.subjectY); 1193 mSubjectTextView.draw(canvas); 1194 } 1195 1196 private void drawSenders(Canvas canvas) { 1197 canvas.translate(mCoordinates.sendersX, mCoordinates.sendersY); 1198 mSendersTextView.draw(canvas); 1199 } 1200 1201 private Bitmap getStarBitmap() { 1202 return mHeader.conversation.starred ? STAR_ON : STAR_OFF; 1203 } 1204 1205 private static void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) { 1206 canvas.drawText(s, 0, s.length(), x, y, paint); 1207 } 1208 1209 /** 1210 * Set the background for this item based on: 1211 * 1. Read / Unread (unread messages have a lighter background) 1212 * 2. Tablet / Phone 1213 * 3. Checkbox checked / Unchecked (controls CAB color for item) 1214 * 4. Activated / Not activated (controls the blue highlight on tablet) 1215 * @param isUnread 1216 */ 1217 private void updateBackground(boolean isUnread) { 1218 final boolean isListOnTablet = mTabletDevice && mActivity.getViewMode().isListMode(); 1219 final int background; 1220 if (isUnread) { 1221 if (isListOnTablet) { 1222 if (mSelected) { 1223 background = R.drawable.list_conversation_wide_unread_selected_holo; 1224 } else { 1225 background = R.drawable.conversation_wide_unread_selector; 1226 } 1227 } else { 1228 if (mSelected) { 1229 background = getCheckedActivatedBackground(); 1230 } else { 1231 background = R.drawable.conversation_unread_selector; 1232 } 1233 } 1234 } else { 1235 if (isListOnTablet) { 1236 if (mSelected) { 1237 background = R.drawable.list_conversation_wide_read_selected_holo; 1238 } else { 1239 background = R.drawable.conversation_wide_read_selector; 1240 } 1241 } else { 1242 if (mSelected) { 1243 background = getCheckedActivatedBackground(); 1244 } else { 1245 background = R.drawable.conversation_read_selector; 1246 } 1247 } 1248 } 1249 setBackgroundResource(background); 1250 } 1251 1252 private final int getCheckedActivatedBackground() { 1253 if (isActivated() && mTabletDevice) { 1254 return R.drawable.list_arrow_selected_holo; 1255 } else { 1256 return R.drawable.list_selected_holo; 1257 } 1258 } 1259 1260 /** 1261 * Toggle the check mark on this view and update the conversation or begin 1262 * drag, if drag is enabled. 1263 */ 1264 @Override 1265 public void toggleSelectedStateOrBeginDrag() { 1266 ViewMode mode = mActivity.getViewMode(); 1267 if (mTabletDevice && mode.isListMode()) { 1268 beginDragMode(); 1269 } else { 1270 toggleSelectedState(); 1271 } 1272 } 1273 1274 @Override 1275 public void toggleSelectedState() { 1276 if (mHeader != null && mHeader.conversation != null) { 1277 mSelected = !mSelected; 1278 Conversation conv = mHeader.conversation; 1279 // Set the list position of this item in the conversation 1280 SwipeableListView listView = getListView(); 1281 conv.position = mSelected && listView != null ? listView.getPositionForView(this) 1282 : Conversation.NO_POSITION; 1283 if (mSelectedConversationSet != null) { 1284 mSelectedConversationSet.toggle(conv); 1285 } 1286 if (mSelectedConversationSet.isEmpty()) { 1287 listView.commitDestructiveActions(true); 1288 } 1289 // We update the background after the checked state has changed 1290 // now that we have a selected background asset. Setting the background 1291 // usually waits for a layout pass, but we don't need a full layout, 1292 // just an update to the background. 1293 requestLayout(); 1294 } 1295 } 1296 1297 /** 1298 * Toggle the star on this view and update the conversation. 1299 */ 1300 public void toggleStar() { 1301 mHeader.conversation.starred = !mHeader.conversation.starred; 1302 Bitmap starBitmap = getStarBitmap(); 1303 postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX 1304 + starBitmap.getWidth(), 1305 mCoordinates.starY + starBitmap.getHeight()); 1306 ConversationCursor cursor = (ConversationCursor) mAdapter.getCursor(); 1307 if (cursor != null) { 1308 cursor.updateBoolean(mHeader.conversation, ConversationColumns.STARRED, 1309 mHeader.conversation.starred); 1310 } 1311 } 1312 1313 private boolean isTouchInContactPhoto(float x) { 1314 // Everything before the right edge of contact photo 1315 return (mHeader.gadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO 1316 && x < (mCoordinates.contactImagesX + mCoordinates.contactImagesWidth)); 1317 } 1318 1319 private boolean isTouchInStar(float x, float y) { 1320 // Everything after the star and include a touch slop. 1321 return mStarEnabled && x > mCoordinates.starX - sTouchSlop; 1322 } 1323 1324 @Override 1325 public boolean canChildBeDismissed() { 1326 return true; 1327 } 1328 1329 @Override 1330 public void dismiss() { 1331 SwipeableListView listView = getListView(); 1332 if (listView != null) { 1333 getListView().dismissChild(this); 1334 } 1335 } 1336 1337 private boolean onTouchEventNoSwipe(MotionEvent event) { 1338 boolean handled = false; 1339 1340 int x = (int) event.getX(); 1341 int y = (int) event.getY(); 1342 mLastTouchX = x; 1343 mLastTouchY = y; 1344 switch (event.getAction()) { 1345 case MotionEvent.ACTION_DOWN: 1346 if (isTouchInContactPhoto(x) || isTouchInStar(x, y)) { 1347 mDownEvent = true; 1348 handled = true; 1349 } 1350 break; 1351 1352 case MotionEvent.ACTION_CANCEL: 1353 mDownEvent = false; 1354 break; 1355 1356 case MotionEvent.ACTION_UP: 1357 if (mDownEvent) { 1358 if (isTouchInContactPhoto(x)) { 1359 // Touch on the check mark 1360 toggleSelectedState(); 1361 } else if (isTouchInStar(x, y)) { 1362 // Touch on the star 1363 toggleStar(); 1364 } 1365 handled = true; 1366 } 1367 break; 1368 } 1369 1370 if (!handled) { 1371 handled = super.onTouchEvent(event); 1372 } 1373 1374 return handled; 1375 } 1376 1377 /** 1378 * ConversationItemView is given the first chance to handle touch events. 1379 */ 1380 @Override 1381 public boolean onTouchEvent(MotionEvent event) { 1382 int x = (int) event.getX(); 1383 int y = (int) event.getY(); 1384 mLastTouchX = x; 1385 mLastTouchY = y; 1386 if (!mSwipeEnabled) { 1387 return onTouchEventNoSwipe(event); 1388 } 1389 switch (event.getAction()) { 1390 case MotionEvent.ACTION_DOWN: 1391 if (isTouchInContactPhoto(x) || isTouchInStar(x, y)) { 1392 mDownEvent = true; 1393 return true; 1394 } 1395 break; 1396 case MotionEvent.ACTION_UP: 1397 if (mDownEvent) { 1398 if (isTouchInContactPhoto(x)) { 1399 // Touch on the check mark 1400 mDownEvent = false; 1401 toggleSelectedState(); 1402 return true; 1403 } else if (isTouchInStar(x, y)) { 1404 // Touch on the star 1405 mDownEvent = false; 1406 toggleStar(); 1407 return true; 1408 } 1409 } 1410 break; 1411 } 1412 // Let View try to handle it as well. 1413 boolean handled = super.onTouchEvent(event); 1414 if (event.getAction() == MotionEvent.ACTION_DOWN) { 1415 return true; 1416 } 1417 return handled; 1418 } 1419 1420 @Override 1421 public boolean performClick() { 1422 boolean handled = super.performClick(); 1423 SwipeableListView list = getListView(); 1424 if (list != null && list.getAdapter() != null) { 1425 int pos = list.findConversation(this, mHeader.conversation); 1426 list.performItemClick(this, pos, mHeader.conversation.id); 1427 } 1428 return handled; 1429 } 1430 1431 private SwipeableListView getListView() { 1432 SwipeableListView v = (SwipeableListView) ((SwipeableConversationItemView) getParent()) 1433 .getListView(); 1434 if (v == null) { 1435 v = mAdapter.getListView(); 1436 } 1437 return v; 1438 } 1439 1440 /** 1441 * Reset any state associated with this conversation item view so that it 1442 * can be reused. 1443 */ 1444 public void reset() { 1445 setAlpha(1f); 1446 setTranslationX(0f); 1447 mAnimatedHeightFraction = 1.0f; 1448 } 1449 1450 @SuppressWarnings("deprecation") 1451 @Override 1452 public void setTranslationX(float translationX) { 1453 super.setTranslationX(translationX); 1454 1455 final ViewParent vp = getParent(); 1456 if (vp == null || !(vp instanceof SwipeableConversationItemView)) { 1457 LogUtils.w(LOG_TAG, 1458 "CIV.setTranslationX unexpected ConversationItemView parent: %s x=%s", 1459 vp, translationX); 1460 } 1461 1462 // When a list item is being swiped or animated, ensure that the hosting view has a 1463 // background color set. We only enable the background during the X-translation effect to 1464 // reduce overdraw during normal list scrolling. 1465 final SwipeableConversationItemView parent = (SwipeableConversationItemView) vp; 1466 if (translationX != 0f) { 1467 parent.setBackgroundResource(R.color.swiped_bg_color); 1468 } else { 1469 parent.setBackgroundDrawable(null); 1470 } 1471 } 1472 1473 /** 1474 * Grow the height of the item and fade it in when bringing a conversation 1475 * back from a destructive action. 1476 */ 1477 public Animator createSwipeUndoAnimation() { 1478 ObjectAnimator undoAnimator = createTranslateXAnimation(true); 1479 return undoAnimator; 1480 } 1481 1482 /** 1483 * Grow the height of the item and fade it in when bringing a conversation 1484 * back from a destructive action. 1485 */ 1486 public Animator createUndoAnimation() { 1487 ObjectAnimator height = createHeightAnimation(true); 1488 Animator fade = ObjectAnimator.ofFloat(this, "alpha", 0, 1.0f); 1489 fade.setDuration(sShrinkAnimationDuration); 1490 fade.setInterpolator(new DecelerateInterpolator(2.0f)); 1491 AnimatorSet transitionSet = new AnimatorSet(); 1492 transitionSet.playTogether(height, fade); 1493 transitionSet.addListener(new HardwareLayerEnabler(this)); 1494 return transitionSet; 1495 } 1496 1497 /** 1498 * Grow the height of the item and fade it in when bringing a conversation 1499 * back from a destructive action. 1500 */ 1501 public Animator createDestroyWithSwipeAnimation() { 1502 ObjectAnimator slide = createTranslateXAnimation(false); 1503 ObjectAnimator height = createHeightAnimation(false); 1504 AnimatorSet transitionSet = new AnimatorSet(); 1505 transitionSet.playSequentially(slide, height); 1506 return transitionSet; 1507 } 1508 1509 private ObjectAnimator createTranslateXAnimation(boolean show) { 1510 SwipeableListView parent = getListView(); 1511 // If we can't get the parent...we have bigger problems. 1512 int width = parent != null ? parent.getMeasuredWidth() : 0; 1513 final float start = show ? width : 0f; 1514 final float end = show ? 0f : width; 1515 ObjectAnimator slide = ObjectAnimator.ofFloat(this, "translationX", start, end); 1516 slide.setInterpolator(new DecelerateInterpolator(2.0f)); 1517 slide.setDuration(sSlideAnimationDuration); 1518 return slide; 1519 } 1520 1521 public Animator createDestroyAnimation() { 1522 return createHeightAnimation(false); 1523 } 1524 1525 private ObjectAnimator createHeightAnimation(boolean show) { 1526 final float start = show ? 0f : 1.0f; 1527 final float end = show ? 1.0f : 0f; 1528 ObjectAnimator height = ObjectAnimator.ofFloat(this, "animatedHeightFraction", start, end); 1529 height.setInterpolator(new DecelerateInterpolator(2.0f)); 1530 height.setDuration(sShrinkAnimationDuration); 1531 return height; 1532 } 1533 1534 // Used by animator 1535 public void setAnimatedHeightFraction(float height) { 1536 mAnimatedHeightFraction = height; 1537 requestLayout(); 1538 } 1539 1540 @Override 1541 public View getSwipeableView() { 1542 return this; 1543 } 1544 1545 /** 1546 * Begin drag mode. Keep the conversation selected (NOT toggle selection) and start drag. 1547 */ 1548 private void beginDragMode() { 1549 if (mLastTouchX < 0 || mLastTouchY < 0) { 1550 return; 1551 } 1552 // If this is already checked, don't bother unchecking it! 1553 if (!mSelected) { 1554 toggleSelectedState(); 1555 } 1556 1557 // Clip data has form: [conversations_uri, conversationId1, 1558 // maxMessageId1, label1, conversationId2, maxMessageId2, label2, ...] 1559 final int count = mSelectedConversationSet.size(); 1560 String description = Utils.formatPlural(mContext, R.plurals.move_conversation, count); 1561 1562 final ClipData data = ClipData.newUri(mContext.getContentResolver(), description, 1563 Conversation.MOVE_CONVERSATIONS_URI); 1564 for (Conversation conversation : mSelectedConversationSet.values()) { 1565 data.addItem(new Item(String.valueOf(conversation.position))); 1566 } 1567 // Protect against non-existent views: only happens for monkeys 1568 final int width = this.getWidth(); 1569 final int height = this.getHeight(); 1570 final boolean isDimensionNegative = (width < 0) || (height < 0); 1571 if (isDimensionNegative) { 1572 LogUtils.e(LOG_TAG, "ConversationItemView: dimension is negative: " 1573 + "width=%d, height=%d", width, height); 1574 return; 1575 } 1576 mActivity.startDragMode(); 1577 // Start drag mode 1578 startDrag(data, new ShadowBuilder(this, count, mLastTouchX, mLastTouchY), null, 0); 1579 } 1580 1581 /** 1582 * Handles the drag event. 1583 * 1584 * @param event the drag event to be handled 1585 */ 1586 @Override 1587 public boolean onDragEvent(DragEvent event) { 1588 switch (event.getAction()) { 1589 case DragEvent.ACTION_DRAG_ENDED: 1590 mActivity.stopDragMode(); 1591 return true; 1592 } 1593 return false; 1594 } 1595 1596 private class ShadowBuilder extends DragShadowBuilder { 1597 private final Drawable mBackground; 1598 1599 private final View mView; 1600 private final String mDragDesc; 1601 private final int mTouchX; 1602 private final int mTouchY; 1603 private int mDragDescX; 1604 private int mDragDescY; 1605 1606 public ShadowBuilder(View view, int count, int touchX, int touchY) { 1607 super(view); 1608 mView = view; 1609 mBackground = mView.getResources().getDrawable(R.drawable.list_pressed_holo); 1610 mDragDesc = Utils.formatPlural(mView.getContext(), R.plurals.move_conversation, count); 1611 mTouchX = touchX; 1612 mTouchY = touchY; 1613 } 1614 1615 @Override 1616 public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) { 1617 int width = mView.getWidth(); 1618 int height = mView.getHeight(); 1619 mDragDescX = mCoordinates.sendersX; 1620 mDragDescY = getPadding(height, (int) mCoordinates.subjectFontSize) 1621 - mCoordinates.subjectAscent; 1622 shadowSize.set(width, height); 1623 shadowTouchPoint.set(mTouchX, mTouchY); 1624 } 1625 1626 @Override 1627 public void onDrawShadow(Canvas canvas) { 1628 mBackground.setBounds(0, 0, mView.getWidth(), mView.getHeight()); 1629 mBackground.draw(canvas); 1630 sPaint.setTextSize(mCoordinates.subjectFontSize); 1631 canvas.drawText(mDragDesc, mDragDescX, mDragDescY, sPaint); 1632 } 1633 } 1634 1635 @Override 1636 public float getMinAllowScrollDistance() { 1637 return sScrollSlop; 1638 } 1639} 1640