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