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