ConversationItemView.java revision 972f263eb00e9d19a294b0a4c512a87a10995ba4
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 if (mSendersWidth < 0) { 777 mSendersWidth = 0; 778 } 779 totalWidth = ellipsize(fixedWidth, sendersY); 780 mHeader.sendersDisplayLayout = new StaticLayout(mHeader.sendersDisplayText, sPaint, 781 mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 782 } 783 784 sPaint.setTextSize(mCoordinates.sendersFontSize); 785 sPaint.setTypeface(Typeface.DEFAULT); 786 if (mSendersWidth < 0) { 787 mSendersWidth = 0; 788 } 789 790 pauseTimer(PERF_TAG_CALCULATE_COORDINATES); 791 } 792 793 // The rules for displaying ellipsized senders are as follows: 794 // 1) If there is message info (either a COUNT or DRAFT info to display), it MUST be shown 795 // 2) If senders do not fit, ellipsize the last one that does fit, and stop 796 // appending new senders 797 private int ellipsizeStyledSenders() { 798 SpannableStringBuilder builder = new SpannableStringBuilder(); 799 float totalWidth = 0; 800 boolean ellipsize = false; 801 float width; 802 SpannableStringBuilder messageInfoString = mHeader.messageInfoString; 803 if (messageInfoString.length() > 0) { 804 CharacterStyle[] spans = messageInfoString.getSpans(0, messageInfoString.length(), 805 CharacterStyle.class); 806 // There is only 1 character style span; make sure we apply all the 807 // styles to the paint object before measuring. 808 if (spans.length > 0) { 809 spans[0].updateDrawState(sPaint); 810 } 811 // Paint the message info string to see if we lose space. 812 float messageInfoWidth = sPaint.measureText(messageInfoString.toString()); 813 totalWidth += messageInfoWidth; 814 } 815 SpannableString prevSender = null; 816 SpannableString ellipsizedText; 817 for (SpannableString sender : mHeader.styledSenders) { 818 // There may be null sender strings if there were dupes we had to remove. 819 if (sender == null) { 820 continue; 821 } 822 // No more width available, we'll only show fixed fragments. 823 if (ellipsize) { 824 break; 825 } 826 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 827 // There is only 1 character style span. 828 if (spans.length > 0) { 829 spans[0].updateDrawState(sPaint); 830 } 831 // If there are already senders present in this string, we need to 832 // make sure we prepend the dividing token 833 if (SendersView.sElidedString.equals(sender.toString())) { 834 prevSender = sender; 835 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken); 836 } else if (builder.length() > 0 837 && (prevSender == null || !SendersView.sElidedString.equals(prevSender 838 .toString()))) { 839 prevSender = sender; 840 sender = copyStyles(spans, sSendersSplitToken + sender); 841 } else { 842 prevSender = sender; 843 } 844 if (spans.length > 0) { 845 spans[0].updateDrawState(sPaint); 846 } 847 // Measure the width of the current sender and make sure we have space 848 width = (int) sPaint.measureText(sender.toString()); 849 if (width + totalWidth > mSendersWidth) { 850 // The text is too long, new line won't help. We have to 851 // ellipsize text. 852 ellipsize = true; 853 width = mSendersWidth - totalWidth; // ellipsis width? 854 ellipsizedText = copyStyles(spans, 855 TextUtils.ellipsize(sender, sPaint, width, TruncateAt.END)); 856 width = (int) sPaint.measureText(ellipsizedText.toString()); 857 } else { 858 ellipsizedText = null; 859 } 860 totalWidth += width; 861 862 final CharSequence fragmentDisplayText; 863 if (ellipsizedText != null) { 864 fragmentDisplayText = ellipsizedText; 865 } else { 866 fragmentDisplayText = sender; 867 } 868 builder.append(fragmentDisplayText); 869 } 870 mHeader.styledMessageInfoStringOffset = builder.length(); 871 if (messageInfoString != null) { 872 builder.append(messageInfoString); 873 } 874 mHeader.styledSendersString = builder; 875 return (int)totalWidth; 876 } 877 878 private SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 879 SpannableString s = new SpannableString(newText); 880 if (spans != null && spans.length > 0) { 881 s.setSpan(spans[0], 0, s.length(), 0); 882 } 883 return s; 884 } 885 886 private int ellipsize(int fixedWidth, int sendersY) { 887 int totalWidth = 0; 888 int currentLine = 1; 889 boolean ellipsize = false; 890 for (SenderFragment senderFragment : mHeader.senderFragments) { 891 CharacterStyle style = senderFragment.style; 892 int start = senderFragment.start; 893 int end = senderFragment.end; 894 int width = senderFragment.width; 895 boolean isFixed = senderFragment.isFixed; 896 style.updateDrawState(sPaint); 897 898 // No more width available, we'll only show fixed fragments. 899 if (ellipsize && !isFixed) { 900 senderFragment.shouldDisplay = false; 901 continue; 902 } 903 904 // New line and ellipsize text if needed. 905 senderFragment.ellipsizedText = null; 906 if (isFixed) { 907 fixedWidth -= width; 908 } 909 if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) { 910 // The text is too long, new line won't help. We have to 911 // ellipsize text. 912 if (totalWidth == 0) { 913 ellipsize = true; 914 } else { 915 // New line. 916 if (currentLine < mCoordinates.sendersLineCount) { 917 currentLine++; 918 sendersY += mCoordinates.sendersLineHeight; 919 totalWidth = 0; 920 // The text is still too long, we have to ellipsize 921 // text. 922 if (totalWidth + width > mSendersWidth) { 923 ellipsize = true; 924 } 925 } else { 926 ellipsize = true; 927 } 928 } 929 930 if (ellipsize) { 931 width = mSendersWidth - totalWidth; 932 // No more new line, we have to reserve width for fixed 933 // fragments. 934 if (currentLine == mCoordinates.sendersLineCount) { 935 width -= fixedWidth; 936 } 937 senderFragment.ellipsizedText = TextUtils.ellipsize( 938 mHeader.sendersText.substring(start, end), sPaint, width, 939 TruncateAt.END).toString(); 940 width = (int) sPaint.measureText(senderFragment.ellipsizedText); 941 } 942 } 943 senderFragment.shouldDisplay = true; 944 totalWidth += width; 945 946 final CharSequence fragmentDisplayText; 947 if (senderFragment.ellipsizedText != null) { 948 fragmentDisplayText = senderFragment.ellipsizedText; 949 } else { 950 fragmentDisplayText = mHeader.sendersText.substring(start, end); 951 } 952 final int spanStart = mHeader.sendersDisplayText.length(); 953 mHeader.sendersDisplayText.append(fragmentDisplayText); 954 mHeader.sendersDisplayText.setSpan(senderFragment.style, spanStart, 955 mHeader.sendersDisplayText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 956 } 957 return totalWidth; 958 } 959 960 /** 961 * If the subject contains the tag of a mailing-list (text surrounded with 962 * []), return the subject with that tag ellipsized, e.g. 963 * "[android-gmail-team] Hello" -> "[andr...] Hello" 964 */ 965 private String filterTag(String subject) { 966 String result = subject; 967 String formatString = getContext().getResources().getString(R.string.filtered_tag); 968 if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') { 969 int end = subject.indexOf(']'); 970 if (end > 0) { 971 String tag = subject.substring(1, end); 972 result = String.format(formatString, Utils.ellipsize(tag, 7), 973 subject.substring(end + 1)); 974 } 975 } 976 return result; 977 } 978 979 @Override 980 protected void onDraw(Canvas canvas) { 981 // Check mark. 982 if (mHeader.checkboxVisible) { 983 Bitmap checkmark = mChecked ? CHECKMARK_ON : CHECKMARK_OFF; 984 canvas.drawBitmap(checkmark, mCoordinates.checkmarkX, mCoordinates.checkmarkY, sPaint); 985 } 986 987 // Personal Level. 988 if (mCoordinates.showPersonalLevel && mHeader.personalLevelBitmap != null) { 989 canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalLevelX, 990 mCoordinates.personalLevelY, sPaint); 991 } 992 993 // Senders. 994 boolean isUnread = mHeader.unread; 995 // Old style senders; apply text colors/ sizes/ styling. 996 canvas.save(); 997 if (mHeader.sendersDisplayLayout != null) { 998 sPaint.setTextSize(mCoordinates.sendersFontSize); 999 sPaint.setTypeface(SendersView.getTypeface(isUnread)); 1000 sPaint.setColor(getFontColor(isUnread ? sSendersTextColorUnread 1001 : sSendersTextColorRead)); 1002 canvas.translate(mCoordinates.sendersX, 1003 mCoordinates.sendersY + mHeader.sendersDisplayLayout.getTopPadding()); 1004 mHeader.sendersDisplayLayout.draw(canvas); 1005 } else { 1006 canvas.translate(mCoordinates.sendersX, 1007 mCoordinates.sendersY + sSendersTextViewTopPadding); 1008 mHeader.sendersTextView.layout(0, 0, mSendersWidth, sSendersTextViewHeight); 1009 mHeader.sendersTextView.draw(canvas); 1010 } 1011 canvas.restore(); 1012 1013 1014 // Subject. 1015 sPaint.setTextSize(mCoordinates.subjectFontSize); 1016 sPaint.setTypeface(Typeface.DEFAULT); 1017 canvas.save(); 1018 if (isActivated() && showActivatedText()) { 1019 if (mHeader.subjectLayoutActivated != null) { 1020 canvas.translate(mCoordinates.subjectX, mCoordinates.subjectY 1021 + mHeader.subjectLayoutActivated.getTopPadding()); 1022 mHeader.subjectLayoutActivated.draw(canvas); 1023 } 1024 } else if (mHeader.subjectLayout != null) { 1025 canvas.translate(mCoordinates.subjectX, 1026 mCoordinates.subjectY + mHeader.subjectLayout.getTopPadding()); 1027 mHeader.subjectLayout.draw(canvas); 1028 } 1029 canvas.restore(); 1030 1031 // Folders. 1032 if (mCoordinates.showFolders) { 1033 mHeader.folderDisplayer.drawFolders(canvas, mCoordinates, mFoldersXEnd, mMode); 1034 } 1035 1036 // If this folder has a color (combined view/Email), show it here 1037 if (mHeader.conversation.color != 0) { 1038 sFoldersPaint.setColor(mHeader.conversation.color); 1039 sFoldersPaint.setStyle(Paint.Style.FILL); 1040 int width = ConversationItemViewCoordinates.getColorBlockWidth(mContext); 1041 int height = ConversationItemViewCoordinates.getColorBlockHeight(mContext); 1042 canvas.drawRect(mCoordinates.dateXEnd - width, 0, mCoordinates.dateXEnd, 1043 height, sFoldersPaint); 1044 } 1045 1046 // Date background: shown when there is an attachment or a visible 1047 // folder. 1048 if (!isActivated() 1049 && (mHeader.conversation.hasAttachments || 1050 (mHeader.folderDisplayer != null 1051 && mHeader.folderDisplayer.hasVisibleFolders())) 1052 && ConversationItemViewCoordinates.showAttachmentBackground(mMode)) { 1053 int leftOffset = (mHeader.conversation.hasAttachments ? mPaperclipX : mDateX) 1054 - sDateBackgroundPaddingLeft; 1055 int top = mCoordinates.showFolders ? mCoordinates.foldersY : mCoordinates.dateY; 1056 mHeader.dateBackground = getDateBackground(mHeader.conversation.hasAttachments); 1057 canvas.drawBitmap(mHeader.dateBackground, leftOffset, top, sPaint); 1058 } else { 1059 mHeader.dateBackground = null; 1060 } 1061 1062 // Draw the reply state. Draw nothing if neither replied nor forwarded. 1063 if (mCoordinates.showReplyState) { 1064 if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) { 1065 canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX, 1066 mCoordinates.replyStateY, null); 1067 } else if (mHeader.hasBeenRepliedTo) { 1068 canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX, 1069 mCoordinates.replyStateY, null); 1070 } else if (mHeader.hasBeenForwarded) { 1071 canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX, 1072 mCoordinates.replyStateY, null); 1073 } else if (mHeader.isInvite) { 1074 canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX, 1075 mCoordinates.replyStateY, null); 1076 } 1077 } 1078 1079 // Date. 1080 sPaint.setTextSize(mCoordinates.dateFontSize); 1081 sPaint.setTypeface(Typeface.DEFAULT); 1082 sPaint.setColor(sDateTextColor); 1083 drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateY - mCoordinates.dateAscent, 1084 sPaint); 1085 1086 // Paper clip icon. 1087 if (mHeader.paperclip != null) { 1088 canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint); 1089 } 1090 1091 if (mHeader.faded) { 1092 int fadedColor = -1; 1093 if (sFadedActivatedColor == -1) { 1094 sFadedActivatedColor = mContext.getResources().getColor( 1095 R.color.faded_activated_conversation_header); 1096 } 1097 fadedColor = sFadedActivatedColor; 1098 int restoreState = canvas.save(); 1099 Rect bounds = canvas.getClipBounds(); 1100 canvas.clipRect(bounds.left, bounds.top, bounds.right 1101 - mContext.getResources().getDimensionPixelSize(R.dimen.triangle_width), 1102 bounds.bottom); 1103 canvas.drawARGB(Color.alpha(fadedColor), Color.red(fadedColor), 1104 Color.green(fadedColor), Color.blue(fadedColor)); 1105 canvas.restoreToCount(restoreState); 1106 } 1107 1108 // Star. 1109 canvas.drawBitmap(getStarBitmap(), mCoordinates.starX, mCoordinates.starY, sPaint); 1110 } 1111 1112 private Bitmap getStarBitmap() { 1113 return mHeader.conversation.starred ? STAR_ON : STAR_OFF; 1114 } 1115 1116 private Bitmap getDateBackground(boolean hasAttachments) { 1117 int leftOffset = (hasAttachments ? mPaperclipX : mDateX) - sDateBackgroundPaddingLeft; 1118 if (hasAttachments) { 1119 if (sDateBackgroundAttachment == null) { 1120 sDateBackgroundAttachment = Bitmap.createScaledBitmap(DATE_BACKGROUND, mViewWidth 1121 - leftOffset, sDateBackgroundHeight, false); 1122 } 1123 return sDateBackgroundAttachment; 1124 } else { 1125 if (sDateBackgroundNoAttachment == null) { 1126 sDateBackgroundNoAttachment = Bitmap.createScaledBitmap(DATE_BACKGROUND, mViewWidth 1127 - leftOffset, sDateBackgroundHeight, false); 1128 } 1129 return sDateBackgroundNoAttachment; 1130 } 1131 } 1132 1133 private void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) { 1134 canvas.drawText(s, 0, s.length(), x, y, paint); 1135 } 1136 1137 private void updateBackground(boolean isUnread) { 1138 if (mBackgroundOverride != -1) { 1139 // If the item is animating, we use a color to avoid shrinking a 9-patch 1140 // and getting weird artifacts from the overlap. 1141 setBackgroundColor(mBackgroundOverride); 1142 return; 1143 } 1144 final boolean isListOnTablet = mTabletDevice && mActivity.getViewMode().isListMode(); 1145 if (isUnread) { 1146 if (isListOnTablet) { 1147 if (mChecked) { 1148 setBackgroundResource(R.drawable.list_conversation_wide_unread_selected_holo); 1149 } else { 1150 setBackgroundResource(R.drawable.conversation_wide_unread_selector); 1151 } 1152 } else { 1153 if (mChecked) { 1154 setCheckedActivatedBackground(); 1155 } else { 1156 setBackgroundResource(R.drawable.conversation_unread_selector); 1157 } 1158 } 1159 } else { 1160 if (isListOnTablet) { 1161 if (mChecked) { 1162 setBackgroundResource(R.drawable.list_conversation_wide_read_selected_holo); 1163 } else { 1164 setBackgroundResource(R.drawable.conversation_wide_read_selector); 1165 } 1166 } else { 1167 if (mChecked) { 1168 setCheckedActivatedBackground(); 1169 } else { 1170 setBackgroundResource(R.drawable.conversation_read_selector); 1171 } 1172 } 1173 } 1174 } 1175 1176 private void setCheckedActivatedBackground() { 1177 if (isActivated() && mTabletDevice) { 1178 setBackgroundResource(R.drawable.list_arrow_selected_holo); 1179 } else { 1180 setBackgroundResource(R.drawable.list_selected_holo); 1181 } 1182 } 1183 1184 /** 1185 * Toggle the check mark on this view and update the conversation or begin 1186 * drag, if drag is enabled. 1187 */ 1188 public void toggleCheckMarkOrBeginDrag() { 1189 ViewMode mode = mActivity.getViewMode(); 1190 if (!mTabletDevice || !mode.isListMode()) { 1191 toggleCheckMark(); 1192 } else { 1193 beginDragMode(); 1194 } 1195 } 1196 1197 private void toggleCheckMark() { 1198 if (mHeader != null && mHeader.conversation != null) { 1199 mChecked = !mChecked; 1200 Conversation conv = mHeader.conversation; 1201 // Set the list position of this item in the conversation 1202 SwipeableListView listView = getListView(); 1203 conv.position = mChecked && listView != null ? listView.getPositionForView(this) 1204 : Conversation.NO_POSITION; 1205 if (mSelectedConversationSet != null) { 1206 mSelectedConversationSet.toggle(this, conv); 1207 } 1208 if (mSelectedConversationSet.isEmpty()) { 1209 listView.commitDestructiveActions(true); 1210 } 1211 // We update the background after the checked state has changed 1212 // now that we have a selected background asset. Setting the background 1213 // usually waits for a layout pass, but we don't need a full layout, 1214 // just an update to the background. 1215 requestLayout(); 1216 } 1217 } 1218 1219 /** 1220 * Return if the checkbox for this item is checked. 1221 */ 1222 public boolean isChecked() { 1223 return mChecked; 1224 } 1225 1226 /** 1227 * Toggle the star on this view and update the conversation. 1228 */ 1229 public void toggleStar() { 1230 mHeader.conversation.starred = !mHeader.conversation.starred; 1231 Bitmap starBitmap = getStarBitmap(); 1232 postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX 1233 + starBitmap.getWidth(), 1234 mCoordinates.starY + starBitmap.getHeight()); 1235 ConversationCursor cursor = (ConversationCursor)mAdapter.getCursor(); 1236 cursor.updateBoolean(mContext, mHeader.conversation, ConversationColumns.STARRED, 1237 mHeader.conversation.starred); 1238 } 1239 1240 private boolean isTouchInCheckmark(float x, float y) { 1241 // Everything before senders and include a touch slop. 1242 return mHeader.checkboxVisible && x < mCoordinates.sendersX + sTouchSlop; 1243 } 1244 1245 private boolean isTouchInStar(float x, float y) { 1246 // Everything after the star and include a touch slop. 1247 return x > mCoordinates.starX - sTouchSlop; 1248 } 1249 1250 @Override 1251 public boolean canChildBeDismissed() { 1252 return true; 1253 } 1254 1255 @Override 1256 public void dismiss() { 1257 SwipeableListView listView = getListView(); 1258 if (listView != null) { 1259 getListView().dismissChild(this); 1260 } 1261 } 1262 1263 private boolean onTouchEventNoSwipe(MotionEvent event) { 1264 boolean handled = false; 1265 1266 int x = (int) event.getX(); 1267 int y = (int) event.getY(); 1268 mLastTouchX = x; 1269 mLastTouchY = y; 1270 switch (event.getAction()) { 1271 case MotionEvent.ACTION_DOWN: 1272 if (isTouchInCheckmark(x, y) || isTouchInStar(x, y)) { 1273 mDownEvent = true; 1274 handled = true; 1275 } 1276 break; 1277 1278 case MotionEvent.ACTION_CANCEL: 1279 mDownEvent = false; 1280 break; 1281 1282 case MotionEvent.ACTION_UP: 1283 if (mDownEvent) { 1284 if (isTouchInCheckmark(x, y)) { 1285 // Touch on the check mark 1286 toggleCheckMark(); 1287 } else if (isTouchInStar(x, y)) { 1288 // Touch on the star 1289 toggleStar(); 1290 } 1291 handled = true; 1292 } 1293 break; 1294 } 1295 1296 if (!handled) { 1297 handled = super.onTouchEvent(event); 1298 } 1299 1300 return handled; 1301 } 1302 1303 /** 1304 * ConversationItemView is given the first chance to handle touch events. 1305 */ 1306 @Override 1307 public boolean onTouchEvent(MotionEvent event) { 1308 int x = (int) event.getX(); 1309 int y = (int) event.getY(); 1310 mLastTouchX = x; 1311 mLastTouchY = y; 1312 if (!mSwipeEnabled) { 1313 return onTouchEventNoSwipe(event); 1314 } 1315 switch (event.getAction()) { 1316 case MotionEvent.ACTION_DOWN: 1317 if (isTouchInCheckmark(x, y) || isTouchInStar(x, y)) { 1318 mDownEvent = true; 1319 return true; 1320 } 1321 break; 1322 case MotionEvent.ACTION_UP: 1323 if (mDownEvent) { 1324 if (isTouchInCheckmark(x, y)) { 1325 // Touch on the check mark 1326 mDownEvent = false; 1327 toggleCheckMark(); 1328 return true; 1329 } else if (isTouchInStar(x, y)) { 1330 // Touch on the star 1331 mDownEvent = false; 1332 toggleStar(); 1333 return true; 1334 } 1335 } 1336 break; 1337 } 1338 // Let View try to handle it as well. 1339 boolean handled = super.onTouchEvent(event); 1340 if (event.getAction() == MotionEvent.ACTION_DOWN) { 1341 return true; 1342 } 1343 return handled; 1344 } 1345 1346 @Override 1347 public boolean performClick() { 1348 boolean handled = super.performClick(); 1349 SwipeableListView list = getListView(); 1350 if (list != null) { 1351 int pos = list.findConversation(this, mHeader.conversation); 1352 list.performItemClick(this, pos, mHeader.conversation.id); 1353 } 1354 return handled; 1355 } 1356 1357 private SwipeableListView getListView() { 1358 SwipeableListView v = (SwipeableListView) ((SwipeableConversationItemView) getParent()) 1359 .getListView(); 1360 if (v == null) { 1361 v = mAdapter.getListView(); 1362 } 1363 return v; 1364 } 1365 1366 /** 1367 * Reset any state associated with this conversation item view so that it 1368 * can be reused. 1369 */ 1370 public void reset() { 1371 mBackgroundOverride = -1; 1372 setAlpha(1); 1373 setTranslationX(0); 1374 setAnimatedHeight(-1); 1375 setMinimumHeight(ConversationItemViewCoordinates.getMinHeight(mContext, 1376 mActivity.getViewMode())); 1377 } 1378 1379 /** 1380 * Grow the height of the item and fade it in when bringing a conversation 1381 * back from a destructive action. 1382 * @param listener 1383 */ 1384 public void startSwipeUndoAnimation(ViewMode viewMode, final AnimatorListener listener) { 1385 ObjectAnimator undoAnimator = createTranslateXAnimation(true); 1386 undoAnimator.addListener(listener); 1387 undoAnimator.start(); 1388 } 1389 1390 /** 1391 * Grow the height of the item and fade it in when bringing a conversation 1392 * back from a destructive action. 1393 * @param listener 1394 */ 1395 public void startUndoAnimation(ViewMode viewMode, final AnimatorListener listener) { 1396 int minHeight = ConversationItemViewCoordinates.getMinHeight(mContext, viewMode); 1397 setMinimumHeight(minHeight); 1398 mAnimatedHeight = 0; 1399 ObjectAnimator height = createHeightAnimation(true); 1400 Animator fade = ObjectAnimator.ofFloat(this, "itemAlpha", 0, 1.0f); 1401 fade.setDuration(sShrinkAnimationDuration); 1402 fade.setInterpolator(new DecelerateInterpolator(2.0f)); 1403 AnimatorSet transitionSet = new AnimatorSet(); 1404 transitionSet.playTogether(height, fade); 1405 transitionSet.addListener(listener); 1406 transitionSet.start(); 1407 } 1408 1409 /** 1410 * Grow the height of the item and fade it in when bringing a conversation 1411 * back from a destructive action. 1412 * @param listener 1413 */ 1414 public void startDestroyWithSwipeAnimation(final AnimatorListener listener) { 1415 ObjectAnimator slide = createTranslateXAnimation(false); 1416 ObjectAnimator height = createHeightAnimation(false); 1417 AnimatorSet transitionSet = new AnimatorSet(); 1418 transitionSet.playSequentially(slide, height); 1419 transitionSet.addListener(listener); 1420 transitionSet.start(); 1421 } 1422 1423 private ObjectAnimator createTranslateXAnimation(boolean show) { 1424 SwipeableListView parent = getListView(); 1425 // If we can't get the parent...we have bigger problems. 1426 int width = parent != null ? parent.getMeasuredWidth() : 0; 1427 final float start = show ? width : 0f; 1428 final float end = show ? 0f : width; 1429 ObjectAnimator slide = ObjectAnimator.ofFloat(this, "translationX", start, end); 1430 slide.setInterpolator(new DecelerateInterpolator(2.0f)); 1431 slide.setDuration(sSlideAnimationDuration); 1432 return slide; 1433 } 1434 1435 public void startDestroyAnimation(final AnimatorListener listener) { 1436 ObjectAnimator height = createHeightAnimation(false); 1437 int minHeight = ConversationItemViewCoordinates.getMinHeight(mContext, 1438 mActivity.getViewMode()); 1439 setMinimumHeight(0); 1440 mBackgroundOverride = sAnimatingBackgroundColor; 1441 setBackgroundColor(mBackgroundOverride); 1442 mAnimatedHeight = minHeight; 1443 height.addListener(listener); 1444 height.start(); 1445 } 1446 1447 private ObjectAnimator createHeightAnimation(boolean show) { 1448 int minHeight = ConversationItemViewCoordinates.getMinHeight(getContext(), 1449 mActivity.getViewMode()); 1450 final int start = show ? 0 : minHeight; 1451 final int end = show ? minHeight : 0; 1452 ObjectAnimator height = ObjectAnimator.ofInt(this, "animatedHeight", start, end); 1453 height.setInterpolator(new DecelerateInterpolator(2.0f)); 1454 height.setDuration(sShrinkAnimationDuration); 1455 return height; 1456 } 1457 1458 // Used by animator 1459 @SuppressWarnings("unused") 1460 public void setItemAlpha(float alpha) { 1461 setAlpha(alpha); 1462 invalidate(); 1463 } 1464 1465 // Used by animator 1466 @SuppressWarnings("unused") 1467 public void setAnimatedHeight(int height) { 1468 mAnimatedHeight = height; 1469 requestLayout(); 1470 } 1471 1472 @Override 1473 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1474 if (mAnimatedHeight == -1) { 1475 int height = measureHeight(heightMeasureSpec, 1476 ConversationItemViewCoordinates.getMode(mContext, mActivity.getViewMode())); 1477 setMeasuredDimension(widthMeasureSpec, height); 1478 } else { 1479 setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mAnimatedHeight); 1480 } 1481 } 1482 1483 /** 1484 * Determine the height of this view. 1485 * @param measureSpec A measureSpec packed into an int 1486 * @param mode The current mode of this view 1487 * @return The height of the view, honoring constraints from measureSpec 1488 */ 1489 private int measureHeight(int measureSpec, int mode) { 1490 int result = 0; 1491 int specMode = MeasureSpec.getMode(measureSpec); 1492 int specSize = MeasureSpec.getSize(measureSpec); 1493 1494 if (specMode == MeasureSpec.EXACTLY) { 1495 // We were told how big to be 1496 result = specSize; 1497 } else { 1498 // Measure the text 1499 result = ConversationItemViewCoordinates.getHeight(mContext, mode); 1500 if (specMode == MeasureSpec.AT_MOST) { 1501 // Respect AT_MOST value if that was what is called for by 1502 // measureSpec 1503 result = Math.min(result, specSize); 1504 } 1505 } 1506 return result; 1507 } 1508 1509 /** 1510 * Get the current position of this conversation item in the list. 1511 */ 1512 public int getPosition() { 1513 return mHeader != null && mHeader.conversation != null ? 1514 mHeader.conversation.position : -1; 1515 } 1516 1517 @Override 1518 public View getSwipeableView() { 1519 return this; 1520 } 1521 1522 /** 1523 * Begin drag mode. Keep the conversation selected (NOT toggle selection) and start drag. 1524 */ 1525 private void beginDragMode() { 1526 if (mLastTouchX < 0 || mLastTouchY < 0) { 1527 return; 1528 } 1529 // If this is already checked, don't bother unchecking it! 1530 if (!mChecked) { 1531 toggleCheckMark(); 1532 } 1533 1534 // Clip data has form: [conversations_uri, conversationId1, 1535 // maxMessageId1, label1, conversationId2, maxMessageId2, label2, ...] 1536 final int count = mSelectedConversationSet.size(); 1537 String description = Utils.formatPlural(mContext, R.plurals.move_conversation, count); 1538 1539 final ClipData data = ClipData.newUri(mContext.getContentResolver(), description, 1540 Conversation.MOVE_CONVERSATIONS_URI); 1541 for (Conversation conversation : mSelectedConversationSet.values()) { 1542 data.addItem(new Item(String.valueOf(conversation.position))); 1543 } 1544 // Protect against non-existent views: only happens for monkeys 1545 final int width = this.getWidth(); 1546 final int height = this.getHeight(); 1547 final boolean isDimensionNegative = (width < 0) || (height < 0); 1548 if (isDimensionNegative) { 1549 LogUtils.e(LOG_TAG, "ConversationItemView: dimension is negative: " 1550 + "width=%d, height=%d", width, height); 1551 return; 1552 } 1553 mActivity.startDragMode(); 1554 // Start drag mode 1555 startDrag(data, new ShadowBuilder(this, count, mLastTouchX, mLastTouchY), null, 0); 1556 } 1557 1558 /** 1559 * Handles the drag event. 1560 * 1561 * @param event the drag event to be handled 1562 */ 1563 @Override 1564 public boolean onDragEvent(DragEvent event) { 1565 switch (event.getAction()) { 1566 case DragEvent.ACTION_DRAG_ENDED: 1567 mActivity.stopDragMode(); 1568 return true; 1569 } 1570 return false; 1571 } 1572 1573 private class ShadowBuilder extends DragShadowBuilder { 1574 private final Drawable mBackground; 1575 1576 private final View mView; 1577 private final String mDragDesc; 1578 private final int mTouchX; 1579 private final int mTouchY; 1580 private int mDragDescX; 1581 private int mDragDescY; 1582 1583 public ShadowBuilder(View view, int count, int touchX, int touchY) { 1584 super(view); 1585 mView = view; 1586 mBackground = mView.getResources().getDrawable(R.drawable.list_pressed_holo); 1587 mDragDesc = Utils.formatPlural(mView.getContext(), R.plurals.move_conversation, count); 1588 mTouchX = touchX; 1589 mTouchY = touchY; 1590 } 1591 1592 @Override 1593 public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) { 1594 int width = mView.getWidth(); 1595 int height = mView.getHeight(); 1596 mDragDescX = mCoordinates.sendersX; 1597 mDragDescY = getPadding(height, mCoordinates.subjectFontSize) 1598 - mCoordinates.subjectAscent; 1599 shadowSize.set(width, height); 1600 shadowTouchPoint.set(mTouchX, mTouchY); 1601 } 1602 1603 @Override 1604 public void onDrawShadow(Canvas canvas) { 1605 mBackground.setBounds(0, 0, mView.getWidth(), mView.getHeight()); 1606 mBackground.draw(canvas); 1607 sPaint.setTextSize(mCoordinates.subjectFontSize); 1608 canvas.drawText(mDragDesc, mDragDescX, mDragDescY, sPaint); 1609 } 1610 } 1611 1612 @Override 1613 public float getMinAllowScrollDistance() { 1614 return sScrollSlop; 1615 } 1616} 1617