ConversationItemView.java revision 68141df2d855af6520a19ff9127f69463d49c3de
1/* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.mail.browse; 19 20import android.animation.Animator; 21import android.animation.Animator.AnimatorListener; 22import android.animation.AnimatorListenerAdapter; 23import android.animation.AnimatorSet; 24import android.animation.ObjectAnimator; 25import android.content.ClipData; 26import android.content.ClipData.Item; 27import android.content.Context; 28import android.content.res.Resources; 29import android.graphics.Bitmap; 30import android.graphics.BitmapFactory; 31import android.graphics.Canvas; 32import android.graphics.Color; 33import android.graphics.LinearGradient; 34import android.graphics.Matrix; 35import android.graphics.Paint; 36import android.graphics.Point; 37import android.graphics.Rect; 38import android.graphics.Shader; 39import android.graphics.Typeface; 40import android.graphics.drawable.Drawable; 41import android.text.Layout.Alignment; 42import android.text.Spannable; 43import android.text.SpannableString; 44import android.text.SpannableStringBuilder; 45import android.text.StaticLayout; 46import android.text.TextPaint; 47import android.text.TextUtils; 48import android.text.TextUtils.TruncateAt; 49import android.text.format.DateUtils; 50import android.text.style.CharacterStyle; 51import android.text.style.ForegroundColorSpan; 52import android.text.style.TextAppearanceSpan; 53import android.text.util.Rfc822Token; 54import android.text.util.Rfc822Tokenizer; 55import android.util.SparseArray; 56import android.util.TypedValue; 57import android.view.DragEvent; 58import android.view.MotionEvent; 59import android.view.View; 60import android.view.ViewGroup; 61import android.view.ViewParent; 62import android.view.animation.DecelerateInterpolator; 63import android.view.animation.LinearInterpolator; 64import android.widget.AbsListView; 65import android.widget.AbsListView.OnScrollListener; 66import android.widget.TextView; 67 68import com.android.mail.R; 69import com.android.mail.R.drawable; 70import com.android.mail.R.integer; 71import com.android.mail.R.string; 72import com.android.mail.analytics.Analytics; 73import com.android.mail.bitmap.AttachmentDrawable; 74import com.android.mail.bitmap.AttachmentGridDrawable; 75import com.android.mail.browse.ConversationItemViewModel.SenderFragment; 76import com.android.mail.perf.Timer; 77import com.android.mail.photomanager.ContactPhotoManager; 78import com.android.mail.photomanager.ContactPhotoManager.ContactIdentifier; 79import com.android.mail.photomanager.PhotoManager.PhotoIdentifier; 80import com.android.mail.providers.Address; 81import com.android.mail.providers.Attachment; 82import com.android.mail.providers.Conversation; 83import com.android.mail.providers.Folder; 84import com.android.mail.providers.UIProvider; 85import com.android.mail.providers.UIProvider.AttachmentRendition; 86import com.android.mail.providers.UIProvider.ConversationColumns; 87import com.android.mail.providers.UIProvider.ConversationListIcon; 88import com.android.mail.providers.UIProvider.FolderType; 89import com.android.mail.ui.AnimatedAdapter; 90import com.android.mail.ui.AnimatedAdapter.ConversationListListener; 91import com.android.mail.ui.ControllableActivity; 92import com.android.mail.ui.ConversationSelectionSet; 93import com.android.mail.ui.DividedImageCanvas; 94import com.android.mail.ui.DividedImageCanvas.InvalidateCallback; 95import com.android.mail.ui.FolderDisplayer; 96import com.android.mail.ui.SwipeableItemView; 97import com.android.mail.ui.SwipeableListView; 98import com.android.mail.ui.ViewMode; 99import com.android.mail.utils.FolderUri; 100import com.android.mail.utils.HardwareLayerEnabler; 101import com.android.mail.utils.LogTag; 102import com.android.mail.utils.LogUtils; 103import com.android.mail.utils.Utils; 104import com.google.common.annotations.VisibleForTesting; 105import com.google.common.collect.Lists; 106 107import java.util.ArrayList; 108import java.util.List; 109 110public class ConversationItemView extends View 111 implements SwipeableItemView, ToggleableItem, InvalidateCallback, OnScrollListener { 112 113 // Timer. 114 private static int sLayoutCount = 0; 115 private static Timer sTimer; // Create the sTimer here if you need to do 116 // perf analysis. 117 private static final int PERF_LAYOUT_ITERATIONS = 50; 118 private static final String PERF_TAG_LAYOUT = "CCHV.layout"; 119 private static final String PERF_TAG_CALCULATE_TEXTS_BITMAPS = "CCHV.txtsbmps"; 120 private static final String PERF_TAG_CALCULATE_SENDER_SUBJECT = "CCHV.sendersubj"; 121 private static final String PERF_TAG_CALCULATE_FOLDERS = "CCHV.folders"; 122 private static final String PERF_TAG_CALCULATE_COORDINATES = "CCHV.coordinates"; 123 private static final String LOG_TAG = LogTag.getLogTag(); 124 125 // Static bitmaps. 126 private static Bitmap STAR_OFF; 127 private static Bitmap STAR_ON; 128 private static Bitmap CHECK; 129 private static Bitmap ATTACHMENT; 130 private static Bitmap ONLY_TO_ME; 131 private static Bitmap TO_ME_AND_OTHERS; 132 private static Bitmap IMPORTANT_ONLY_TO_ME; 133 private static Bitmap IMPORTANT_TO_ME_AND_OTHERS; 134 private static Bitmap IMPORTANT_TO_OTHERS; 135 private static Bitmap STATE_REPLIED; 136 private static Bitmap STATE_FORWARDED; 137 private static Bitmap STATE_REPLIED_AND_FORWARDED; 138 private static Bitmap STATE_CALENDAR_INVITE; 139 private static Bitmap VISIBLE_CONVERSATION_CARET; 140 private static Drawable RIGHT_EDGE_TABLET; 141 private static Drawable PLACEHOLDER; 142 private static Drawable PROGRESS_BAR; 143 144 private static String sSendersSplitToken; 145 private static String sElidedPaddingToken; 146 private static String sOverflowCountFormat; 147 148 // Static colors. 149 private static int sSendersTextColorRead; 150 private static int sSendersTextColorUnread; 151 private static int sDateTextColor; 152 private static int sStarTouchSlop; 153 private static int sSenderImageTouchSlop; 154 private static int sShrinkAnimationDuration; 155 private static int sSlideAnimationDuration; 156 private static int sOverflowCountMax; 157 private static int sCabAnimationDuration; 158 159 // Static paints. 160 private static final TextPaint sPaint = new TextPaint(); 161 private static final TextPaint sFoldersPaint = new TextPaint(); 162 private static final Paint sCheckBackgroundPaint = new Paint(); 163 164 // Backgrounds for different states. 165 private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>(); 166 167 // Dimensions and coordinates. 168 private int mViewWidth = -1; 169 /** The view mode at which we calculated mViewWidth previously. */ 170 private int mPreviousMode; 171 172 private int mInfoIconX; 173 private int mDateX; 174 private int mPaperclipX; 175 private int mSendersWidth; 176 177 /** Whether we are on a tablet device or not */ 178 private final boolean mTabletDevice; 179 /** Whether we are on an expansive tablet */ 180 private final boolean mIsExpansiveTablet; 181 /** When in conversation mode, true if the list is hidden */ 182 private final boolean mListCollapsible; 183 184 @VisibleForTesting 185 ConversationItemViewCoordinates mCoordinates; 186 187 private ConversationItemViewCoordinates.Config mConfig; 188 189 private final Context mContext; 190 191 public ConversationItemViewModel mHeader; 192 private boolean mDownEvent; 193 private boolean mSelected = false; 194 private ConversationSelectionSet mSelectedConversationSet; 195 private Folder mDisplayedFolder; 196 private boolean mStarEnabled; 197 private boolean mSwipeEnabled; 198 private int mLastTouchX; 199 private int mLastTouchY; 200 private AnimatedAdapter mAdapter; 201 private float mAnimatedHeightFraction = 1.0f; 202 private final String mAccount; 203 private ControllableActivity mActivity; 204 private ConversationListListener mConversationListListener; 205 private final TextView mSubjectTextView; 206 private final TextView mSendersTextView; 207 private int mGadgetMode; 208 private boolean mAttachmentPreviewsEnabled; 209 private boolean mParallaxSpeedAlternative; 210 private boolean mParallaxDirectionAlternative; 211 private final DividedImageCanvas mContactImagesHolder; 212 private static ContactPhotoManager sContactPhotoManager; 213 214 private static int sFoldersLeftPadding; 215 private static TextAppearanceSpan sSubjectTextUnreadSpan; 216 private static TextAppearanceSpan sSubjectTextReadSpan; 217 private static ForegroundColorSpan sSnippetTextUnreadSpan; 218 private static ForegroundColorSpan sSnippetTextReadSpan; 219 private static int sScrollSlop; 220 private static CharacterStyle sActivatedTextSpan; 221 222 private final AttachmentGridDrawable mAttachmentsView; 223 224 private final Matrix mPhotoFlipMatrix = new Matrix(); 225 private final Matrix mCheckMatrix = new Matrix(); 226 227 private final CabAnimator mPhotoFlipAnimator; 228 229 /** 230 * The conversation id, if this conversation was selected the last time we were in a selection 231 * mode. This is reset after any animations complete upon exiting the selection mode. 232 */ 233 private long mLastSelectedId = -1; 234 235 /** The resource id of the color to use to override the background. */ 236 private int mBackgroundOverrideResId = -1; 237 /** The bitmap to use, or <code>null</code> for the default */ 238 private Bitmap mPhotoBitmap = null; 239 private Rect mPhotoRect = null; 240 241 /** 242 * A listener for clicks on the various areas of a conversation item. 243 */ 244 public interface ConversationItemAreaClickListener { 245 /** Called when the info icon is clicked. */ 246 void onInfoIconClicked(); 247 248 /** Called when the star is clicked. */ 249 void onStarClicked(); 250 } 251 252 /** If set, it will steal all clicks for which the interface has a click method. */ 253 private ConversationItemAreaClickListener mConversationItemAreaClickListener = null; 254 255 static { 256 sPaint.setAntiAlias(true); 257 sFoldersPaint.setAntiAlias(true); 258 259 sCheckBackgroundPaint.setColor(Color.GRAY); 260 } 261 262 public static void setScrollStateChanged(final int scrollState) { 263 if (sContactPhotoManager == null) { 264 return; 265 } 266 final boolean flinging = scrollState == OnScrollListener.SCROLL_STATE_FLING; 267 268 if (flinging) { 269 sContactPhotoManager.pause(); 270 } else { 271 sContactPhotoManager.resume(); 272 } 273 } 274 275 /** 276 * Handles displaying folders in a conversation header view. 277 */ 278 static class ConversationItemFolderDisplayer extends FolderDisplayer { 279 280 private int mFoldersCount; 281 282 public ConversationItemFolderDisplayer(Context context) { 283 super(context); 284 } 285 286 @Override 287 public void loadConversationFolders(Conversation conv, final FolderUri ignoreFolderUri, 288 final int ignoreFolderType) { 289 super.loadConversationFolders(conv, ignoreFolderUri, ignoreFolderType); 290 mFoldersCount = mFoldersSortedSet.size(); 291 } 292 293 @Override 294 public void reset() { 295 super.reset(); 296 mFoldersCount = 0; 297 } 298 299 public boolean hasVisibleFolders() { 300 return mFoldersCount > 0; 301 } 302 303 private int measureFolders(int availableSpace, int cellSize) { 304 int totalWidth = 0; 305 boolean firstTime = true; 306 for (Folder f : mFoldersSortedSet) { 307 final String folderString = f.name; 308 int width = (int) sFoldersPaint.measureText(folderString) + cellSize; 309 if (firstTime) { 310 firstTime = false; 311 } else { 312 width += sFoldersLeftPadding; 313 } 314 totalWidth += width; 315 if (totalWidth > availableSpace) { 316 break; 317 } 318 } 319 320 return totalWidth; 321 } 322 323 public void drawFolders(Canvas canvas, ConversationItemViewCoordinates coordinates) { 324 if (mFoldersCount == 0) { 325 return; 326 } 327 final int xMinStart = coordinates.foldersX; 328 final int xEnd = coordinates.foldersXEnd; 329 final int y = coordinates.foldersY; 330 final int height = coordinates.foldersHeight; 331 int textBottomPadding = coordinates.foldersTextBottomPadding; 332 333 sFoldersPaint.setTextSize(coordinates.foldersFontSize); 334 sFoldersPaint.setTypeface(coordinates.foldersTypeface); 335 336 // Initialize space and cell size based on the current mode. 337 int availableSpace = xEnd - xMinStart; 338 int maxFoldersCount = availableSpace / coordinates.getFolderMinimumWidth(); 339 int foldersCount = Math.min(mFoldersCount, maxFoldersCount); 340 int averageWidth = availableSpace / foldersCount; 341 int cellSize = coordinates.getFolderCellWidth(); 342 343 // TODO(ath): sFoldersPaint.measureText() is done 3x in this method. stop that. 344 // Extra credit: maybe cache results across items as long as font size doesn't change. 345 346 final int totalWidth = measureFolders(availableSpace, cellSize); 347 int xStart = xEnd - Math.min(availableSpace, totalWidth); 348 final boolean overflow = totalWidth > availableSpace; 349 350 // Second pass to draw folders. 351 int i = 0; 352 for (Folder f : mFoldersSortedSet) { 353 if (availableSpace <= 0) { 354 break; 355 } 356 final String folderString = f.name; 357 final int fgColor = f.getForegroundColor(mDefaultFgColor); 358 final int bgColor = f.getBackgroundColor(mDefaultBgColor); 359 boolean labelTooLong = false; 360 final int textW = (int) sFoldersPaint.measureText(folderString); 361 int width = textW + cellSize + sFoldersLeftPadding; 362 363 if (overflow && width > averageWidth) { 364 if (i < foldersCount - 1) { 365 width = averageWidth; 366 } else { 367 // allow the last label to take all remaining space 368 // (and don't let it make room for padding) 369 width = availableSpace + sFoldersLeftPadding; 370 } 371 labelTooLong = true; 372 } 373 374 // TODO (mindyp): how to we get this? 375 final boolean isMuted = false; 376 // labelValues.folderId == 377 // sGmail.getFolderMap(mAccount).getFolderIdIgnored(); 378 379 // Draw the box. 380 sFoldersPaint.setColor(bgColor); 381 sFoldersPaint.setStyle(Paint.Style.FILL); 382 canvas.drawRect(xStart, y, xStart + width - sFoldersLeftPadding, 383 y + height, sFoldersPaint); 384 385 // Draw the text. 386 final int padding = cellSize / 2; 387 sFoldersPaint.setColor(fgColor); 388 sFoldersPaint.setStyle(Paint.Style.FILL); 389 if (labelTooLong) { 390 final int rightBorder = xStart + width - sFoldersLeftPadding - padding; 391 final Shader shader = new LinearGradient(rightBorder - padding, y, rightBorder, 392 y, fgColor, Utils.getTransparentColor(fgColor), Shader.TileMode.CLAMP); 393 sFoldersPaint.setShader(shader); 394 } 395 canvas.drawText(folderString, xStart + padding, y + height - textBottomPadding, 396 sFoldersPaint); 397 if (labelTooLong) { 398 sFoldersPaint.setShader(null); 399 } 400 401 availableSpace -= width; 402 xStart += width; 403 i++; 404 } 405 } 406 } 407 408 public ConversationItemView(Context context, String account) { 409 super(context); 410 Utils.traceBeginSection("CIVC constructor"); 411 setClickable(true); 412 setLongClickable(true); 413 mContext = context.getApplicationContext(); 414 final Resources res = mContext.getResources(); 415 mTabletDevice = Utils.useTabletUI(res); 416 mIsExpansiveTablet = 417 mTabletDevice ? res.getBoolean(R.bool.use_expansive_tablet_ui) : false; 418 mListCollapsible = res.getBoolean(R.bool.list_collapsible); 419 mAccount = account; 420 421 if (STAR_OFF == null) { 422 // Initialize static bitmaps. 423 STAR_OFF = BitmapFactory.decodeResource(res, R.drawable.ic_btn_star_off); 424 STAR_ON = BitmapFactory.decodeResource(res, R.drawable.ic_btn_star_on); 425 CHECK = BitmapFactory.decodeResource(res, R.drawable.ic_avatar_check); 426 ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attachment_holo_light); 427 ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double); 428 TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single); 429 IMPORTANT_ONLY_TO_ME = BitmapFactory.decodeResource(res, 430 R.drawable.ic_email_caret_double_important_unread); 431 IMPORTANT_TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, 432 R.drawable.ic_email_caret_single_important_unread); 433 IMPORTANT_TO_OTHERS = BitmapFactory.decodeResource(res, 434 R.drawable.ic_email_caret_none_important_unread); 435 STATE_REPLIED = 436 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_holo_light); 437 STATE_FORWARDED = 438 BitmapFactory.decodeResource(res, R.drawable.ic_badge_forward_holo_light); 439 STATE_REPLIED_AND_FORWARDED = 440 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_forward_holo_light); 441 STATE_CALENDAR_INVITE = 442 BitmapFactory.decodeResource(res, R.drawable.ic_badge_invite_holo_light); 443 VISIBLE_CONVERSATION_CARET = BitmapFactory.decodeResource(res, R.drawable.caret_grey); 444 RIGHT_EDGE_TABLET = res.getDrawable(R.drawable.list_edge_tablet); 445 PLACEHOLDER = res.getDrawable(drawable.ic_attachment_load); 446 PROGRESS_BAR = res.getDrawable(drawable.progress_holo); 447 448 // Initialize colors. 449 sActivatedTextSpan = CharacterStyle.wrap(new ForegroundColorSpan( 450 res.getColor(R.color.senders_text_color_read))); 451 sSendersTextColorRead = res.getColor(R.color.senders_text_color_read); 452 sSendersTextColorUnread = res.getColor(R.color.senders_text_color_unread); 453 sSubjectTextUnreadSpan = new TextAppearanceSpan(mContext, 454 R.style.SubjectAppearanceUnreadStyle); 455 sSubjectTextReadSpan = new TextAppearanceSpan(mContext, 456 R.style.SubjectAppearanceReadStyle); 457 sSnippetTextUnreadSpan = 458 new ForegroundColorSpan(res.getColor(R.color.snippet_text_color_unread)); 459 sSnippetTextReadSpan = 460 new ForegroundColorSpan(res.getColor(R.color.snippet_text_color_read)); 461 sDateTextColor = res.getColor(R.color.date_text_color); 462 sStarTouchSlop = res.getDimensionPixelSize(R.dimen.star_touch_slop); 463 sSenderImageTouchSlop = res.getDimensionPixelSize(R.dimen.sender_image_touch_slop); 464 sShrinkAnimationDuration = res.getInteger(R.integer.shrink_animation_duration); 465 sSlideAnimationDuration = res.getInteger(R.integer.slide_animation_duration); 466 // Initialize static color. 467 sSendersSplitToken = res.getString(R.string.senders_split_token); 468 sElidedPaddingToken = res.getString(R.string.elided_padding_token); 469 sOverflowCountFormat = res.getString(string.ap_overflow_format); 470 sScrollSlop = res.getInteger(R.integer.swipeScrollSlop); 471 sFoldersLeftPadding = res.getDimensionPixelOffset(R.dimen.folders_left_padding); 472 sContactPhotoManager = ContactPhotoManager.createContactPhotoManager(context); 473 sOverflowCountMax = res.getInteger(integer.ap_overflow_max_count); 474 sCabAnimationDuration = 475 res.getInteger(R.integer.conv_item_view_cab_anim_duration); 476 } 477 478 mPhotoFlipAnimator = new CabAnimator("photoFlipFraction", 0, 2, 479 sCabAnimationDuration) { 480 @Override 481 public void invalidateArea() { 482 final int left = mCoordinates.contactImagesX; 483 final int right = left + mContactImagesHolder.getWidth(); 484 final int top = mCoordinates.contactImagesY; 485 final int bottom = top + mContactImagesHolder.getHeight(); 486 invalidate(left, top, right, bottom); 487 } 488 }; 489 490 mSendersTextView = new TextView(mContext); 491 mSendersTextView.setIncludeFontPadding(false); 492 493 mSubjectTextView = new TextView(mContext); 494 mSubjectTextView.setEllipsize(TextUtils.TruncateAt.END); 495 mSubjectTextView.setIncludeFontPadding(false); 496 497 mContactImagesHolder = new DividedImageCanvas(context, new InvalidateCallback() { 498 @Override 499 public void invalidate() { 500 if (mCoordinates == null) { 501 return; 502 } 503 ConversationItemView.this.invalidate(mCoordinates.contactImagesX, 504 mCoordinates.contactImagesY, 505 mCoordinates.contactImagesX + mCoordinates.contactImagesWidth, 506 mCoordinates.contactImagesY + mCoordinates.contactImagesHeight); 507 } 508 }); 509 510 mAttachmentsView = new AttachmentGridDrawable(res, PLACEHOLDER, PROGRESS_BAR); 511 mAttachmentsView.setCallback(this); 512 513 Utils.traceEndSection(); 514 } 515 516 public void bind(final Conversation conversation, final ControllableActivity activity, 517 final ConversationListListener conversationListListener, 518 final ConversationSelectionSet set, final Folder folder, 519 final int checkboxOrSenderImage, final boolean showAttachmentPreviews, 520 final boolean parallaxSpeedAlternative, final boolean parallaxDirectionAlternative, 521 final boolean swipeEnabled, final boolean priorityArrowEnabled, 522 final AnimatedAdapter adapter) { 523 Utils.traceBeginSection("CIVC.bind"); 524 bind(ConversationItemViewModel.forConversation(mAccount, conversation), activity, 525 conversationListListener, null /* conversationItemAreaClickListener */, set, folder, 526 checkboxOrSenderImage, showAttachmentPreviews, parallaxSpeedAlternative, 527 parallaxDirectionAlternative, swipeEnabled, priorityArrowEnabled, adapter, 528 -1 /* backgroundOverrideResId */, 529 null /* photoBitmap */); 530 Utils.traceEndSection(); 531 } 532 533 public void bindAd(final ConversationItemViewModel conversationItemViewModel, 534 final ControllableActivity activity, 535 final ConversationListListener conversationListListener, 536 final ConversationItemAreaClickListener conversationItemAreaClickListener, 537 final Folder folder, final int checkboxOrSenderImage, final AnimatedAdapter adapter, 538 final int backgroundOverrideResId, final Bitmap photoBitmap) { 539 Utils.traceBeginSection("CIVC.bindAd"); 540 bind(conversationItemViewModel, activity, conversationListListener, 541 conversationItemAreaClickListener, null /* set */, folder, checkboxOrSenderImage, 542 false /* attachment previews */, false /* parallax */, false /* parallax */, 543 true /* swipeEnabled */, false /* priorityArrowEnabled */, adapter, 544 backgroundOverrideResId, photoBitmap); 545 Utils.traceEndSection(); 546 } 547 548 private void bind(final ConversationItemViewModel header, final ControllableActivity activity, 549 final ConversationListListener conversationListListener, 550 final ConversationItemAreaClickListener conversationItemAreaClickListener, 551 final ConversationSelectionSet set, final Folder folder, 552 final int checkboxOrSenderImage, final boolean showAttachmentPreviews, 553 final boolean parallaxSpeedAlternative, final boolean parallaxDirectionAlternative, 554 boolean swipeEnabled, final boolean priorityArrowEnabled, final AnimatedAdapter adapter, 555 final int backgroundOverrideResId, final Bitmap photoBitmap) { 556 mBackgroundOverrideResId = backgroundOverrideResId; 557 mPhotoBitmap = photoBitmap; 558 mConversationItemAreaClickListener = conversationItemAreaClickListener; 559 560 if (mHeader != null) { 561 // If this was previously bound to a different conversation, remove any contact photo 562 // manager requests. 563 if (header.conversation.id != mHeader.conversation.id || 564 (mHeader.displayableSenderNames != null && !mHeader.displayableSenderNames 565 .equals(header.displayableSenderNames))) { 566 ArrayList<String> divisionIds = mContactImagesHolder.getDivisionIds(); 567 if (divisionIds != null) { 568 mContactImagesHolder.reset(); 569 for (int pos = 0; pos < divisionIds.size(); pos++) { 570 sContactPhotoManager.removePhoto(ContactPhotoManager.generateHash( 571 mContactImagesHolder, pos, divisionIds.get(pos))); 572 } 573 } 574 } 575 576 // If this was previously bound to a different conversation, 577 // remove any attachment preview manager requests. 578 if (header.conversation.id != mHeader.conversation.id 579 || header.conversation.attachmentPreviewsCount 580 != mHeader.conversation.attachmentPreviewsCount 581 || !header.conversation.getAttachmentPreviewUris() 582 .equals(mHeader.conversation.getAttachmentPreviewUris())) { 583 584 // unbind the attachments view (releasing bitmap references) 585 // (this also cancels all async tasks) 586 for (int i = 0, len = mAttachmentsView.getCount(); i < len; i++) { 587 mAttachmentsView.getOrCreateDrawable(i).unbind(); 588 } 589 // reset the grid, as the newly bound item may have a different attachment count 590 mAttachmentsView.setCount(0); 591 } 592 593 if (header.conversation.id != mHeader.conversation.id) { 594 // Stop the photo flip animation 595 mPhotoFlipAnimator.stopAnimation(); 596 } 597 } 598 mCoordinates = null; 599 mHeader = header; 600 mActivity = activity; 601 mConversationListListener = conversationListListener; 602 mSelectedConversationSet = set; 603 mDisplayedFolder = folder; 604 mStarEnabled = folder != null && !folder.isTrash(); 605 mSwipeEnabled = swipeEnabled; 606 mAdapter = adapter; 607 mAttachmentsView.setBitmapCache(mAdapter.getBitmapCache()); 608 mAttachmentsView.setDecodeAggregator(mAdapter.getDecodeAggregator()); 609 610 if (checkboxOrSenderImage == ConversationListIcon.SENDER_IMAGE) { 611 mGadgetMode = ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO; 612 } else { 613 mGadgetMode = ConversationItemViewCoordinates.GADGET_NONE; 614 } 615 616 mAttachmentPreviewsEnabled = showAttachmentPreviews; 617 mParallaxSpeedAlternative = parallaxSpeedAlternative; 618 mParallaxDirectionAlternative = parallaxDirectionAlternative; 619 620 // Initialize folder displayer. 621 if (mHeader.folderDisplayer == null) { 622 mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext); 623 } else { 624 mHeader.folderDisplayer.reset(); 625 } 626 627 final int ignoreFolderType; 628 if (mDisplayedFolder.isInbox()) { 629 ignoreFolderType = FolderType.INBOX; 630 } else { 631 ignoreFolderType = -1; 632 } 633 634 mHeader.folderDisplayer.loadConversationFolders(mHeader.conversation, 635 mDisplayedFolder.folderUri, ignoreFolderType); 636 637 if (mHeader.dateOverrideText == null) { 638 mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext, 639 mHeader.conversation.dateMs); 640 } else { 641 mHeader.dateText = mHeader.dateOverrideText; 642 } 643 644 mConfig = new ConversationItemViewCoordinates.Config() 645 .withGadget(mGadgetMode) 646 .withAttachmentPreviews(getAttachmentPreviewsMode()); 647 if (header.folderDisplayer.hasVisibleFolders()) { 648 mConfig.showFolders(); 649 } 650 if (header.hasBeenForwarded || header.hasBeenRepliedTo || header.isInvite) { 651 mConfig.showReplyState(); 652 } 653 if (mHeader.conversation.color != 0) { 654 mConfig.showColorBlock(); 655 } 656 // Personal level. 657 mHeader.personalLevelBitmap = null; 658 if (true) { // TODO: hook this up to a setting 659 final int personalLevel = mHeader.conversation.personalLevel; 660 final boolean isImportant = 661 mHeader.conversation.priority == UIProvider.ConversationPriority.IMPORTANT; 662 final boolean useImportantMarkers = isImportant && priorityArrowEnabled; 663 664 if (personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) { 665 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME 666 : ONLY_TO_ME; 667 } else if (personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) { 668 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS 669 : TO_ME_AND_OTHERS; 670 } else if (useImportantMarkers) { 671 mHeader.personalLevelBitmap = IMPORTANT_TO_OTHERS; 672 } 673 } 674 if (mHeader.personalLevelBitmap != null) { 675 mConfig.showPersonalIndicator(); 676 } 677 678 final int overflowCount = Math.min(getOverflowCount(), sOverflowCountMax); 679 mHeader.overflowText = (overflowCount > 0) ? 680 String.format(sOverflowCountFormat, overflowCount) : null; 681 682 mAttachmentsView.setOverflowText(mHeader.overflowText); 683 684 setContentDescription(); 685 requestLayout(); 686 } 687 688 @Override 689 public void invalidateDrawable(Drawable who) { 690 boolean handled = false; 691 if (mCoordinates != null) { 692 if (mAttachmentsView.equals(who)) { 693 final Rect r = new Rect(who.getBounds()); 694 r.offset(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY); 695 ConversationItemView.this.invalidate(r.left, r.top, r.right, r.bottom); 696 handled = true; 697 } 698 } 699 if (!handled) { 700 super.invalidateDrawable(who); 701 } 702 } 703 704 /** 705 * Get the Conversation object associated with this view. 706 */ 707 public Conversation getConversation() { 708 return mHeader.conversation; 709 } 710 711 private static void startTimer(String tag) { 712 if (sTimer != null) { 713 sTimer.start(tag); 714 } 715 } 716 717 private static void pauseTimer(String tag) { 718 if (sTimer != null) { 719 sTimer.pause(tag); 720 } 721 } 722 723 @Override 724 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 725 Utils.traceBeginSection("CIVC.measure"); 726 final int wSize = MeasureSpec.getSize(widthMeasureSpec); 727 728 final int currentMode = mActivity.getViewMode().getMode(); 729 if (wSize != mViewWidth || mPreviousMode != currentMode) { 730 mViewWidth = wSize; 731 mPreviousMode = currentMode; 732 } 733 mHeader.viewWidth = mViewWidth; 734 735 mConfig.updateWidth(wSize).setViewMode(currentMode); 736 737 Resources res = getResources(); 738 mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen); 739 740 mCoordinates = ConversationItemViewCoordinates.forConfig(mContext, mConfig, 741 mAdapter.getCoordinatesCache()); 742 743 if (mPhotoBitmap != null) { 744 mPhotoRect = new Rect(0, 0, mCoordinates.contactImagesWidth, 745 mCoordinates.contactImagesHeight); 746 } 747 748 final int h = (mAnimatedHeightFraction != 1.0f) ? 749 Math.round(mAnimatedHeightFraction * mCoordinates.height) : mCoordinates.height; 750 setMeasuredDimension(mConfig.getWidth(), h); 751 Utils.traceEndSection(); 752 } 753 754 @Override 755 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 756 startTimer(PERF_TAG_LAYOUT); 757 Utils.traceBeginSection("CIVC.layout"); 758 759 super.onLayout(changed, left, top, right, bottom); 760 761 Utils.traceBeginSection("text and bitmaps"); 762 calculateTextsAndBitmaps(); 763 Utils.traceEndSection(); 764 765 Utils.traceBeginSection("coordinates"); 766 calculateCoordinates(); 767 Utils.traceEndSection(); 768 769 // Subject. 770 createSubject(mHeader.unread); 771 772 if (!mHeader.isLayoutValid()) { 773 setContentDescription(); 774 } 775 mHeader.validate(); 776 777 pauseTimer(PERF_TAG_LAYOUT); 778 if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) { 779 sTimer.dumpResults(); 780 sTimer = new Timer(); 781 sLayoutCount = 0; 782 } 783 Utils.traceEndSection(); 784 } 785 786 private void setContentDescription() { 787 if (mActivity.isAccessibilityEnabled()) { 788 mHeader.resetContentDescription(); 789 setContentDescription(mHeader.getContentDescription(mContext)); 790 } 791 } 792 793 @Override 794 public void setBackgroundResource(int resourceId) { 795 Utils.traceBeginSection("set background resource"); 796 Drawable drawable = mBackgrounds.get(resourceId); 797 if (drawable == null) { 798 drawable = getResources().getDrawable(resourceId); 799 mBackgrounds.put(resourceId, drawable); 800 } 801 if (getBackground() != drawable) { 802 super.setBackgroundDrawable(drawable); 803 } 804 Utils.traceEndSection(); 805 } 806 807 private void calculateTextsAndBitmaps() { 808 startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 809 810 if (mSelectedConversationSet != null) { 811 mSelected = mSelectedConversationSet.contains(mHeader.conversation); 812 } 813 setSelected(mSelected); 814 mHeader.gadgetMode = mGadgetMode; 815 816 final boolean isUnread = mHeader.unread; 817 updateBackground(isUnread); 818 819 mHeader.sendersDisplayText = new SpannableStringBuilder(); 820 mHeader.styledSendersString = null; 821 822 mHeader.hasDraftMessage = mHeader.conversation.numDrafts() > 0; 823 824 // Parse senders fragments. 825 if (mHeader.conversation.conversationInfo != null) { 826 // This is Gmail 827 Context context = getContext(); 828 mHeader.messageInfoString = SendersView 829 .createMessageInfo(context, mHeader.conversation, true); 830 int maxChars = ConversationItemViewCoordinates.getSendersLength(context, 831 mCoordinates.getMode(), mHeader.conversation.hasAttachments); 832 mHeader.displayableSenderEmails = new ArrayList<String>(); 833 mHeader.displayableSenderNames = new ArrayList<String>(); 834 mHeader.styledSenders = new ArrayList<SpannableString>(); 835 SendersView.format(context, mHeader.conversation.conversationInfo, 836 mHeader.messageInfoString.toString(), maxChars, mHeader.styledSenders, 837 mHeader.displayableSenderNames, mHeader.displayableSenderEmails, mAccount, 838 true); 839 840 if (mHeader.displayableSenderEmails.isEmpty() && mHeader.hasDraftMessage) { 841 mHeader.displayableSenderEmails.add(mAccount); 842 mHeader.displayableSenderNames.add(mAccount); 843 } 844 845 // If we have displayable senders, load their thumbnails 846 loadSenderImages(); 847 } else { 848 // This is Email 849 SendersView.formatSenders(mHeader, getContext(), true); 850 if (!TextUtils.isEmpty(mHeader.conversation.senders)) { 851 mHeader.displayableSenderEmails = new ArrayList<String>(); 852 mHeader.displayableSenderNames = new ArrayList<String>(); 853 854 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(mHeader.conversation.senders); 855 for (int i = 0; i < tokens.length;i++) { 856 final Rfc822Token token = tokens[i]; 857 final String senderName = Address.decodeAddressName(token.getName()); 858 final String senderAddress = token.getAddress(); 859 mHeader.displayableSenderEmails.add(senderAddress); 860 mHeader.displayableSenderNames.add( 861 !TextUtils.isEmpty(senderName) ? senderName : senderAddress); 862 } 863 loadSenderImages(); 864 } 865 } 866 867 if (isAttachmentPreviewsEnabled()) { 868 loadAttachmentPreviews(); 869 } 870 871 if (mHeader.isLayoutValid()) { 872 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 873 return; 874 } 875 startTimer(PERF_TAG_CALCULATE_FOLDERS); 876 877 878 pauseTimer(PERF_TAG_CALCULATE_FOLDERS); 879 880 // Paper clip icon. 881 mHeader.paperclip = null; 882 if (mHeader.conversation.hasAttachments) { 883 mHeader.paperclip = ATTACHMENT; 884 } 885 886 startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 887 888 pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 889 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 890 } 891 892 private boolean isAttachmentPreviewsEnabled() { 893 return mAttachmentPreviewsEnabled && !mHeader.conversation.getAttachmentPreviewUris() 894 .isEmpty(); 895 } 896 897 private int getOverflowCount() { 898 return mHeader.conversation.attachmentPreviewsCount - mHeader.conversation 899 .getAttachmentPreviewUris().size(); 900 } 901 902 private int getAttachmentPreviewsMode() { 903 if (isAttachmentPreviewsEnabled()) { 904 return mHeader.conversation.read 905 ? ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_READ 906 : ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_UNREAD; 907 } else { 908 return ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_NONE; 909 } 910 } 911 912 private float getParallaxSpeedMultiplier() { 913 return mParallaxSpeedAlternative 914 ? SwipeableListView.ATTACHMENT_PARALLAX_MULTIPLIER_ALTERNATIVE 915 : SwipeableListView.ATTACHMENT_PARALLAX_MULTIPLIER_NORMAL; 916 } 917 918 // FIXME(ath): maybe move this to bind(). the only dependency on layout is on tile W/H, which 919 // is immutable. 920 private void loadSenderImages() { 921 if (mGadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO 922 && mHeader.displayableSenderEmails != null 923 && mHeader.displayableSenderEmails.size() > 0) { 924 if (mCoordinates.contactImagesWidth <= 0 || mCoordinates.contactImagesHeight <= 0) { 925 LogUtils.w(LOG_TAG, 926 "Contact image width(%d) or height(%d) is 0 for mode: (%d).", 927 mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight, 928 mCoordinates.getMode()); 929 return; 930 } 931 932 int size = mHeader.displayableSenderEmails.size(); 933 final List<Object> keys = Lists.newArrayListWithCapacity(size); 934 for (int i = 0; i < DividedImageCanvas.MAX_DIVISIONS && i < size; i++) { 935 keys.add(mHeader.displayableSenderEmails.get(i)); 936 } 937 938 mContactImagesHolder.setDimensions(mCoordinates.contactImagesWidth, 939 mCoordinates.contactImagesHeight); 940 mContactImagesHolder.setDivisionIds(keys); 941 String emailAddress; 942 for (int i = 0; i < DividedImageCanvas.MAX_DIVISIONS && i < size; i++) { 943 emailAddress = mHeader.displayableSenderEmails.get(i); 944 PhotoIdentifier photoIdentifier = new ContactIdentifier( 945 mHeader.displayableSenderNames.get(i), emailAddress, i); 946 sContactPhotoManager.loadThumbnail(photoIdentifier, mContactImagesHolder); 947 } 948 } 949 } 950 951 private void loadAttachmentPreviews() { 952 if (mCoordinates.attachmentPreviewsWidth <= 0 953 || mCoordinates.attachmentPreviewsHeight <= 0) { 954 LogUtils.w(LOG_TAG, 955 "Attachment preview width(%d) or height(%d) is 0 for mode: (%d,%d).", 956 mCoordinates.attachmentPreviewsWidth, mCoordinates.attachmentPreviewsHeight, 957 mCoordinates.getMode(), getAttachmentPreviewsMode()); 958 return; 959 } 960 Utils.traceBeginSection("attachment previews"); 961 962 Utils.traceBeginSection("Setup load attachment previews"); 963 964 LogUtils.d(LOG_TAG, 965 "loadAttachmentPreviews: Loading attachment previews for conversation %s", 966 mHeader.conversation); 967 968 // Get list of attachments and states from conversation 969 final ArrayList<String> attachmentUris = mHeader.conversation.getAttachmentPreviewUris(); 970 final int previewStates = mHeader.conversation.attachmentPreviewStates; 971 final int displayCount = Math.min( 972 attachmentUris.size(), AttachmentGridDrawable.MAX_VISIBLE_ATTACHMENT_COUNT); 973 Utils.traceEndSection(); 974 975 mAttachmentsView.setCoordinates(mCoordinates); 976 mAttachmentsView.setCount(displayCount); 977 978 final int decodeHeight; 979 // if parallax is enabled, increase the desired vertical size of attachment bitmaps 980 // so we have extra pixels to scroll within 981 if (SwipeableListView.ENABLE_ATTACHMENT_PARALLAX) { 982 decodeHeight = Math.round(mCoordinates.attachmentPreviewsDecodeHeight 983 * getParallaxSpeedMultiplier()); 984 } else { 985 decodeHeight = mCoordinates.attachmentPreviewsDecodeHeight; 986 } 987 988 // set the bounds before binding inner drawables so they can decode right away 989 // (they need the their bounds set to know whether to decode to 1x1 or 2x1 dimens) 990 mAttachmentsView.setBounds(0, 0, mCoordinates.attachmentPreviewsWidth, 991 mCoordinates.attachmentPreviewsHeight); 992 993 for (int i = 0; i < displayCount; i++) { 994 Utils.traceBeginSection("setup single attachment preview"); 995 final String uri = attachmentUris.get(i); 996 997 // Find the rendition to load based on availability. 998 LogUtils.v(LOG_TAG, "loadAttachmentPreviews: state [BEST, SIMPLE] is [%s, %s] for %s ", 999 Attachment.getPreviewState(previewStates, i, AttachmentRendition.BEST), 1000 Attachment.getPreviewState(previewStates, i, AttachmentRendition.SIMPLE), 1001 uri); 1002 int bestAvailableRendition = -1; 1003 // BEST first, else use less preferred renditions 1004 for (final int rendition : AttachmentRendition.PREFERRED_RENDITIONS) { 1005 if (Attachment.getPreviewState(previewStates, i, rendition)) { 1006 bestAvailableRendition = rendition; 1007 break; 1008 } 1009 } 1010 1011 LogUtils.d(LOG_TAG, 1012 "creating/setting drawable region in CIV=%s canvas=%s rend=%s uri=%s", 1013 this, mAttachmentsView, bestAvailableRendition, uri); 1014 final AttachmentDrawable drawable = mAttachmentsView.getOrCreateDrawable(i); 1015 drawable.setDecodeDimensions(mCoordinates.attachmentPreviewsWidth, decodeHeight); 1016 drawable.setParallaxSpeedMultiplier(getParallaxSpeedMultiplier()); 1017 if (bestAvailableRendition != -1) { 1018 drawable.bind(getContext(), uri, bestAvailableRendition); 1019 } else { 1020 drawable.showStaticPlaceholder(); 1021 } 1022 1023 Utils.traceEndSection(); 1024 } 1025 1026 Utils.traceEndSection(); 1027 } 1028 1029 private static int makeExactSpecForSize(int size) { 1030 return MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY); 1031 } 1032 1033 private static void layoutViewExactly(View v, int w, int h) { 1034 v.measure(makeExactSpecForSize(w), makeExactSpecForSize(h)); 1035 v.layout(0, 0, w, h); 1036 } 1037 1038 private void layoutSenders() { 1039 if (mHeader.styledSendersString != null) { 1040 if (isActivated() && showActivatedText()) { 1041 mHeader.styledSendersString.setSpan(sActivatedTextSpan, 0, 1042 mHeader.styledMessageInfoStringOffset, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1043 } else { 1044 mHeader.styledSendersString.removeSpan(sActivatedTextSpan); 1045 } 1046 1047 final int w = mSendersWidth; 1048 final int h = mCoordinates.sendersHeight; 1049 mSendersTextView.setLayoutParams(new ViewGroup.LayoutParams(w, h)); 1050 mSendersTextView.setMaxLines(mCoordinates.sendersLineCount); 1051 mSendersTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.sendersFontSize); 1052 layoutViewExactly(mSendersTextView, w, h); 1053 1054 mSendersTextView.setText(mHeader.styledSendersString); 1055 } 1056 } 1057 1058 private void createSubject(final boolean isUnread) { 1059 final String subject = filterTag(mHeader.conversation.subject); 1060 final String snippet = mHeader.conversation.getSnippet(); 1061 final Spannable displayedStringBuilder = new SpannableString( 1062 Conversation.getSubjectAndSnippetForDisplay(mContext, subject, snippet)); 1063 1064 // since spans affect text metrics, add spans to the string before measure/layout or fancy 1065 // ellipsizing 1066 final int subjectTextLength = (subject != null) ? subject.length() : 0; 1067 if (!TextUtils.isEmpty(subject)) { 1068 displayedStringBuilder.setSpan(TextAppearanceSpan.wrap( 1069 isUnread ? sSubjectTextUnreadSpan : sSubjectTextReadSpan), 0, subjectTextLength, 1070 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1071 } 1072 if (!TextUtils.isEmpty(snippet)) { 1073 final int startOffset = subjectTextLength; 1074 // Start after the end of the subject text; since the subject may be 1075 // "" or null, this could start at the 0th character in the subjectText string 1076 displayedStringBuilder.setSpan(ForegroundColorSpan.wrap( 1077 isUnread ? sSnippetTextUnreadSpan : sSnippetTextReadSpan), startOffset, 1078 displayedStringBuilder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1079 } 1080 if (isActivated() && showActivatedText()) { 1081 displayedStringBuilder.setSpan(sActivatedTextSpan, 0, displayedStringBuilder.length(), 1082 Spannable.SPAN_INCLUSIVE_INCLUSIVE); 1083 } 1084 1085 final int subjectWidth = mCoordinates.subjectWidth; 1086 final int subjectHeight = mCoordinates.subjectHeight; 1087 mSubjectTextView.setLayoutParams(new ViewGroup.LayoutParams(subjectWidth, subjectHeight)); 1088 mSubjectTextView.setMaxLines(mCoordinates.subjectLineCount); 1089 mSubjectTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.subjectFontSize); 1090 layoutViewExactly(mSubjectTextView, subjectWidth, subjectHeight); 1091 1092 mSubjectTextView.setText(displayedStringBuilder); 1093 } 1094 1095 private boolean showActivatedText() { 1096 // For activated elements in tablet in conversation mode, we show an activated color, since 1097 // the background is dark blue for activated versus gray for non-activated. 1098 return mTabletDevice && !mListCollapsible; 1099 } 1100 1101 private boolean canFitFragment(int width, int line, int fixedWidth) { 1102 if (line == mCoordinates.sendersLineCount) { 1103 return width + fixedWidth <= mSendersWidth; 1104 } else { 1105 return width <= mSendersWidth; 1106 } 1107 } 1108 1109 private void calculateCoordinates() { 1110 startTimer(PERF_TAG_CALCULATE_COORDINATES); 1111 1112 sPaint.setTextSize(mCoordinates.dateFontSize); 1113 sPaint.setTypeface(Typeface.DEFAULT); 1114 1115 if (mHeader.infoIcon != null) { 1116 mInfoIconX = mCoordinates.infoIconXEnd - mHeader.infoIcon.getWidth(); 1117 1118 // If we have an info icon, we start drawing the date text: 1119 // At the end of the date TextView minus the width of the date text 1120 mDateX = mCoordinates.dateXEnd - (int) sPaint.measureText( 1121 mHeader.dateText != null ? mHeader.dateText.toString() : ""); 1122 } else { 1123 // If there is no info icon, we start drawing the date text: 1124 // At the end of the info icon ImageView minus the width of the date text 1125 // We use the info icon ImageView for positioning, since we want the date text to be 1126 // at the right, since there is no info icon 1127 mDateX = mCoordinates.infoIconXEnd - (int) sPaint.measureText( 1128 mHeader.dateText != null ? mHeader.dateText.toString() : ""); 1129 } 1130 1131 mPaperclipX = mDateX - ATTACHMENT.getWidth() - mCoordinates.datePaddingLeft; 1132 1133 if (mCoordinates.isWide()) { 1134 // In wide mode, the end of the senders should align with 1135 // the start of the subject and is based on a max width. 1136 mSendersWidth = mCoordinates.sendersWidth; 1137 } else { 1138 // In normal mode, the width is based on where the date/attachment icon start. 1139 final int dateAttachmentStart; 1140 // Have this end near the paperclip or date, not the folders. 1141 if (mHeader.paperclip != null) { 1142 dateAttachmentStart = mPaperclipX - mCoordinates.paperclipPaddingLeft; 1143 } else { 1144 dateAttachmentStart = mDateX - mCoordinates.datePaddingLeft; 1145 } 1146 mSendersWidth = dateAttachmentStart - mCoordinates.sendersX; 1147 } 1148 1149 // Second pass to layout each fragment. 1150 sPaint.setTextSize(mCoordinates.sendersFontSize); 1151 sPaint.setTypeface(Typeface.DEFAULT); 1152 1153 if (mHeader.styledSenders != null) { 1154 ellipsizeStyledSenders(); 1155 layoutSenders(); 1156 } else { 1157 // First pass to calculate width of each fragment. 1158 int totalWidth = 0; 1159 int fixedWidth = 0; 1160 for (SenderFragment senderFragment : mHeader.senderFragments) { 1161 CharacterStyle style = senderFragment.style; 1162 int start = senderFragment.start; 1163 int end = senderFragment.end; 1164 style.updateDrawState(sPaint); 1165 senderFragment.width = (int) sPaint.measureText(mHeader.sendersText, start, end); 1166 boolean isFixed = senderFragment.isFixed; 1167 if (isFixed) { 1168 fixedWidth += senderFragment.width; 1169 } 1170 totalWidth += senderFragment.width; 1171 } 1172 1173 if (mSendersWidth < 0) { 1174 mSendersWidth = 0; 1175 } 1176 totalWidth = ellipsize(fixedWidth); 1177 mHeader.sendersDisplayLayout = new StaticLayout(mHeader.sendersDisplayText, sPaint, 1178 mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 1179 } 1180 1181 if (mSendersWidth < 0) { 1182 mSendersWidth = 0; 1183 } 1184 1185 pauseTimer(PERF_TAG_CALCULATE_COORDINATES); 1186 } 1187 1188 // The rules for displaying ellipsized senders are as follows: 1189 // 1) If there is message info (either a COUNT or DRAFT info to display), it MUST be shown 1190 // 2) If senders do not fit, ellipsize the last one that does fit, and stop 1191 // appending new senders 1192 private int ellipsizeStyledSenders() { 1193 SpannableStringBuilder builder = new SpannableStringBuilder(); 1194 float totalWidth = 0; 1195 boolean ellipsize = false; 1196 float width; 1197 SpannableStringBuilder messageInfoString = mHeader.messageInfoString; 1198 if (messageInfoString.length() > 0) { 1199 CharacterStyle[] spans = messageInfoString.getSpans(0, messageInfoString.length(), 1200 CharacterStyle.class); 1201 // There is only 1 character style span; make sure we apply all the 1202 // styles to the paint object before measuring. 1203 if (spans.length > 0) { 1204 spans[0].updateDrawState(sPaint); 1205 } 1206 // Paint the message info string to see if we lose space. 1207 float messageInfoWidth = sPaint.measureText(messageInfoString.toString()); 1208 totalWidth += messageInfoWidth; 1209 } 1210 SpannableString prevSender = null; 1211 SpannableString ellipsizedText; 1212 for (SpannableString sender : mHeader.styledSenders) { 1213 // There may be null sender strings if there were dupes we had to remove. 1214 if (sender == null) { 1215 continue; 1216 } 1217 // No more width available, we'll only show fixed fragments. 1218 if (ellipsize) { 1219 break; 1220 } 1221 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 1222 // There is only 1 character style span. 1223 if (spans.length > 0) { 1224 spans[0].updateDrawState(sPaint); 1225 } 1226 // If there are already senders present in this string, we need to 1227 // make sure we prepend the dividing token 1228 if (SendersView.sElidedString.equals(sender.toString())) { 1229 prevSender = sender; 1230 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken); 1231 } else if (builder.length() > 0 1232 && (prevSender == null || !SendersView.sElidedString.equals(prevSender 1233 .toString()))) { 1234 prevSender = sender; 1235 sender = copyStyles(spans, sSendersSplitToken + sender); 1236 } else { 1237 prevSender = sender; 1238 } 1239 if (spans.length > 0) { 1240 spans[0].updateDrawState(sPaint); 1241 } 1242 // Measure the width of the current sender and make sure we have space 1243 width = (int) sPaint.measureText(sender.toString()); 1244 if (width + totalWidth > mSendersWidth) { 1245 // The text is too long, new line won't help. We have to 1246 // ellipsize text. 1247 ellipsize = true; 1248 width = mSendersWidth - totalWidth; // ellipsis width? 1249 ellipsizedText = copyStyles(spans, 1250 TextUtils.ellipsize(sender, sPaint, width, TruncateAt.END)); 1251 width = (int) sPaint.measureText(ellipsizedText.toString()); 1252 } else { 1253 ellipsizedText = null; 1254 } 1255 totalWidth += width; 1256 1257 final CharSequence fragmentDisplayText; 1258 if (ellipsizedText != null) { 1259 fragmentDisplayText = ellipsizedText; 1260 } else { 1261 fragmentDisplayText = sender; 1262 } 1263 builder.append(fragmentDisplayText); 1264 } 1265 mHeader.styledMessageInfoStringOffset = builder.length(); 1266 builder.append(messageInfoString); 1267 mHeader.styledSendersString = builder; 1268 return (int)totalWidth; 1269 } 1270 1271 private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 1272 SpannableString s = new SpannableString(newText); 1273 if (spans != null && spans.length > 0) { 1274 s.setSpan(spans[0], 0, s.length(), 0); 1275 } 1276 return s; 1277 } 1278 1279 private int ellipsize(int fixedWidth) { 1280 int totalWidth = 0; 1281 int currentLine = 1; 1282 boolean ellipsize = false; 1283 for (SenderFragment senderFragment : mHeader.senderFragments) { 1284 CharacterStyle style = senderFragment.style; 1285 int start = senderFragment.start; 1286 int end = senderFragment.end; 1287 int width = senderFragment.width; 1288 boolean isFixed = senderFragment.isFixed; 1289 style.updateDrawState(sPaint); 1290 1291 // No more width available, we'll only show fixed fragments. 1292 if (ellipsize && !isFixed) { 1293 senderFragment.shouldDisplay = false; 1294 continue; 1295 } 1296 1297 // New line and ellipsize text if needed. 1298 senderFragment.ellipsizedText = null; 1299 if (isFixed) { 1300 fixedWidth -= width; 1301 } 1302 if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) { 1303 // The text is too long, new line won't help. We have to 1304 // ellipsize text. 1305 if (totalWidth == 0) { 1306 ellipsize = true; 1307 } else { 1308 // New line. 1309 if (currentLine < mCoordinates.sendersLineCount) { 1310 currentLine++; 1311 totalWidth = 0; 1312 // The text is still too long, we have to ellipsize 1313 // text. 1314 if (totalWidth + width > mSendersWidth) { 1315 ellipsize = true; 1316 } 1317 } else { 1318 ellipsize = true; 1319 } 1320 } 1321 1322 if (ellipsize) { 1323 width = mSendersWidth - totalWidth; 1324 // No more new line, we have to reserve width for fixed 1325 // fragments. 1326 if (currentLine == mCoordinates.sendersLineCount) { 1327 width -= fixedWidth; 1328 } 1329 senderFragment.ellipsizedText = TextUtils.ellipsize( 1330 mHeader.sendersText.substring(start, end), sPaint, width, 1331 TruncateAt.END).toString(); 1332 width = (int) sPaint.measureText(senderFragment.ellipsizedText); 1333 } 1334 } 1335 senderFragment.shouldDisplay = true; 1336 totalWidth += width; 1337 1338 final CharSequence fragmentDisplayText; 1339 if (senderFragment.ellipsizedText != null) { 1340 fragmentDisplayText = senderFragment.ellipsizedText; 1341 } else { 1342 fragmentDisplayText = mHeader.sendersText.substring(start, end); 1343 } 1344 final int spanStart = mHeader.sendersDisplayText.length(); 1345 mHeader.sendersDisplayText.append(fragmentDisplayText); 1346 mHeader.sendersDisplayText.setSpan(senderFragment.style, spanStart, 1347 mHeader.sendersDisplayText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1348 } 1349 return totalWidth; 1350 } 1351 1352 /** 1353 * If the subject contains the tag of a mailing-list (text surrounded with 1354 * []), return the subject with that tag ellipsized, e.g. 1355 * "[android-gmail-team] Hello" -> "[andr...] Hello" 1356 */ 1357 private String filterTag(String subject) { 1358 String result = subject; 1359 String formatString = getContext().getResources().getString(R.string.filtered_tag); 1360 if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') { 1361 int end = subject.indexOf(']'); 1362 if (end > 0) { 1363 String tag = subject.substring(1, end); 1364 result = String.format(formatString, Utils.ellipsize(tag, 7), 1365 subject.substring(end + 1)); 1366 } 1367 } 1368 return result; 1369 } 1370 1371 @Override 1372 public final void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, 1373 int totalItemCount) { 1374 if (SwipeableListView.ENABLE_ATTACHMENT_PARALLAX) { 1375 if (mHeader == null || mCoordinates == null || !isAttachmentPreviewsEnabled()) { 1376 return; 1377 } 1378 1379 invalidate(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY, 1380 mCoordinates.attachmentPreviewsX + mCoordinates.attachmentPreviewsWidth, 1381 mCoordinates.attachmentPreviewsY + mCoordinates.attachmentPreviewsHeight); 1382 } 1383 } 1384 1385 @Override 1386 public void onScrollStateChanged(AbsListView view, int scrollState) { 1387 } 1388 1389 @Override 1390 protected void onDraw(Canvas canvas) { 1391 Utils.traceBeginSection("CIVC.draw"); 1392 1393 // Contact photo 1394 if (mGadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO) { 1395 canvas.save(); 1396 drawContactImageArea(canvas); 1397 canvas.restore(); 1398 } 1399 1400 // Senders. 1401 boolean isUnread = mHeader.unread; 1402 // Old style senders; apply text colors/ sizes/ styling. 1403 canvas.save(); 1404 if (mHeader.sendersDisplayLayout != null) { 1405 sPaint.setTextSize(mCoordinates.sendersFontSize); 1406 sPaint.setTypeface(SendersView.getTypeface(isUnread)); 1407 sPaint.setColor(isUnread ? sSendersTextColorUnread : sSendersTextColorRead); 1408 canvas.translate(mCoordinates.sendersX, mCoordinates.sendersY 1409 + mHeader.sendersDisplayLayout.getTopPadding()); 1410 mHeader.sendersDisplayLayout.draw(canvas); 1411 } else { 1412 drawSenders(canvas); 1413 } 1414 canvas.restore(); 1415 1416 1417 // Subject. 1418 sPaint.setTypeface(Typeface.DEFAULT); 1419 canvas.save(); 1420 drawSubject(canvas); 1421 canvas.restore(); 1422 1423 // Folders. 1424 if (mConfig.areFoldersVisible()) { 1425 mHeader.folderDisplayer.drawFolders(canvas, mCoordinates); 1426 } 1427 1428 // If this folder has a color (combined view/Email), show it here 1429 if (mConfig.isColorBlockVisible()) { 1430 sFoldersPaint.setColor(mHeader.conversation.color); 1431 sFoldersPaint.setStyle(Paint.Style.FILL); 1432 canvas.drawRect(mCoordinates.colorBlockX, mCoordinates.colorBlockY, 1433 mCoordinates.colorBlockX + mCoordinates.colorBlockWidth, 1434 mCoordinates.colorBlockY + mCoordinates.colorBlockHeight, sFoldersPaint); 1435 } 1436 1437 // Draw the reply state. Draw nothing if neither replied nor forwarded. 1438 if (mConfig.isReplyStateVisible()) { 1439 if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) { 1440 canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX, 1441 mCoordinates.replyStateY, null); 1442 } else if (mHeader.hasBeenRepliedTo) { 1443 canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX, 1444 mCoordinates.replyStateY, null); 1445 } else if (mHeader.hasBeenForwarded) { 1446 canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX, 1447 mCoordinates.replyStateY, null); 1448 } else if (mHeader.isInvite) { 1449 canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX, 1450 mCoordinates.replyStateY, null); 1451 } 1452 } 1453 1454 if (mConfig.isPersonalIndicatorVisible()) { 1455 canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalIndicatorX, 1456 mCoordinates.personalIndicatorY, null); 1457 } 1458 1459 // Info icon 1460 if (mHeader.infoIcon != null) { 1461 canvas.drawBitmap(mHeader.infoIcon, mInfoIconX, mCoordinates.infoIconY, sPaint); 1462 } 1463 1464 // Date. 1465 sPaint.setTextSize(mCoordinates.dateFontSize); 1466 sPaint.setTypeface(Typeface.DEFAULT); 1467 sPaint.setColor(sDateTextColor); 1468 drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateYBaseline, 1469 sPaint); 1470 1471 // Paper clip icon. 1472 if (mHeader.paperclip != null) { 1473 canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint); 1474 } 1475 1476 if (mStarEnabled) { 1477 // Star. 1478 canvas.drawBitmap(getStarBitmap(), mCoordinates.starX, mCoordinates.starY, sPaint); 1479 } 1480 1481 // Attachment previews 1482 if (isAttachmentPreviewsEnabled()) { 1483 canvas.save(); 1484 drawAttachmentPreviews(canvas); 1485 canvas.restore(); 1486 } 1487 1488 // right-side edge effect when in tablet conversation mode and the list is not collapsed 1489 if (mTabletDevice && !mListCollapsible && 1490 (ViewMode.isConversationMode(mConfig.getViewMode()) 1491 || ViewMode.isAdMode(mConfig.getViewMode()))) { 1492 RIGHT_EDGE_TABLET.setBounds(getWidth() - RIGHT_EDGE_TABLET.getIntrinsicWidth(), 0, 1493 getWidth(), getHeight()); 1494 RIGHT_EDGE_TABLET.draw(canvas); 1495 1496 if (isActivated()) { 1497 // draw caret on the right, centered vertically 1498 final int x = getWidth() - VISIBLE_CONVERSATION_CARET.getWidth(); 1499 final int y = (getHeight() - VISIBLE_CONVERSATION_CARET.getHeight()) / 2; 1500 canvas.drawBitmap(VISIBLE_CONVERSATION_CARET, x, y, null); 1501 } 1502 } 1503 Utils.traceEndSection(); 1504 } 1505 1506 /** 1507 * Draws the contact images or check, in the correct animated state. 1508 */ 1509 private void drawContactImageArea(final Canvas canvas) { 1510 if (isSelected()) { 1511 mLastSelectedId = mHeader.conversation.id; 1512 1513 // Since this is selected, we draw the checkbox if the animation is not running, or if 1514 // it's running, and is past the half-way point 1515 if (mPhotoFlipAnimator.getValue() > 1 || !mPhotoFlipAnimator.isStarted()) { 1516 // Flash in the check 1517 drawCheckbox(canvas); 1518 } else { 1519 // Flip out the contact photo 1520 drawContactImages(canvas); 1521 } 1522 } else { 1523 if ((mConversationListListener.isExitingSelectionMode() 1524 && mLastSelectedId == mHeader.conversation.id) 1525 || mPhotoFlipAnimator.isStarted()) { 1526 // Animate back to the photo 1527 if (!mPhotoFlipAnimator.isStarted()) { 1528 mPhotoFlipAnimator.startAnimation(true /* reverse */); 1529 } 1530 1531 if (mPhotoFlipAnimator.getValue() > 1) { 1532 // Flash out the check 1533 drawCheckbox(canvas); 1534 } else { 1535 // Flip in the contact photo 1536 drawContactImages(canvas); 1537 } 1538 } else { 1539 mLastSelectedId = -1; // We don't care anymore 1540 mPhotoFlipAnimator.stopAnimation(); // It's not running, but we want to reset state 1541 1542 // Contact photos 1543 drawContactImages(canvas); 1544 } 1545 } 1546 } 1547 1548 private void drawContactImages(final Canvas canvas) { 1549 // mPhotoFlipFraction goes from 0 to 1 1550 final float value = mPhotoFlipAnimator.getValue(); 1551 1552 final float scale = 1f - value; 1553 final float xOffset = mContactImagesHolder.getWidth() * value / 2; 1554 1555 mPhotoFlipMatrix.reset(); 1556 mPhotoFlipMatrix.postScale(scale, 1); 1557 1558 final float x = mCoordinates.contactImagesX + xOffset; 1559 final float y = mCoordinates.contactImagesY; 1560 1561 canvas.translate(x, y); 1562 1563 if (mPhotoBitmap == null) { 1564 mContactImagesHolder.draw(canvas, mPhotoFlipMatrix); 1565 } else { 1566 canvas.drawBitmap(mPhotoBitmap, null, mPhotoRect, sPaint); 1567 } 1568 } 1569 1570 private void drawCheckbox(final Canvas canvas) { 1571 // mPhotoFlipFraction goes from 1 to 2 1572 1573 // Draw the background 1574 canvas.save(); 1575 canvas.translate(mCoordinates.contactImagesX, mCoordinates.contactImagesY); 1576 canvas.drawRect(0, 0, mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight, 1577 sCheckBackgroundPaint); 1578 canvas.restore(); 1579 1580 final int x = mCoordinates.contactImagesX 1581 + (mCoordinates.contactImagesWidth - CHECK.getWidth()) / 2; 1582 final int y = mCoordinates.contactImagesY 1583 + (mCoordinates.contactImagesHeight - CHECK.getHeight()) / 2; 1584 1585 final float value = mPhotoFlipAnimator.getValue(); 1586 final float scale; 1587 1588 if (!mPhotoFlipAnimator.isStarted()) { 1589 // We're not animating 1590 scale = 1; 1591 } else if (value < 1.9) { 1592 // 1.0 to 1.9 will scale 0 to 1 1593 scale = (value - 1f) / 0.9f; 1594 } else if (value < 1.95) { 1595 // 1.9 to 1.95 will scale 1 to 19/18 1596 scale = (value - 1f) / 0.9f; 1597 } else { 1598 // 1.95 to 2.0 will scale 19/18 to 1 1599 scale = (0.95f - (value - 1.95f)) / 0.9f; 1600 } 1601 1602 final float xOffset = CHECK.getWidth() * (1f - scale) / 2f; 1603 final float yOffset = CHECK.getHeight() * (1f - scale) / 2f; 1604 1605 mCheckMatrix.reset(); 1606 mCheckMatrix.postScale(scale, scale); 1607 1608 canvas.translate(x + xOffset, y + yOffset); 1609 1610 canvas.drawBitmap(CHECK, mCheckMatrix, sPaint); 1611 } 1612 1613 private void drawAttachmentPreviews(Canvas canvas) { 1614 canvas.translate(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY); 1615 final float fraction; 1616 if (SwipeableListView.ENABLE_ATTACHMENT_PARALLAX) { 1617 final View listView = getListView(); 1618 final View listItemView = unwrap(); 1619 if (mParallaxDirectionAlternative) { 1620 fraction = 1 - (float) listItemView.getBottom() 1621 / (listView.getHeight() + listItemView.getHeight()); 1622 } else { 1623 fraction = (float) listItemView.getBottom() 1624 / (listView.getHeight() + listItemView.getHeight()); 1625 } 1626 } else { 1627 // Vertically center the preview crop, which has already been decoded at 1/3. 1628 fraction = 0.5f; 1629 } 1630 mAttachmentsView.setParallaxFraction(fraction); 1631 mAttachmentsView.draw(canvas); 1632 } 1633 1634 private void drawSubject(Canvas canvas) { 1635 canvas.translate(mCoordinates.subjectX, mCoordinates.subjectY); 1636 mSubjectTextView.draw(canvas); 1637 } 1638 1639 private void drawSenders(Canvas canvas) { 1640 canvas.translate(mCoordinates.sendersX, mCoordinates.sendersY); 1641 mSendersTextView.draw(canvas); 1642 } 1643 1644 private Bitmap getStarBitmap() { 1645 return mHeader.conversation.starred ? STAR_ON : STAR_OFF; 1646 } 1647 1648 private static void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) { 1649 canvas.drawText(s, 0, s.length(), x, y, paint); 1650 } 1651 1652 /** 1653 * Set the background for this item based on: 1654 * 1. Read / Unread (unread messages have a lighter background) 1655 * 2. Tablet / Phone 1656 * 3. Checkbox checked / Unchecked (controls CAB color for item) 1657 * 4. Activated / Not activated (controls the blue highlight on tablet) 1658 * @param isUnread 1659 */ 1660 private void updateBackground(boolean isUnread) { 1661 final int background; 1662 if (mBackgroundOverrideResId > 0) { 1663 background = mBackgroundOverrideResId; 1664 } else if (isUnread) { 1665 background = R.drawable.conversation_unread_selector; 1666 } else { 1667 background = R.drawable.conversation_read_selector; 1668 } 1669 setBackgroundResource(background); 1670 } 1671 1672 /** 1673 * Toggle the check mark on this view and update the conversation or begin 1674 * drag, if drag is enabled. 1675 */ 1676 @Override 1677 public boolean toggleSelectedStateOrBeginDrag() { 1678 ViewMode mode = mActivity.getViewMode(); 1679 if (mIsExpansiveTablet && mode.isListMode()) { 1680 return beginDragMode(); 1681 } else { 1682 return toggleSelectedState("long_press"); 1683 } 1684 } 1685 1686 @Override 1687 public boolean toggleSelectedState() { 1688 return toggleSelectedState(null); 1689 } 1690 1691 private boolean toggleSelectedState(String sourceOpt) { 1692 if (mHeader != null && mHeader.conversation != null && mSelectedConversationSet != null) { 1693 mSelected = !mSelected; 1694 setSelected(mSelected); 1695 Conversation conv = mHeader.conversation; 1696 // Set the list position of this item in the conversation 1697 SwipeableListView listView = getListView(); 1698 conv.position = mSelected && listView != null ? listView.getPositionForView(this) 1699 : Conversation.NO_POSITION; 1700 1701 if (mSelectedConversationSet.isEmpty()) { 1702 final String source = (sourceOpt != null) ? sourceOpt : "checkbox"; 1703 Analytics.getInstance().sendEvent("enter_cab_mode", source, null, 0); 1704 } 1705 1706 mSelectedConversationSet.toggle(conv); 1707 if (mSelectedConversationSet.isEmpty()) { 1708 listView.commitDestructiveActions(true); 1709 } 1710 1711 final boolean reverse = !mSelected; 1712 mPhotoFlipAnimator.startAnimation(reverse); 1713 mPhotoFlipAnimator.invalidateArea(); 1714 1715 // We update the background after the checked state has changed 1716 // now that we have a selected background asset. Setting the background 1717 // usually waits for a layout pass, but we don't need a full layout, 1718 // just an update to the background. 1719 requestLayout(); 1720 1721 return true; 1722 } 1723 1724 return false; 1725 } 1726 1727 /** 1728 * Toggle the star on this view and update the conversation. 1729 */ 1730 public void toggleStar() { 1731 mHeader.conversation.starred = !mHeader.conversation.starred; 1732 Bitmap starBitmap = getStarBitmap(); 1733 postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX 1734 + starBitmap.getWidth(), 1735 mCoordinates.starY + starBitmap.getHeight()); 1736 ConversationCursor cursor = (ConversationCursor) mAdapter.getCursor(); 1737 if (cursor != null) { 1738 // TODO(skennedy) What about ads? 1739 cursor.updateBoolean(mHeader.conversation, ConversationColumns.STARRED, 1740 mHeader.conversation.starred); 1741 } 1742 } 1743 1744 private boolean isTouchInContactPhoto(float x, float y) { 1745 // Everything before the right edge of contact photo 1746 1747 final int threshold = mCoordinates.contactImagesX + mCoordinates.contactImagesWidth 1748 + sSenderImageTouchSlop; 1749 1750 // Allow touching a little right of the contact photo when we're already in selection mode 1751 final float extra; 1752 if (mSelectedConversationSet == null || mSelectedConversationSet.isEmpty()) { 1753 extra = 0; 1754 } else { 1755 extra = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16, 1756 getResources().getDisplayMetrics()); 1757 } 1758 1759 return mHeader.gadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO 1760 && x < (threshold + extra) 1761 && (!isAttachmentPreviewsEnabled() || y < mCoordinates.attachmentPreviewsY); 1762 } 1763 1764 private boolean isTouchInInfoIcon(final float x, final float y) { 1765 if (mHeader.infoIcon == null) { 1766 // We have no info icon 1767 return false; 1768 } 1769 1770 // Regardless of device, we always want to be right of the date's left touch slop 1771 if (x < mDateX - sStarTouchSlop) { 1772 return false; 1773 } 1774 1775 if (mStarEnabled) { 1776 if (mIsExpansiveTablet) { 1777 // Just check that we're left of the star's touch area 1778 if (x >= mCoordinates.starX - sStarTouchSlop) { 1779 return false; 1780 } 1781 } else { 1782 // We're on a phone or non-expansive tablet 1783 1784 // We allow touches all the way to the right edge, so no x check is necessary 1785 1786 // We need to be above the star's touch area, which ends at the top of the subject 1787 // text 1788 return y < mCoordinates.subjectY; 1789 } 1790 } 1791 1792 // With no star below the info icon, we allow touches anywhere from the top edge to the 1793 // bottom edge, or to the top of the attachment previews, whichever is higher 1794 return !isAttachmentPreviewsEnabled() || y < mCoordinates.attachmentPreviewsY; 1795 } 1796 1797 private boolean isTouchInStar(float x, float y) { 1798 if (mHeader.infoIcon != null && !mIsExpansiveTablet) { 1799 // We have an info icon, and it's above the star 1800 // We allow touches everywhere below the top of the subject text 1801 if (y < mCoordinates.subjectY) { 1802 return false; 1803 } 1804 } 1805 1806 // Everything after the star and include a touch slop. 1807 return mStarEnabled 1808 && x > mCoordinates.starX - sStarTouchSlop 1809 && (!isAttachmentPreviewsEnabled() || y < mCoordinates.attachmentPreviewsY); 1810 } 1811 1812 @Override 1813 public boolean canChildBeDismissed() { 1814 return true; 1815 } 1816 1817 @Override 1818 public void dismiss() { 1819 SwipeableListView listView = getListView(); 1820 if (listView != null) { 1821 getListView().dismissChild(this); 1822 } 1823 } 1824 1825 private boolean onTouchEventNoSwipe(MotionEvent event) { 1826 Utils.traceBeginSection("on touch event no swipe"); 1827 boolean handled = false; 1828 1829 int x = (int) event.getX(); 1830 int y = (int) event.getY(); 1831 mLastTouchX = x; 1832 mLastTouchY = y; 1833 switch (event.getAction()) { 1834 case MotionEvent.ACTION_DOWN: 1835 if (isTouchInContactPhoto(x, y) || isTouchInInfoIcon(x, y) || isTouchInStar(x, y)) { 1836 mDownEvent = true; 1837 handled = true; 1838 } 1839 break; 1840 1841 case MotionEvent.ACTION_CANCEL: 1842 mDownEvent = false; 1843 break; 1844 1845 case MotionEvent.ACTION_UP: 1846 if (mDownEvent) { 1847 if (isTouchInContactPhoto(x, y)) { 1848 // Touch on the check mark 1849 toggleSelectedState(); 1850 } else if (isTouchInInfoIcon(x, y)) { 1851 if (mConversationItemAreaClickListener != null) { 1852 mConversationItemAreaClickListener.onInfoIconClicked(); 1853 } 1854 } else if (isTouchInStar(x, y)) { 1855 // Touch on the star 1856 if (mConversationItemAreaClickListener == null) { 1857 toggleStar(); 1858 } else { 1859 mConversationItemAreaClickListener.onStarClicked(); 1860 } 1861 } 1862 handled = true; 1863 } 1864 break; 1865 } 1866 1867 if (!handled) { 1868 handled = super.onTouchEvent(event); 1869 } 1870 1871 Utils.traceEndSection(); 1872 return handled; 1873 } 1874 1875 /** 1876 * ConversationItemView is given the first chance to handle touch events. 1877 */ 1878 @Override 1879 public boolean onTouchEvent(MotionEvent event) { 1880 Utils.traceBeginSection("on touch event"); 1881 int x = (int) event.getX(); 1882 int y = (int) event.getY(); 1883 mLastTouchX = x; 1884 mLastTouchY = y; 1885 if (!mSwipeEnabled) { 1886 Utils.traceEndSection(); 1887 return onTouchEventNoSwipe(event); 1888 } 1889 switch (event.getAction()) { 1890 case MotionEvent.ACTION_DOWN: 1891 if (isTouchInContactPhoto(x, y) || isTouchInInfoIcon(x, y) || isTouchInStar(x, y)) { 1892 mDownEvent = true; 1893 Utils.traceEndSection(); 1894 return true; 1895 } 1896 break; 1897 case MotionEvent.ACTION_UP: 1898 if (mDownEvent) { 1899 if (isTouchInContactPhoto(x, y)) { 1900 // Touch on the check mark 1901 Utils.traceEndSection(); 1902 mDownEvent = false; 1903 toggleSelectedState(); 1904 Utils.traceEndSection(); 1905 return true; 1906 } else if (isTouchInInfoIcon(x, y)) { 1907 // Touch on the info icon 1908 mDownEvent = false; 1909 if (mConversationItemAreaClickListener != null) { 1910 mConversationItemAreaClickListener.onInfoIconClicked(); 1911 } 1912 Utils.traceEndSection(); 1913 return true; 1914 } else if (isTouchInStar(x, y)) { 1915 // Touch on the star 1916 mDownEvent = false; 1917 if (mConversationItemAreaClickListener == null) { 1918 toggleStar(); 1919 } else { 1920 mConversationItemAreaClickListener.onStarClicked(); 1921 } 1922 Utils.traceEndSection(); 1923 return true; 1924 } 1925 } 1926 break; 1927 } 1928 // Let View try to handle it as well. 1929 boolean handled = super.onTouchEvent(event); 1930 if (event.getAction() == MotionEvent.ACTION_DOWN) { 1931 Utils.traceEndSection(); 1932 return true; 1933 } 1934 Utils.traceEndSection(); 1935 return handled; 1936 } 1937 1938 @Override 1939 public boolean performClick() { 1940 final boolean handled = super.performClick(); 1941 final SwipeableListView list = getListView(); 1942 if (!handled && list != null && list.getAdapter() != null) { 1943 final int pos = list.findConversation(this, mHeader.conversation); 1944 list.performItemClick(this, pos, mHeader.conversation.id); 1945 } 1946 return handled; 1947 } 1948 1949 private View unwrap() { 1950 final ViewParent vp = getParent(); 1951 if (vp == null || !(vp instanceof View)) { 1952 return null; 1953 } 1954 return (View) vp; 1955 } 1956 1957 private SwipeableListView getListView() { 1958 SwipeableListView v = null; 1959 final View wrapper = unwrap(); 1960 if (wrapper != null && wrapper instanceof SwipeableConversationItemView) { 1961 v = (SwipeableListView) ((SwipeableConversationItemView) wrapper).getListView(); 1962 } 1963 if (v == null) { 1964 v = mAdapter.getListView(); 1965 } 1966 return v; 1967 } 1968 1969 /** 1970 * Reset any state associated with this conversation item view so that it 1971 * can be reused. 1972 */ 1973 public void reset() { 1974 Utils.traceBeginSection("reset"); 1975 setAlpha(1f); 1976 setTranslationX(0f); 1977 mAnimatedHeightFraction = 1.0f; 1978 Utils.traceEndSection(); 1979 } 1980 1981 @SuppressWarnings("deprecation") 1982 @Override 1983 public void setTranslationX(float translationX) { 1984 super.setTranslationX(translationX); 1985 1986 // When a list item is being swiped or animated, ensure that the hosting view has a 1987 // background color set. We only enable the background during the X-translation effect to 1988 // reduce overdraw during normal list scrolling. 1989 final View parent = (View) getParent(); 1990 if (parent == null) { 1991 LogUtils.w(LOG_TAG, "CIV.setTranslationX null ConversationItemView parent x=%s", 1992 translationX); 1993 } 1994 1995 if (parent instanceof SwipeableConversationItemView) { 1996 if (translationX != 0f) { 1997 parent.setBackgroundResource(R.color.swiped_bg_color); 1998 } else { 1999 parent.setBackgroundDrawable(null); 2000 } 2001 } 2002 } 2003 2004 /** 2005 * Grow the height of the item and fade it in when bringing a conversation 2006 * back from a destructive action. 2007 */ 2008 public Animator createSwipeUndoAnimation() { 2009 ObjectAnimator undoAnimator = createTranslateXAnimation(true); 2010 return undoAnimator; 2011 } 2012 2013 /** 2014 * Grow the height of the item and fade it in when bringing a conversation 2015 * back from a destructive action. 2016 */ 2017 public Animator createUndoAnimation() { 2018 ObjectAnimator height = createHeightAnimation(true); 2019 Animator fade = ObjectAnimator.ofFloat(this, "alpha", 0, 1.0f); 2020 fade.setDuration(sShrinkAnimationDuration); 2021 fade.setInterpolator(new DecelerateInterpolator(2.0f)); 2022 AnimatorSet transitionSet = new AnimatorSet(); 2023 transitionSet.playTogether(height, fade); 2024 transitionSet.addListener(new HardwareLayerEnabler(this)); 2025 return transitionSet; 2026 } 2027 2028 /** 2029 * Grow the height of the item and fade it in when bringing a conversation 2030 * back from a destructive action. 2031 */ 2032 public Animator createDestroyWithSwipeAnimation() { 2033 ObjectAnimator slide = createTranslateXAnimation(false); 2034 ObjectAnimator height = createHeightAnimation(false); 2035 AnimatorSet transitionSet = new AnimatorSet(); 2036 transitionSet.playSequentially(slide, height); 2037 return transitionSet; 2038 } 2039 2040 private ObjectAnimator createTranslateXAnimation(boolean show) { 2041 SwipeableListView parent = getListView(); 2042 // If we can't get the parent...we have bigger problems. 2043 int width = parent != null ? parent.getMeasuredWidth() : 0; 2044 final float start = show ? width : 0f; 2045 final float end = show ? 0f : width; 2046 ObjectAnimator slide = ObjectAnimator.ofFloat(this, "translationX", start, end); 2047 slide.setInterpolator(new DecelerateInterpolator(2.0f)); 2048 slide.setDuration(sSlideAnimationDuration); 2049 return slide; 2050 } 2051 2052 public Animator createDestroyAnimation() { 2053 return createHeightAnimation(false); 2054 } 2055 2056 private ObjectAnimator createHeightAnimation(boolean show) { 2057 final float start = show ? 0f : 1.0f; 2058 final float end = show ? 1.0f : 0f; 2059 ObjectAnimator height = ObjectAnimator.ofFloat(this, "animatedHeightFraction", start, end); 2060 height.setInterpolator(new DecelerateInterpolator(2.0f)); 2061 height.setDuration(sShrinkAnimationDuration); 2062 return height; 2063 } 2064 2065 // Used by animator 2066 public void setAnimatedHeightFraction(float height) { 2067 mAnimatedHeightFraction = height; 2068 requestLayout(); 2069 } 2070 2071 @Override 2072 public SwipeableView getSwipeableView() { 2073 return SwipeableView.from(this); 2074 } 2075 2076 /** 2077 * Begin drag mode. Keep the conversation selected (NOT toggle selection) and start drag. 2078 */ 2079 private boolean beginDragMode() { 2080 if (mLastTouchX < 0 || mLastTouchY < 0 || mSelectedConversationSet == null) { 2081 return false; 2082 } 2083 // If this is already checked, don't bother unchecking it! 2084 if (!mSelected) { 2085 toggleSelectedState(); 2086 } 2087 2088 // Clip data has form: [conversations_uri, conversationId1, 2089 // maxMessageId1, label1, conversationId2, maxMessageId2, label2, ...] 2090 final int count = mSelectedConversationSet.size(); 2091 String description = Utils.formatPlural(mContext, R.plurals.move_conversation, count); 2092 2093 final ClipData data = ClipData.newUri(mContext.getContentResolver(), description, 2094 Conversation.MOVE_CONVERSATIONS_URI); 2095 for (Conversation conversation : mSelectedConversationSet.values()) { 2096 data.addItem(new Item(String.valueOf(conversation.position))); 2097 } 2098 // Protect against non-existent views: only happens for monkeys 2099 final int width = this.getWidth(); 2100 final int height = this.getHeight(); 2101 final boolean isDimensionNegative = (width < 0) || (height < 0); 2102 if (isDimensionNegative) { 2103 LogUtils.e(LOG_TAG, "ConversationItemView: dimension is negative: " 2104 + "width=%d, height=%d", width, height); 2105 return false; 2106 } 2107 mActivity.startDragMode(); 2108 // Start drag mode 2109 startDrag(data, new ShadowBuilder(this, count, mLastTouchX, mLastTouchY), null, 0); 2110 2111 return true; 2112 } 2113 2114 /** 2115 * Handles the drag event. 2116 * 2117 * @param event the drag event to be handled 2118 */ 2119 @Override 2120 public boolean onDragEvent(DragEvent event) { 2121 switch (event.getAction()) { 2122 case DragEvent.ACTION_DRAG_ENDED: 2123 mActivity.stopDragMode(); 2124 return true; 2125 } 2126 return false; 2127 } 2128 2129 private class ShadowBuilder extends DragShadowBuilder { 2130 private final Drawable mBackground; 2131 2132 private final View mView; 2133 private final String mDragDesc; 2134 private final int mTouchX; 2135 private final int mTouchY; 2136 private int mDragDescX; 2137 private int mDragDescY; 2138 2139 public ShadowBuilder(View view, int count, int touchX, int touchY) { 2140 super(view); 2141 mView = view; 2142 mBackground = mView.getResources().getDrawable(R.drawable.list_pressed_holo); 2143 mDragDesc = Utils.formatPlural(mView.getContext(), R.plurals.move_conversation, count); 2144 mTouchX = touchX; 2145 mTouchY = touchY; 2146 } 2147 2148 @Override 2149 public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) { 2150 final int width = mView.getWidth(); 2151 final int height = mView.getHeight(); 2152 2153 sPaint.setTextSize(mCoordinates.subjectFontSize); 2154 mDragDescX = mCoordinates.sendersX; 2155 mDragDescY = (height - (int) mCoordinates.subjectFontSize) / 2 ; 2156 shadowSize.set(width, height); 2157 shadowTouchPoint.set(mTouchX, mTouchY); 2158 } 2159 2160 @Override 2161 public void onDrawShadow(Canvas canvas) { 2162 mBackground.setBounds(0, 0, mView.getWidth(), mView.getHeight()); 2163 mBackground.draw(canvas); 2164 sPaint.setTextSize(mCoordinates.subjectFontSize); 2165 canvas.drawText(mDragDesc, mDragDescX, mDragDescY - sPaint.ascent(), sPaint); 2166 } 2167 } 2168 2169 @Override 2170 public float getMinAllowScrollDistance() { 2171 return sScrollSlop; 2172 } 2173 2174 private abstract class CabAnimator { 2175 private ObjectAnimator mAnimator = null; 2176 2177 private final String mPropertyName; 2178 2179 private float mValue; 2180 2181 private final float mStartValue; 2182 private final float mEndValue; 2183 2184 private final long mDuration; 2185 2186 private boolean mReversing = false; 2187 2188 public CabAnimator(final String propertyName, final float startValue, final float endValue, 2189 final long duration) { 2190 mPropertyName = propertyName; 2191 2192 mStartValue = startValue; 2193 mEndValue = endValue; 2194 2195 mDuration = duration; 2196 } 2197 2198 private ObjectAnimator createAnimator() { 2199 final ObjectAnimator animator = ObjectAnimator.ofFloat(ConversationItemView.this, 2200 mPropertyName, mStartValue, mEndValue); 2201 animator.setDuration(mDuration); 2202 animator.setInterpolator(new LinearInterpolator()); 2203 animator.addListener(new AnimatorListenerAdapter() { 2204 @Override 2205 public void onAnimationEnd(final Animator animation) { 2206 invalidateArea(); 2207 } 2208 }); 2209 animator.addListener(mAnimatorListener); 2210 return animator; 2211 } 2212 2213 private final AnimatorListener mAnimatorListener = new AnimatorListener() { 2214 @Override 2215 public void onAnimationStart(final Animator animation) { 2216 // Do nothing 2217 } 2218 2219 @Override 2220 public void onAnimationEnd(final Animator animation) { 2221 if (mReversing) { 2222 mReversing = false; 2223 // We no longer want to track whether we were last selected, 2224 // since we no longer are selected 2225 mLastSelectedId = -1; 2226 } 2227 } 2228 2229 @Override 2230 public void onAnimationCancel(final Animator animation) { 2231 // Do nothing 2232 } 2233 2234 @Override 2235 public void onAnimationRepeat(final Animator animation) { 2236 // Do nothing 2237 } 2238 }; 2239 2240 public abstract void invalidateArea(); 2241 2242 public void setValue(final float fraction) { 2243 if (mValue == fraction) { 2244 return; 2245 } 2246 mValue = fraction; 2247 invalidateArea(); 2248 } 2249 2250 public float getValue() { 2251 return mValue; 2252 } 2253 2254 /** 2255 * @param reverse <code>true</code> to animate in reverse 2256 */ 2257 public void startAnimation(final boolean reverse) { 2258 if (mAnimator != null) { 2259 mAnimator.cancel(); 2260 } 2261 2262 mAnimator = createAnimator(); 2263 mReversing = reverse; 2264 2265 if (reverse) { 2266 mAnimator.reverse(); 2267 } else { 2268 mAnimator.start(); 2269 } 2270 } 2271 2272 public void stopAnimation() { 2273 if (mAnimator != null) { 2274 mAnimator.cancel(); 2275 mAnimator = null; 2276 } 2277 2278 mReversing = false; 2279 2280 setValue(0); 2281 } 2282 2283 public boolean isStarted() { 2284 return mAnimator != null && mAnimator.isStarted(); 2285 } 2286 } 2287 2288 public void setPhotoFlipFraction(final float fraction) { 2289 mPhotoFlipAnimator.setValue(fraction); 2290 } 2291 2292 public String getAccount() { 2293 return mAccount; 2294 } 2295} 2296