| /* |
| * Copyright (C) 2012 Google Inc. |
| * Licensed to The Android Open Source Project. |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.mail.browse; |
| |
| import android.animation.Animator; |
| import android.animation.Animator.AnimatorListener; |
| import android.animation.AnimatorSet; |
| import android.animation.ObjectAnimator; |
| import android.content.ClipData; |
| import android.content.ClipData.Item; |
| import android.content.Context; |
| import android.content.res.Resources; |
| import android.database.Cursor; |
| import android.graphics.Bitmap; |
| import android.graphics.BitmapFactory; |
| import android.graphics.Canvas; |
| import android.graphics.Color; |
| import android.graphics.LinearGradient; |
| import android.graphics.Paint; |
| import android.graphics.Point; |
| import android.graphics.Rect; |
| import android.graphics.Shader; |
| import android.graphics.Typeface; |
| import android.graphics.drawable.Drawable; |
| import android.text.Layout.Alignment; |
| import android.text.Spannable; |
| import android.text.SpannableString; |
| import android.text.SpannableStringBuilder; |
| import android.text.StaticLayout; |
| import android.text.TextPaint; |
| import android.text.TextUtils; |
| import android.text.TextUtils.TruncateAt; |
| import android.text.format.DateUtils; |
| import android.text.style.CharacterStyle; |
| import android.text.style.ForegroundColorSpan; |
| import android.text.style.StyleSpan; |
| import android.util.SparseArray; |
| import android.view.HapticFeedbackConstants; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.ViewConfiguration; |
| import android.view.animation.DecelerateInterpolator; |
| import android.widget.ListView; |
| |
| import com.android.mail.R; |
| import com.android.mail.browse.ConversationItemViewModel.SenderFragment; |
| import com.android.mail.perf.Timer; |
| import com.android.mail.providers.Conversation; |
| import com.android.mail.providers.Folder; |
| import com.android.mail.providers.UIProvider; |
| import com.android.mail.providers.UIProvider.ConversationColumns; |
| import com.android.mail.ui.AnimatedAdapter; |
| import com.android.mail.ui.ControllableActivity; |
| import com.android.mail.ui.ConversationSelectionSet; |
| import com.android.mail.ui.FolderDisplayer; |
| import com.android.mail.ui.SwipeableItemView; |
| import com.android.mail.ui.ViewMode; |
| import com.android.mail.utils.LogTag; |
| import com.android.mail.utils.LogUtils; |
| import com.android.mail.utils.Utils; |
| import com.google.common.annotations.VisibleForTesting; |
| |
| public class ConversationItemView extends View implements SwipeableItemView { |
| // Timer. |
| private static int sLayoutCount = 0; |
| private static Timer sTimer; // Create the sTimer here if you need to do |
| // perf analysis. |
| private static final int PERF_LAYOUT_ITERATIONS = 50; |
| private static final String PERF_TAG_LAYOUT = "CCHV.layout"; |
| private static final String PERF_TAG_CALCULATE_TEXTS_BITMAPS = "CCHV.txtsbmps"; |
| private static final String PERF_TAG_CALCULATE_SENDER_SUBJECT = "CCHV.sendersubj"; |
| private static final String PERF_TAG_CALCULATE_FOLDERS = "CCHV.folders"; |
| private static final String PERF_TAG_CALCULATE_COORDINATES = "CCHV.coordinates"; |
| private static final String LOG_TAG = LogTag.getLogTag(); |
| |
| // Static bitmaps. |
| private static Bitmap CHECKMARK_OFF; |
| private static Bitmap CHECKMARK_ON; |
| private static Bitmap STAR_OFF; |
| private static Bitmap STAR_ON; |
| private static Bitmap ATTACHMENT; |
| private static Bitmap ONLY_TO_ME; |
| private static Bitmap TO_ME_AND_OTHERS; |
| private static Bitmap IMPORTANT_ONLY_TO_ME; |
| private static Bitmap IMPORTANT_TO_ME_AND_OTHERS; |
| private static Bitmap IMPORTANT_TO_OTHERS; |
| private static Bitmap DATE_BACKGROUND; |
| private static Bitmap STATE_REPLIED; |
| private static Bitmap STATE_FORWARDED; |
| private static Bitmap STATE_REPLIED_AND_FORWARDED; |
| private static Bitmap STATE_CALENDAR_INVITE; |
| |
| private static String sSendersSplitToken; |
| private static String sElidedPaddingToken; |
| private static String sEllipsis; |
| |
| // Static colors. |
| private static int sDefaultTextColor; |
| private static int sActivatedTextColor; |
| private static int sSubjectTextColorRead; |
| private static int sSubjectTextColorUnead; |
| private static int sSnippetTextColorRead; |
| private static int sSnippetTextColorUnread; |
| private static int sSendersTextColorRead; |
| private static int sSendersTextColorUnread; |
| private static int sDateTextColor; |
| private static int sDateBackgroundPaddingLeft; |
| private static int sTouchSlop; |
| private static int sDateBackgroundHeight; |
| private static int sStandardScaledDimen; |
| private static int sShrinkAnimationDuration; |
| private static int sSlideAnimationDuration; |
| |
| // Static paints. |
| private static TextPaint sPaint = new TextPaint(); |
| private static TextPaint sFoldersPaint = new TextPaint(); |
| |
| // Backgrounds for different states. |
| private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>(); |
| |
| // Dimensions and coordinates. |
| private int mViewWidth = -1; |
| private int mMode = -1; |
| private int mDateX; |
| private int mPaperclipX; |
| private int mFoldersXEnd; |
| private int mSendersWidth; |
| |
| /** Whether we're running under test mode. */ |
| private boolean mTesting = false; |
| /** Whether we are on a tablet device or not */ |
| private final boolean mTabletDevice; |
| |
| @VisibleForTesting |
| ConversationItemViewCoordinates mCoordinates; |
| |
| private final Context mContext; |
| |
| public ConversationItemViewModel mHeader; |
| private boolean mDownEvent; |
| private boolean mChecked = false; |
| private static int sFadedActivatedColor = -1; |
| private ConversationSelectionSet mSelectedConversationSet; |
| private Folder mDisplayedFolder; |
| private boolean mPriorityMarkersEnabled; |
| private boolean mCheckboxesEnabled; |
| private HandleLongPress mPendingCheckForLongPress; |
| private boolean mSwipeEnabled; |
| private int mLastTouchX; |
| private int mLastTouchY; |
| private AnimatedAdapter mAdapter; |
| private int mAnimatedHeight = -1; |
| private String mAccount; |
| private Runnable mListItemClick; |
| private ControllableActivity mActivity; |
| private static Bitmap sDateBackgroundAttachment; |
| private static Bitmap sDateBackgroundNoAttachment; |
| private static int sUndoAnimationOffset; |
| private static Bitmap MORE_FOLDERS; |
| |
| static { |
| sPaint.setAntiAlias(true); |
| sFoldersPaint.setAntiAlias(true); |
| } |
| |
| /** |
| * Handles displaying folders in a conversation header view. |
| */ |
| static class ConversationItemFolderDisplayer extends FolderDisplayer { |
| // Maximum number of folders to be displayed. |
| private static final int MAX_DISPLAYED_FOLDERS_COUNT = 4; |
| |
| private int mFoldersCount; |
| private boolean mHasMoreFolders; |
| |
| public ConversationItemFolderDisplayer(Context context) { |
| super(context); |
| } |
| |
| @Override |
| public void loadConversationFolders(Conversation conv, Folder ignoreFolder) { |
| super.loadConversationFolders(conv, ignoreFolder); |
| |
| mFoldersCount = mFoldersSortedSet.size(); |
| mHasMoreFolders = mFoldersCount > MAX_DISPLAYED_FOLDERS_COUNT; |
| mFoldersCount = Math.min(mFoldersCount, MAX_DISPLAYED_FOLDERS_COUNT); |
| } |
| |
| public boolean hasVisibleFolders() { |
| return mFoldersCount > 0; |
| } |
| |
| private int measureFolders(int mode) { |
| int availableSpace = ConversationItemViewCoordinates.getFoldersWidth(mContext, mode); |
| int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext, mode, |
| mFoldersCount); |
| |
| int totalWidth = 0; |
| for (Folder f : mFoldersSortedSet) { |
| final String folderString = f.name; |
| int width = (int) sFoldersPaint.measureText(folderString) + cellSize; |
| if (width % cellSize != 0) { |
| width += cellSize - (width % cellSize); |
| } |
| totalWidth += width; |
| if (totalWidth > availableSpace) { |
| break; |
| } |
| } |
| |
| return totalWidth; |
| } |
| |
| public void drawFolders(Canvas canvas, ConversationItemViewCoordinates coordinates, |
| int foldersXEnd, int mode) { |
| if (mFoldersCount == 0) { |
| return; |
| } |
| |
| int xEnd = foldersXEnd; |
| int y = coordinates.foldersY - coordinates.foldersAscent; |
| int height = coordinates.foldersHeight; |
| int topPadding = coordinates.foldersTopPadding; |
| int ascent = coordinates.foldersAscent; |
| sFoldersPaint.setTextSize(coordinates.foldersFontSize); |
| |
| // Initialize space and cell size based on the current mode. |
| int availableSpace = ConversationItemViewCoordinates.getFoldersWidth(mContext, mode); |
| int averageWidth = availableSpace / mFoldersCount; |
| int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext, mode, |
| mFoldersCount); |
| |
| // First pass to calculate the starting point. |
| int totalWidth = measureFolders(mode); |
| int xStart = xEnd - Math.min(availableSpace, totalWidth); |
| |
| // Second pass to draw folders. |
| for (Folder f : mFoldersSortedSet) { |
| final String folderString = f.name; |
| final int fgColor = f.getForegroundColor(mDefaultFgColor); |
| final int bgColor = f.getBackgroundColor(mDefaultBgColor); |
| int width = cellSize; |
| boolean labelTooLong = false; |
| width = (int) sFoldersPaint.measureText(folderString) + cellSize; |
| if (width % cellSize != 0) { |
| width += cellSize - (width % cellSize); |
| } |
| if (totalWidth > availableSpace && width > averageWidth) { |
| width = averageWidth; |
| labelTooLong = true; |
| } |
| |
| // TODO (mindyp): how to we get this? |
| final boolean isMuted = false; |
| // labelValues.folderId == |
| // sGmail.getFolderMap(mAccount).getFolderIdIgnored(); |
| |
| // Draw the box. |
| sFoldersPaint.setColor(bgColor); |
| sFoldersPaint.setStyle(isMuted ? Paint.Style.STROKE : Paint.Style.FILL_AND_STROKE); |
| canvas.drawRect(xStart, y + ascent, xStart + width, y + ascent + height, |
| sFoldersPaint); |
| |
| // Draw the text. |
| int padding = getPadding(width, (int) sFoldersPaint.measureText(folderString)); |
| if (labelTooLong) { |
| TextPaint shortPaint = new TextPaint(); |
| shortPaint.setColor(fgColor); |
| shortPaint.setTextSize(coordinates.foldersFontSize); |
| padding = cellSize / 2; |
| int rightBorder = xStart + width - padding; |
| Shader shader = new LinearGradient(rightBorder - padding, y, rightBorder, y, |
| fgColor, Utils.getTransparentColor(fgColor), Shader.TileMode.CLAMP); |
| shortPaint.setShader(shader); |
| canvas.drawText(folderString, xStart + padding, y + topPadding, shortPaint); |
| } else { |
| sFoldersPaint.setColor(fgColor); |
| canvas.drawText(folderString, xStart + padding, y + topPadding, sFoldersPaint); |
| } |
| |
| availableSpace -= width; |
| xStart += width; |
| if (availableSpace <= 0 && mHasMoreFolders) { |
| canvas.drawBitmap(MORE_FOLDERS, xEnd, y + ascent, sFoldersPaint); |
| return; |
| } |
| } |
| } |
| } |
| |
| /** |
| * Helpers function to align an element in the center of a space. |
| */ |
| private static int getPadding(int space, int length) { |
| return (space - length) / 2; |
| } |
| |
| public ConversationItemView(Context context, String account) { |
| super(context); |
| mContext = context.getApplicationContext(); |
| mTabletDevice = Utils.useTabletUI(mContext); |
| mAccount = account; |
| final View item = this; |
| mListItemClick = new Runnable() { |
| @Override |
| public void run() { |
| ListView list = getListView(); |
| if (list != null) { |
| int pos = list.getPositionForView(item); |
| list.performItemClick(item, pos, mHeader.conversation.id); |
| } |
| } |
| }; |
| Resources res = mContext.getResources(); |
| |
| if (CHECKMARK_OFF == null) { |
| // Initialize static bitmaps. |
| CHECKMARK_OFF = BitmapFactory.decodeResource(res, |
| R.drawable.btn_check_off_normal_holo_light); |
| CHECKMARK_ON = BitmapFactory.decodeResource(res, |
| R.drawable.btn_check_on_normal_holo_light); |
| STAR_OFF = BitmapFactory.decodeResource(res, |
| R.drawable.btn_star_off_normal_email_holo_light); |
| STAR_ON = BitmapFactory.decodeResource(res, |
| R.drawable.btn_star_on_normal_email_holo_light); |
| ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double); |
| TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single); |
| IMPORTANT_ONLY_TO_ME = BitmapFactory.decodeResource(res, |
| R.drawable.ic_email_caret_double_important_unread); |
| IMPORTANT_TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, |
| R.drawable.ic_email_caret_single_important_unread); |
| IMPORTANT_TO_OTHERS = BitmapFactory.decodeResource(res, |
| R.drawable.ic_email_caret_none_important_unread); |
| ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attachment_holo_light); |
| MORE_FOLDERS = BitmapFactory.decodeResource(res, R.drawable.ic_folders_more); |
| DATE_BACKGROUND = BitmapFactory.decodeResource(res, R.drawable.folder_bg_holo_light); |
| STATE_REPLIED = |
| BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_holo_light); |
| STATE_FORWARDED = |
| BitmapFactory.decodeResource(res, R.drawable.ic_badge_forward_holo_light); |
| STATE_REPLIED_AND_FORWARDED = |
| BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_forward_holo_light); |
| STATE_CALENDAR_INVITE = |
| BitmapFactory.decodeResource(res, R.drawable.ic_badge_invite_holo_light); |
| |
| // Initialize colors. |
| sDefaultTextColor = res.getColor(R.color.default_text_color); |
| sActivatedTextColor = res.getColor(android.R.color.white); |
| sSubjectTextColorRead = res.getColor(R.color.subject_text_color_read); |
| sSubjectTextColorUnead = res.getColor(R.color.subject_text_color_unread); |
| sSnippetTextColorRead = res.getColor(R.color.snippet_text_color_read); |
| sSnippetTextColorUnread = res.getColor(R.color.snippet_text_color_unread); |
| sSendersTextColorRead = res.getColor(R.color.senders_text_color_read); |
| sSendersTextColorUnread = res.getColor(R.color.senders_text_color_unread); |
| sDateTextColor = res.getColor(R.color.date_text_color); |
| sDateBackgroundPaddingLeft = res |
| .getDimensionPixelSize(R.dimen.date_background_padding_left); |
| sTouchSlop = res.getDimensionPixelSize(R.dimen.touch_slop); |
| sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height); |
| sStandardScaledDimen = res.getDimensionPixelSize(R.dimen.standard_scaled_dimen); |
| sShrinkAnimationDuration = res.getInteger(R.integer.shrink_animation_duration); |
| sSlideAnimationDuration = res.getInteger(R.integer.slide_animation_duration); |
| sUndoAnimationOffset = res.getDimensionPixelOffset(R.dimen.undo_animation_offset); |
| // Initialize static color. |
| sSendersSplitToken = res.getString(R.string.senders_split_token); |
| sElidedPaddingToken = res.getString(R.string.elided_padding_token); |
| sEllipsis = res.getString(R.string.ellipsis); |
| } |
| } |
| |
| public void bind(Cursor cursor, ControllableActivity activity, ConversationSelectionSet set, |
| Folder folder, boolean checkboxesDisabled, boolean swipeEnabled, |
| boolean priorityArrowEnabled, AnimatedAdapter adapter) { |
| bind(ConversationItemViewModel.forCursor(mAccount, cursor), activity, set, folder, |
| checkboxesDisabled, swipeEnabled, priorityArrowEnabled, adapter); |
| } |
| |
| public void bind(Conversation conversation, ControllableActivity activity, |
| ConversationSelectionSet set, Folder folder, boolean checkboxesDisabled, |
| boolean swipeEnabled, boolean priorityArrowEnabled, AnimatedAdapter adapter) { |
| bind(ConversationItemViewModel.forConversation(mAccount, conversation), activity, set, |
| folder, checkboxesDisabled, swipeEnabled, priorityArrowEnabled, adapter); |
| } |
| |
| private void bind(ConversationItemViewModel header, ControllableActivity activity, |
| ConversationSelectionSet set, Folder folder, boolean checkboxesDisabled, |
| boolean swipeEnabled, boolean priorityArrowEnabled, AnimatedAdapter adapter) { |
| mHeader = header; |
| mActivity = activity; |
| mSelectedConversationSet = set; |
| mDisplayedFolder = folder; |
| mCheckboxesEnabled = !checkboxesDisabled; |
| mSwipeEnabled = swipeEnabled; |
| mPriorityMarkersEnabled = priorityArrowEnabled; |
| mAdapter = adapter; |
| setContentDescription(mHeader.getContentDescription(mContext)); |
| requestLayout(); |
| } |
| |
| /** |
| * Get the Conversation object associated with this view. |
| */ |
| public Conversation getConversation() { |
| return mHeader.conversation; |
| } |
| |
| /** |
| * Sets the mode. Only used for testing. |
| */ |
| @VisibleForTesting |
| void setMode(int mode) { |
| mMode = mode; |
| mTesting = true; |
| } |
| |
| private static void startTimer(String tag) { |
| if (sTimer != null) { |
| sTimer.start(tag); |
| } |
| } |
| |
| private static void pauseTimer(String tag) { |
| if (sTimer != null) { |
| sTimer.pause(tag); |
| } |
| } |
| |
| @Override |
| protected void onLayout(boolean changed, int left, int top, int right, int bottom) { |
| startTimer(PERF_TAG_LAYOUT); |
| |
| super.onLayout(changed, left, top, right, bottom); |
| |
| int width = right - left; |
| if (width != mViewWidth) { |
| mViewWidth = width; |
| if (!mTesting) { |
| mMode = ConversationItemViewCoordinates.getMode(mContext, mActivity.getViewMode()); |
| } |
| } |
| mHeader.viewWidth = mViewWidth; |
| Resources res = getResources(); |
| mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen); |
| if (mHeader.standardScaledDimen != sStandardScaledDimen) { |
| // Large Text has been toggle on/off. Update the static dimens. |
| sStandardScaledDimen = mHeader.standardScaledDimen; |
| ConversationItemViewCoordinates.refreshConversationHeights(mContext); |
| sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height); |
| } |
| mCoordinates = ConversationItemViewCoordinates.forWidth(mContext, mViewWidth, mMode, |
| mHeader.standardScaledDimen); |
| calculateTextsAndBitmaps(); |
| calculateCoordinates(); |
| mHeader.validate(mContext); |
| |
| pauseTimer(PERF_TAG_LAYOUT); |
| if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) { |
| sTimer.dumpResults(); |
| sTimer = new Timer(); |
| sLayoutCount = 0; |
| } |
| } |
| |
| @Override |
| public void setBackgroundResource(int resourceId) { |
| Drawable drawable = mBackgrounds.get(resourceId); |
| if (drawable == null) { |
| drawable = getResources().getDrawable(resourceId); |
| mBackgrounds.put(resourceId, drawable); |
| } |
| if (getBackground() != drawable) { |
| super.setBackgroundDrawable(drawable); |
| } |
| } |
| |
| private void calculateTextsAndBitmaps() { |
| startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); |
| if (mSelectedConversationSet != null) { |
| mChecked = mSelectedConversationSet.contains(mHeader.conversation); |
| } |
| // Update font color. |
| int fontColor = getFontColor(sDefaultTextColor); |
| boolean fontChanged = false; |
| if (mHeader.fontColor != fontColor) { |
| fontChanged = true; |
| mHeader.fontColor = fontColor; |
| } |
| |
| boolean isUnread = mHeader.unread; |
| |
| final boolean checkboxEnabled = mCheckboxesEnabled; |
| if (mHeader.checkboxVisible != checkboxEnabled) { |
| mHeader.checkboxVisible = checkboxEnabled; |
| } |
| |
| // Update background. |
| updateBackground(isUnread); |
| |
| if (mHeader.isLayoutValid(mContext)) { |
| // Relayout subject if font color has changed. |
| if (fontChanged) { |
| layoutSubjectSpans(isUnread); |
| layoutSubject(); |
| } |
| pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); |
| return; |
| } |
| |
| startTimer(PERF_TAG_CALCULATE_FOLDERS); |
| |
| // Initialize folder displayer. |
| if (mCoordinates.showFolders) { |
| mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext); |
| mHeader.folderDisplayer.loadConversationFolders(mHeader.conversation, mDisplayedFolder); |
| } |
| |
| pauseTimer(PERF_TAG_CALCULATE_FOLDERS); |
| |
| mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext, |
| mHeader.conversation.dateMs).toString(); |
| |
| // Paper clip icon. |
| mHeader.paperclip = null; |
| if (mHeader.conversation.hasAttachments) { |
| mHeader.paperclip = ATTACHMENT; |
| } |
| // Personal level. |
| mHeader.personalLevelBitmap = null; |
| if (mCoordinates.showPersonalLevel) { |
| final int personalLevel = mHeader.personalLevel; |
| final boolean isImportant = |
| mHeader.priority == UIProvider.ConversationPriority.IMPORTANT; |
| final boolean useImportantMarkers = isImportant && mPriorityMarkersEnabled; |
| |
| if (personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) { |
| mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME |
| : ONLY_TO_ME; |
| } else if (personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) { |
| mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS |
| : TO_ME_AND_OTHERS; |
| } else if (useImportantMarkers) { |
| mHeader.personalLevelBitmap = IMPORTANT_TO_OTHERS; |
| } |
| } |
| |
| startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); |
| |
| // Subject. |
| layoutSubjectSpans(isUnread); |
| |
| mHeader.sendersDisplayText = new SpannableStringBuilder(); |
| mHeader.styledSendersString = new SpannableStringBuilder(); |
| |
| // Parse senders fragments. |
| if (mHeader.conversation.conversationInfo != null) { |
| Context context = getContext(); |
| mHeader.messageInfoString = SendersView |
| .createMessageInfo(context, mHeader.conversation); |
| int maxChars = ConversationItemViewCoordinates.getSubjectLength(context, |
| ConversationItemViewCoordinates.getMode(context, mActivity.getViewMode()), |
| mHeader.folderDisplayer != null && mHeader.folderDisplayer.mFoldersCount > 0, |
| mHeader.conversation.hasAttachments); |
| mHeader.styledSenders = SendersView.format(context, |
| mHeader.conversation.conversationInfo, mHeader.messageInfoString.toString(), |
| maxChars); |
| } else { |
| SendersView.formatSenders(mHeader, getContext()); |
| } |
| |
| pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); |
| pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); |
| } |
| |
| private void layoutSubjectSpans(boolean isUnread) { |
| if (showActivatedText()) { |
| mHeader.subjectTextActivated = createSubject(isUnread, true); |
| } |
| mHeader.subjectText = createSubject(isUnread, false); |
| } |
| |
| private SpannableStringBuilder createSubject(boolean isUnread, boolean activated) { |
| final String subject = filterTag(mHeader.conversation.subject); |
| final String snippet = mHeader.conversation.getSnippet(); |
| int subjectColor = activated ? sActivatedTextColor : isUnread ? sSubjectTextColorUnead |
| : sSubjectTextColorRead; |
| int snippetColor = activated ? sActivatedTextColor : isUnread ? sSnippetTextColorUnread |
| : sSnippetTextColorRead; |
| SpannableStringBuilder subjectText = new SpannableStringBuilder( |
| (snippet != null) ? mContext.getString(R.string.subject_and_snippet, subject, |
| snippet) : subject); |
| if (isUnread) { |
| subjectText.setSpan(new StyleSpan(Typeface.BOLD), 0, subject.length(), |
| Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); |
| } |
| subjectText.setSpan(new ForegroundColorSpan(subjectColor), 0, subject.length(), |
| Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); |
| if (snippet != null) { |
| subjectText.setSpan(new ForegroundColorSpan(snippetColor), subject.length() + 1, |
| subjectText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); |
| } |
| return subjectText; |
| } |
| |
| private int getFontColor(int defaultColor) { |
| return isActivated() && mTabletDevice ? sActivatedTextColor |
| : defaultColor; |
| } |
| |
| private boolean showActivatedText() { |
| return mTabletDevice; |
| } |
| |
| private void layoutSubject() { |
| if (showActivatedText()) { |
| mHeader.subjectLayoutActivated = |
| createSubjectLayout(true, mHeader.subjectTextActivated); |
| } |
| mHeader.subjectLayout = createSubjectLayout(false, mHeader.subjectText); |
| } |
| |
| private StaticLayout createSubjectLayout(boolean activated, |
| SpannableStringBuilder subjectText) { |
| sPaint.setTextSize(mCoordinates.subjectFontSize); |
| sPaint.setColor(activated ? sActivatedTextColor |
| : mHeader.unread ? sSubjectTextColorUnead : sSubjectTextColorRead); |
| StaticLayout subjectLayout = new StaticLayout(subjectText, sPaint, |
| mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true); |
| int lineCount = subjectLayout.getLineCount(); |
| if (mCoordinates.subjectLineCount < lineCount) { |
| int end = subjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1); |
| subjectLayout = new StaticLayout(subjectText.subSequence(0, end), sPaint, |
| mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true); |
| } |
| return subjectLayout; |
| } |
| |
| private boolean canFitFragment(int width, int line, int fixedWidth) { |
| if (line == mCoordinates.sendersLineCount) { |
| return width + fixedWidth <= mSendersWidth; |
| } else { |
| return width <= mSendersWidth; |
| } |
| } |
| |
| private void calculateCoordinates() { |
| startTimer(PERF_TAG_CALCULATE_COORDINATES); |
| |
| sPaint.setTextSize(mCoordinates.dateFontSize); |
| sPaint.setTypeface(Typeface.DEFAULT); |
| mDateX = mCoordinates.dateXEnd - (int) sPaint.measureText(mHeader.dateText); |
| |
| mPaperclipX = mDateX - ATTACHMENT.getWidth(); |
| |
| int cellWidth = mContext.getResources().getDimensionPixelSize(R.dimen.folder_cell_width); |
| |
| if (ConversationItemViewCoordinates.isWideMode(mMode)) { |
| // Folders are displayed above the date. |
| mFoldersXEnd = mCoordinates.dateXEnd; |
| // In wide mode, the end of the senders should align with |
| // the start of the subject and is based on a max width. |
| mSendersWidth = mCoordinates.sendersWidth; |
| } else { |
| // In normal mode, the width is based on where the folders or date |
| // (or attachment icon) start. |
| if (mCoordinates.showFolders) { |
| if (mHeader.paperclip != null) { |
| mFoldersXEnd = mPaperclipX; |
| } else { |
| mFoldersXEnd = mDateX - cellWidth / 2; |
| } |
| mSendersWidth = mFoldersXEnd - mCoordinates.sendersX - 2 * cellWidth; |
| if (mHeader.folderDisplayer.hasVisibleFolders()) { |
| mSendersWidth -= ConversationItemViewCoordinates.getFoldersWidth(mContext, |
| mMode); |
| } |
| } else { |
| int dateAttachmentStart = 0; |
| // Have this end near the paperclip or date, not the folders. |
| if (mHeader.paperclip != null) { |
| dateAttachmentStart = mPaperclipX; |
| } else { |
| dateAttachmentStart = mDateX; |
| } |
| mSendersWidth = dateAttachmentStart - mCoordinates.sendersX - cellWidth; |
| } |
| } |
| |
| if (mHeader.isLayoutValid(mContext)) { |
| pauseTimer(PERF_TAG_CALCULATE_COORDINATES); |
| return; |
| } |
| |
| // Layout subject. |
| layoutSubject(); |
| |
| // Second pass to layout each fragment. |
| int sendersY = mCoordinates.sendersY - mCoordinates.sendersAscent; |
| |
| if (mHeader.styledSenders != null) { |
| ellipsizeStyledSenders(); |
| mHeader.sendersDisplayLayout = new StaticLayout(mHeader.styledSendersString, sPaint, |
| mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true); |
| } else { |
| // First pass to calculate width of each fragment. |
| int totalWidth = 0; |
| int fixedWidth = 0; |
| sPaint.setTextSize(mCoordinates.sendersFontSize); |
| sPaint.setTypeface(Typeface.DEFAULT); |
| for (SenderFragment senderFragment : mHeader.senderFragments) { |
| CharacterStyle style = senderFragment.style; |
| int start = senderFragment.start; |
| int end = senderFragment.end; |
| style.updateDrawState(sPaint); |
| senderFragment.width = (int) sPaint.measureText(mHeader.sendersText, start, end); |
| boolean isFixed = senderFragment.isFixed; |
| if (isFixed) { |
| fixedWidth += senderFragment.width; |
| } |
| totalWidth += senderFragment.width; |
| } |
| |
| if (!ConversationItemViewCoordinates.displaySendersInline(mMode)) { |
| sendersY += totalWidth <= mSendersWidth ? mCoordinates.sendersLineHeight / 2 : 0; |
| } |
| totalWidth = ellipsize(fixedWidth, sendersY); |
| mHeader.sendersDisplayLayout = new StaticLayout(mHeader.sendersDisplayText, sPaint, |
| mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true); |
| } |
| |
| sPaint.setTextSize(mCoordinates.sendersFontSize); |
| sPaint.setTypeface(Typeface.DEFAULT); |
| if (mSendersWidth < 0) { |
| mSendersWidth = 0; |
| } |
| |
| pauseTimer(PERF_TAG_CALCULATE_COORDINATES); |
| } |
| |
| // The rules for displaying ellipsized senders are as follows: |
| // 1) If there is message info (either a COUNT or DRAFT info to display), it MUST be shown |
| // 2) If senders do not fit, ellipsize the last one that does fit, and stop |
| // appending new senders |
| private int ellipsizeStyledSenders() { |
| SpannableStringBuilder builder = new SpannableStringBuilder(); |
| float totalWidth = 0; |
| boolean ellipsize = false; |
| SpannableString ellipsizedText; |
| float width; |
| SpannableStringBuilder messageInfoString = mHeader.messageInfoString; |
| if (messageInfoString.length() > 0) { |
| CharacterStyle[] spans = messageInfoString.getSpans(0, messageInfoString.length(), |
| CharacterStyle.class); |
| // There is only 1 character style span; make sure we apply all the |
| // styles to the paint object before measuring. |
| if (spans.length > 0) { |
| spans[0].updateDrawState(sPaint); |
| } |
| // Paint the message info string to see if we lose space. |
| float messageInfoWidth = sPaint.measureText(messageInfoString.toString()); |
| totalWidth += messageInfoWidth; |
| } |
| SpannableString prevSender = null; |
| for (SpannableString sender : mHeader.styledSenders) { |
| // There may be null sender strings if there were dupes we had to remove. |
| if (sender == null) { |
| continue; |
| } |
| // No more width available, we'll only show fixed fragments. |
| if (ellipsize) { |
| break; |
| } |
| CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); |
| // There is only 1 character style span. |
| if (spans.length > 0) { |
| spans[0].updateDrawState(sPaint); |
| } |
| // If there are already senders present in this string, we need to |
| // make sure we prepend the dividing token |
| if (SendersView.sElidedString.equals(sender.toString())) { |
| prevSender = sender; |
| sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken); |
| } else if (builder.length() > 0 |
| && (prevSender == null || !SendersView.sElidedString.equals(prevSender |
| .toString()))) { |
| prevSender = sender; |
| sender = copyStyles(spans, sSendersSplitToken + sender); |
| } else { |
| prevSender = sender; |
| } |
| // Measure the width of the current sender and make sure we have space |
| width = (int) sPaint.measureText(sender.toString()); |
| if (width + totalWidth > mSendersWidth) { |
| // The text is too long, new line won't help. We have to |
| // ellipsize text. |
| ellipsize = true; |
| width = mSendersWidth - totalWidth - getEllipsisWidth(); // ellipsis width? |
| ellipsizedText = copyStyles(spans, |
| TextUtils.ellipsize(sender, sPaint, width, TruncateAt.END)); |
| width = (int) sPaint.measureText(ellipsizedText.toString()); |
| } else { |
| ellipsizedText = null; |
| } |
| totalWidth += width; |
| |
| final CharSequence fragmentDisplayText; |
| if (ellipsizedText != null) { |
| fragmentDisplayText = ellipsizedText; |
| } else { |
| fragmentDisplayText = sender; |
| } |
| builder.append(fragmentDisplayText); |
| } |
| if (messageInfoString.length() > 0) { |
| builder.append(messageInfoString); |
| } |
| mHeader.styledSendersString = builder; |
| return (int)totalWidth; |
| } |
| |
| private float getEllipsisWidth() { |
| return sPaint.measureText(sEllipsis); |
| } |
| |
| private SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { |
| SpannableString s = new SpannableString(newText); |
| if (spans != null && spans.length > 0) { |
| s.setSpan(spans[0], 0, s.length(), 0); |
| } |
| return s; |
| } |
| |
| private int ellipsize(int fixedWidth, int sendersY) { |
| int totalWidth = 0; |
| int currentLine = 1; |
| boolean ellipsize = false; |
| for (SenderFragment senderFragment : mHeader.senderFragments) { |
| CharacterStyle style = senderFragment.style; |
| int start = senderFragment.start; |
| int end = senderFragment.end; |
| int width = senderFragment.width; |
| boolean isFixed = senderFragment.isFixed; |
| style.updateDrawState(sPaint); |
| |
| // No more width available, we'll only show fixed fragments. |
| if (ellipsize && !isFixed) { |
| senderFragment.shouldDisplay = false; |
| continue; |
| } |
| |
| // New line and ellipsize text if needed. |
| senderFragment.ellipsizedText = null; |
| if (isFixed) { |
| fixedWidth -= width; |
| } |
| if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) { |
| // The text is too long, new line won't help. We have to |
| // ellipsize text. |
| if (totalWidth == 0) { |
| ellipsize = true; |
| } else { |
| // New line. |
| if (currentLine < mCoordinates.sendersLineCount) { |
| currentLine++; |
| sendersY += mCoordinates.sendersLineHeight; |
| totalWidth = 0; |
| // The text is still too long, we have to ellipsize |
| // text. |
| if (totalWidth + width > mSendersWidth) { |
| ellipsize = true; |
| } |
| } else { |
| ellipsize = true; |
| } |
| } |
| |
| if (ellipsize) { |
| width = mSendersWidth - totalWidth; |
| // No more new line, we have to reserve width for fixed |
| // fragments. |
| if (currentLine == mCoordinates.sendersLineCount) { |
| width -= fixedWidth; |
| } |
| senderFragment.ellipsizedText = TextUtils.ellipsize( |
| mHeader.sendersText.substring(start, end), sPaint, width, |
| TruncateAt.END).toString(); |
| width = (int) sPaint.measureText(senderFragment.ellipsizedText); |
| } |
| } |
| senderFragment.shouldDisplay = true; |
| totalWidth += width; |
| |
| final CharSequence fragmentDisplayText; |
| if (senderFragment.ellipsizedText != null) { |
| fragmentDisplayText = senderFragment.ellipsizedText; |
| } else { |
| fragmentDisplayText = mHeader.sendersText.substring(start, end); |
| } |
| final int spanStart = mHeader.sendersDisplayText.length(); |
| mHeader.sendersDisplayText.append(fragmentDisplayText); |
| mHeader.sendersDisplayText.setSpan(senderFragment.style, spanStart, |
| mHeader.sendersDisplayText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); |
| } |
| return totalWidth; |
| } |
| |
| /** |
| * If the subject contains the tag of a mailing-list (text surrounded with |
| * []), return the subject with that tag ellipsized, e.g. |
| * "[android-gmail-team] Hello" -> "[andr...] Hello" |
| */ |
| private String filterTag(String subject) { |
| String result = subject; |
| String formatString = getContext().getResources().getString(R.string.filtered_tag); |
| if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') { |
| int end = subject.indexOf(']'); |
| if (end > 0) { |
| String tag = subject.substring(1, end); |
| result = String.format(formatString, Utils.ellipsize(tag, 7), |
| subject.substring(end + 1)); |
| } |
| } |
| return result; |
| } |
| |
| @Override |
| protected void onDraw(Canvas canvas) { |
| // Check mark. |
| if (mHeader.checkboxVisible) { |
| Bitmap checkmark = mChecked ? CHECKMARK_ON : CHECKMARK_OFF; |
| canvas.drawBitmap(checkmark, mCoordinates.checkmarkX, mCoordinates.checkmarkY, sPaint); |
| } |
| |
| // Personal Level. |
| if (mCoordinates.showPersonalLevel && mHeader.personalLevelBitmap != null) { |
| canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalLevelX, |
| mCoordinates.personalLevelY, sPaint); |
| } |
| |
| // Senders. |
| boolean isUnread = mHeader.unread; |
| // Old style senders; apply text colors/ sizes/ styling. |
| if (mHeader.senderFragments.size() > 0) { |
| sPaint.setTextSize(mCoordinates.sendersFontSize); |
| sPaint.setTypeface(SendersView.getTypeface(isUnread)); |
| int sendersColor = getFontColor(isUnread ? sSendersTextColorUnread |
| : sSendersTextColorRead); |
| sPaint.setColor(sendersColor); |
| } |
| canvas.save(); |
| canvas.translate(mCoordinates.sendersX, |
| mCoordinates.sendersY + mHeader.sendersDisplayLayout.getTopPadding()); |
| mHeader.sendersDisplayLayout.draw(canvas); |
| canvas.restore(); |
| |
| // Subject. |
| sPaint.setTextSize(mCoordinates.subjectFontSize); |
| sPaint.setTypeface(Typeface.DEFAULT); |
| canvas.save(); |
| if (isActivated() && showActivatedText()) { |
| if (mHeader.subjectLayoutActivated != null) { |
| canvas.translate(mCoordinates.subjectX, mCoordinates.subjectY |
| + mHeader.subjectLayoutActivated.getTopPadding()); |
| mHeader.subjectLayoutActivated.draw(canvas); |
| } |
| } else if (mHeader.subjectLayout != null) { |
| canvas.translate(mCoordinates.subjectX, |
| mCoordinates.subjectY + mHeader.subjectLayout.getTopPadding()); |
| mHeader.subjectLayout.draw(canvas); |
| } |
| canvas.restore(); |
| |
| // Folders. |
| if (mCoordinates.showFolders) { |
| mHeader.folderDisplayer.drawFolders(canvas, mCoordinates, mFoldersXEnd, mMode); |
| } |
| |
| // If this folder has a color (combined view/Email), show it here |
| if (mHeader.conversation.color != 0) { |
| sFoldersPaint.setColor(mHeader.conversation.color); |
| sFoldersPaint.setStyle(Paint.Style.FILL); |
| int width = ConversationItemViewCoordinates.getColorBlockWidth(mContext); |
| int height = ConversationItemViewCoordinates.getColorBlockHeight(mContext); |
| canvas.drawRect(mCoordinates.dateXEnd - width, 0, mCoordinates.dateXEnd, |
| height, sFoldersPaint); |
| } |
| |
| // Date background: shown when there is an attachment or a visible |
| // folder. |
| if (!isActivated() |
| && (mHeader.conversation.hasAttachments || |
| (mHeader.folderDisplayer != null |
| && mHeader.folderDisplayer.hasVisibleFolders())) |
| && ConversationItemViewCoordinates.showAttachmentBackground(mMode)) { |
| int leftOffset = (mHeader.conversation.hasAttachments ? mPaperclipX : mDateX) |
| - sDateBackgroundPaddingLeft; |
| int top = mCoordinates.showFolders ? mCoordinates.foldersY : mCoordinates.dateY; |
| mHeader.dateBackground = getDateBackground(mHeader.conversation.hasAttachments); |
| canvas.drawBitmap(mHeader.dateBackground, leftOffset, top, sPaint); |
| } else { |
| mHeader.dateBackground = null; |
| } |
| |
| // Draw the reply state. Draw nothing if neither replied nor forwarded. |
| if (mCoordinates.showReplyState) { |
| if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) { |
| canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX, |
| mCoordinates.replyStateY, null); |
| } else if (mHeader.hasBeenRepliedTo) { |
| canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX, |
| mCoordinates.replyStateY, null); |
| } else if (mHeader.hasBeenForwarded) { |
| canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX, |
| mCoordinates.replyStateY, null); |
| } else if (mHeader.isInvite) { |
| canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX, |
| mCoordinates.replyStateY, null); |
| } |
| } |
| |
| // Date. |
| sPaint.setTextSize(mCoordinates.dateFontSize); |
| sPaint.setTypeface(Typeface.DEFAULT); |
| sPaint.setColor(sDateTextColor); |
| drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateY - mCoordinates.dateAscent, |
| sPaint); |
| |
| // Paper clip icon. |
| if (mHeader.paperclip != null) { |
| canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint); |
| } |
| |
| if (mHeader.faded) { |
| int fadedColor = -1; |
| if (sFadedActivatedColor == -1) { |
| sFadedActivatedColor = mContext.getResources().getColor( |
| R.color.faded_activated_conversation_header); |
| } |
| fadedColor = sFadedActivatedColor; |
| int restoreState = canvas.save(); |
| Rect bounds = canvas.getClipBounds(); |
| canvas.clipRect(bounds.left, bounds.top, bounds.right |
| - mContext.getResources().getDimensionPixelSize(R.dimen.triangle_width), |
| bounds.bottom); |
| canvas.drawARGB(Color.alpha(fadedColor), Color.red(fadedColor), |
| Color.green(fadedColor), Color.blue(fadedColor)); |
| canvas.restoreToCount(restoreState); |
| } |
| |
| // Star. |
| canvas.drawBitmap(getStarBitmap(), mCoordinates.starX, mCoordinates.starY, sPaint); |
| } |
| |
| private Bitmap getStarBitmap() { |
| return mHeader.conversation.starred ? STAR_ON : STAR_OFF; |
| } |
| |
| private Bitmap getDateBackground(boolean hasAttachments) { |
| int leftOffset = (hasAttachments ? mPaperclipX : mDateX) - sDateBackgroundPaddingLeft; |
| if (hasAttachments) { |
| if (sDateBackgroundAttachment == null) { |
| sDateBackgroundAttachment = Bitmap.createScaledBitmap(DATE_BACKGROUND, mViewWidth |
| - leftOffset, sDateBackgroundHeight, false); |
| } |
| return sDateBackgroundAttachment; |
| } else { |
| if (sDateBackgroundNoAttachment == null) { |
| sDateBackgroundNoAttachment = Bitmap.createScaledBitmap(DATE_BACKGROUND, mViewWidth |
| - leftOffset, sDateBackgroundHeight, false); |
| } |
| return sDateBackgroundNoAttachment; |
| } |
| } |
| |
| private void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) { |
| canvas.drawText(s, 0, s.length(), x, y, paint); |
| } |
| |
| private void updateBackground(boolean isUnread) { |
| ViewMode viewMode = mActivity.getViewMode(); |
| if (isUnread) { |
| if (mTabletDevice && viewMode.isListMode()) { |
| if (mChecked) { |
| setBackgroundResource(R.drawable.list_conversation_wide_unread_selected_holo); |
| } else { |
| setBackgroundResource(R.drawable.conversation_wide_unread_selector); |
| } |
| } else { |
| if (mChecked) { |
| setCheckedActivatedBackground(); |
| } else { |
| setBackgroundResource(R.drawable.conversation_unread_selector); |
| } |
| } |
| } else { |
| if (mTabletDevice && viewMode.isListMode()) { |
| if (mChecked) { |
| setBackgroundResource(R.drawable.list_conversation_wide_read_selected_holo); |
| } else { |
| setBackgroundResource(R.drawable.conversation_wide_read_selector); |
| } |
| } else { |
| if (mChecked) { |
| setCheckedActivatedBackground(); |
| } else { |
| setBackgroundResource(R.drawable.conversation_read_selector); |
| } |
| } |
| } |
| } |
| |
| private void setCheckedActivatedBackground() { |
| if (isActivated() && mTabletDevice) { |
| setBackgroundResource(R.drawable.list_arrow_selected_holo); |
| } else { |
| setBackgroundResource(R.drawable.list_selected_holo); |
| } |
| } |
| |
| /** |
| * Toggle the check mark on this view and update the conversation |
| */ |
| public void toggleCheckMark() { |
| if (mHeader != null && mHeader.conversation != null) { |
| mChecked = !mChecked; |
| Conversation conv = mHeader.conversation; |
| // Set the list position of this item in the conversation |
| ListView listView = getListView(); |
| conv.position = mChecked && listView != null ? listView.getPositionForView(this) |
| : Conversation.NO_POSITION; |
| if (mSelectedConversationSet != null) { |
| mSelectedConversationSet.toggle(this, conv); |
| } |
| // We update the background after the checked state has changed now |
| // that |
| // we have a selected background asset. Setting the background |
| // usually |
| // waits for a layout pass, but we don't need a full layout, just an |
| // update to the background. |
| requestLayout(); |
| } |
| } |
| |
| /** |
| * Return if the checkbox for this item is checked. |
| */ |
| public boolean isChecked() { |
| return mChecked; |
| } |
| |
| /** |
| * Toggle the star on this view and update the conversation. |
| */ |
| public void toggleStar() { |
| mHeader.conversation.starred = !mHeader.conversation.starred; |
| Bitmap starBitmap = getStarBitmap(); |
| postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX |
| + starBitmap.getWidth(), |
| mCoordinates.starY + starBitmap.getHeight()); |
| ConversationCursor cursor = (ConversationCursor)mAdapter.getCursor(); |
| cursor.updateBoolean(mContext, mHeader.conversation, ConversationColumns.STARRED, |
| mHeader.conversation.starred); |
| } |
| |
| private boolean isTouchInCheckmark(float x, float y) { |
| // Everything before senders and include a touch slop. |
| return mHeader.checkboxVisible && x < mCoordinates.sendersX + sTouchSlop; |
| } |
| |
| private boolean isTouchInStar(float x, float y) { |
| // Everything after the star and include a touch slop. |
| return x > mCoordinates.starX - sTouchSlop; |
| } |
| |
| /** |
| * Cancel any potential tap handling on this view. |
| */ |
| @Override |
| public void cancelTap() { |
| setPressed(false); |
| removeCallbacks(mPendingCheckForLongPress); |
| } |
| |
| /** |
| * ConversationItemView is given the first chance to handle touch events. |
| */ |
| @Override |
| public boolean onTouchEvent(MotionEvent event) { |
| int x = mLastTouchX = (int) event.getX(); |
| int y = mLastTouchY = (int) event.getY(); |
| if (!mSwipeEnabled) { |
| return onTouchEventNoSwipe(event); |
| } |
| boolean handled = true; |
| |
| switch (event.getAction()) { |
| case MotionEvent.ACTION_DOWN: |
| mDownEvent = true; |
| // This checks for long press. The actual tap is handled on "up". |
| checkForLongPress(); |
| // In order to allow the down event and subsequent move events |
| // to bubble to the swipe handler, we need to return that all |
| // down events are handled. |
| handled = true; |
| break; |
| case MotionEvent.ACTION_CANCEL: |
| mDownEvent = false; |
| break; |
| case MotionEvent.ACTION_UP: |
| if (mDownEvent) { |
| // ConversationItemView gets the first chance to handle up |
| // events if there was a down event and there was no move |
| // event in between. In this case, ConversationItemView |
| // received the down event, and then an up event in the |
| // same location (+/- slop). Treat this as a click on the |
| // view or on a specific part of the view. |
| cancelTap(); |
| if (isTouchInCheckmark(x, y)) { |
| // Touch on the check mark |
| toggleCheckMark(); |
| handled = true; |
| } else if (isTouchInStar(x, y)) { |
| // Touch on the star |
| toggleStar(); |
| handled = true; |
| } else { |
| setActivated(true); |
| // Put the list item click in the queue so we can show |
| // the user tap feedback first. |
| postDelayed(mListItemClick, 0); |
| } |
| handled = true; |
| } else { |
| // There was no down event that this view was made aware of, |
| // therefore it cannot handle it. |
| handled = false; |
| } |
| break; |
| } |
| |
| // Let View try to handle it as well. |
| return handled || super.onTouchEvent(event); |
| } |
| |
| private ListView getListView() { |
| return ((SwipeableConversationItemView) getParent()).getListView(); |
| } |
| |
| private boolean onTouchEventNoSwipe(MotionEvent event) { |
| boolean handled = true; |
| |
| int x = (int) event.getX(); |
| int y = (int) event.getY(); |
| switch (event.getAction()) { |
| case MotionEvent.ACTION_DOWN: |
| mDownEvent = true; |
| // In order to allow the down event and subsequent move events |
| // to bubble to the swipe handler, we need to return that all |
| // down events are handled. |
| handled = isTouchInCheckmark(x, y) || isTouchInStar(x, y); |
| break; |
| case MotionEvent.ACTION_CANCEL: |
| mDownEvent = false; |
| break; |
| case MotionEvent.ACTION_UP: |
| if (mDownEvent) { |
| // ConversationItemView gets the first chance to handle up |
| // events if there was a down event and there was no move |
| // event in between. In this case, ConversationItemView |
| // received the down event, and then an up event in the |
| // same location (+/- slop). Treat this as a click on the |
| // view or on a specific part of the view. |
| if (isTouchInCheckmark(x, y)) { |
| // Touch on the check mark |
| toggleCheckMark(); |
| } else if (isTouchInStar(x, y)) { |
| // Touch on the star |
| toggleStar(); |
| } |
| handled = true; |
| } else { |
| // There was no down event that this view was made aware of, |
| // therefore it cannot handle it. |
| handled = false; |
| } |
| break; |
| } |
| |
| // Let View try to handle it as well. |
| return handled || super.onTouchEvent(event); |
| } |
| |
| /** |
| * Return if this item should respond to long clicks. |
| */ |
| @Override |
| public boolean isLongClickable() { |
| return true; |
| } |
| |
| public void checkForLongPress() { |
| // refreshDrawableState(); |
| final int longPressTimeout = ViewConfiguration.getLongPressTimeout(); |
| final boolean longClickable = isLongClickable(); |
| |
| if (longClickable) { |
| if (mPendingCheckForLongPress == null) { |
| mPendingCheckForLongPress = new HandleLongPress(); |
| } |
| postDelayed(mPendingCheckForLongPress, longPressTimeout); |
| } |
| } |
| |
| private class HandleLongPress implements Runnable { |
| @Override |
| public void run() { |
| setPressed(false); |
| handleLongClick(); |
| performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); |
| } |
| } |
| |
| /** |
| * Grow the height of the item and fade it in when bringing a conversation |
| * back from a destructive action. |
| * @param listener |
| */ |
| public void startSwipeUndoAnimation(ViewMode viewMode, final AnimatorListener listener) { |
| ObjectAnimator undoAnimator = createTranslateXAnimation(true); |
| undoAnimator.addListener(listener); |
| undoAnimator.start(); |
| } |
| |
| /** |
| * Grow the height of the item and fade it in when bringing a conversation |
| * back from a destructive action. |
| * @param listener |
| */ |
| public void startUndoAnimation(ViewMode viewMode, final AnimatorListener listener) { |
| int minHeight = ConversationItemViewCoordinates.getMinHeight(mContext, viewMode); |
| setMinimumHeight(minHeight); |
| mAnimatedHeight = 0; |
| ObjectAnimator height = createHeightAnimation(true); |
| Animator fade = ObjectAnimator.ofFloat(this, "itemAlpha", 0, 1.0f); |
| fade.setDuration(sShrinkAnimationDuration); |
| fade.setInterpolator(new DecelerateInterpolator(2.0f)); |
| AnimatorSet transitionSet = new AnimatorSet(); |
| transitionSet.playTogether(height, fade); |
| transitionSet.addListener(listener); |
| transitionSet.start(); |
| } |
| |
| /** |
| * Grow the height of the item and fade it in when bringing a conversation |
| * back from a destructive action. |
| * @param listener |
| */ |
| public void startDestroyWithSwipeAnimation(final AnimatorListener listener) { |
| ObjectAnimator slide = createTranslateXAnimation(false); |
| ObjectAnimator height = createHeightAnimation(false); |
| AnimatorSet transitionSet = new AnimatorSet(); |
| transitionSet.playSequentially(slide, height); |
| transitionSet.addListener(listener); |
| transitionSet.start(); |
| } |
| |
| private ObjectAnimator createTranslateXAnimation(boolean show) { |
| final float start = show ? sUndoAnimationOffset : 0f; |
| final float end = show ? 0f : sUndoAnimationOffset; |
| ObjectAnimator slide = ObjectAnimator.ofFloat(this, "translationX", start, end); |
| slide.setInterpolator(new DecelerateInterpolator(2.0f)); |
| slide.setDuration(sSlideAnimationDuration); |
| return slide; |
| } |
| |
| private ObjectAnimator createHeightAnimation(boolean show) { |
| int minHeight = ConversationItemViewCoordinates.getMinHeight(getContext(), |
| mActivity.getViewMode()); |
| final int start = show ? 0 : minHeight; |
| final int end = show ? minHeight : 0; |
| ObjectAnimator height = ObjectAnimator.ofInt(this, "animatedHeight", start, end); |
| height.setInterpolator(new DecelerateInterpolator(2.0f)); |
| height.setDuration(sShrinkAnimationDuration); |
| return height; |
| } |
| |
| public void startDestroyAnimation(final AnimatorListener listener) { |
| ObjectAnimator height = createHeightAnimation(false); |
| int minHeight = ConversationItemViewCoordinates.getMinHeight(mContext, |
| mActivity.getViewMode()); |
| setMinimumHeight(0); |
| mAnimatedHeight = minHeight; |
| height.addListener(listener); |
| height.start(); |
| } |
| |
| // Used by animator |
| @SuppressWarnings("unused") |
| public void setItemAlpha(float alpha) { |
| setAlpha(alpha); |
| invalidate(); |
| } |
| |
| // Used by animator |
| @SuppressWarnings("unused") |
| public void setAnimatedHeight(int height) { |
| mAnimatedHeight = height; |
| requestLayout(); |
| } |
| |
| @Override |
| protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
| if (mAnimatedHeight == -1) { |
| int height = measureHeight(heightMeasureSpec, |
| ConversationItemViewCoordinates.getMode(mContext, mActivity.getViewMode())); |
| setMeasuredDimension(widthMeasureSpec, height); |
| } else { |
| setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mAnimatedHeight); |
| } |
| } |
| |
| /** |
| * Determine the height of this view. |
| * @param measureSpec A measureSpec packed into an int |
| * @param mode The current mode of this view |
| * @return The height of the view, honoring constraints from measureSpec |
| */ |
| private int measureHeight(int measureSpec, int mode) { |
| int result = 0; |
| int specMode = MeasureSpec.getMode(measureSpec); |
| int specSize = MeasureSpec.getSize(measureSpec); |
| |
| if (specMode == MeasureSpec.EXACTLY) { |
| // We were told how big to be |
| result = specSize; |
| } else { |
| // Measure the text |
| result = ConversationItemViewCoordinates.getHeight(mContext, mode); |
| if (specMode == MeasureSpec.AT_MOST) { |
| // Respect AT_MOST value if that was what is called for by |
| // measureSpec |
| result = Math.min(result, specSize); |
| } |
| } |
| return result; |
| } |
| |
| /** |
| * Get the current position of this conversation item in the list. |
| */ |
| public int getPosition() { |
| return mHeader != null && mHeader.conversation != null ? |
| mHeader.conversation.position : -1; |
| } |
| |
| /** |
| * Select the current conversation. |
| */ |
| private void selectConversation() { |
| if (!mSelectedConversationSet.containsKey(mHeader.conversation.id)) { |
| toggleCheckMark(); |
| } |
| } |
| |
| @Override |
| public View getView() { |
| return this; |
| } |
| |
| /** |
| * With two pane mode and mailboxes in one pane (tablet), add the |
| * conversation to the selected set and start drag mode. In two pane mode |
| * when viewing conversations (tablet), toggle selection. In one pane mode |
| * (phone, and portrait mode on tablet), toggle selection. |
| */ |
| private void handleLongClick() { |
| mDownEvent = false; |
| // If we are in one pane mode, or we are looking at conversations, drag and drop is |
| // meaningless. Allow the list's long click handler to do the right thing. |
| if (!Utils.useTabletUI(mContext) || !mActivity.getViewMode().isListMode()) { |
| performLongClick(); |
| } else { |
| beginDragMode(); |
| } |
| } |
| |
| /** |
| * Begin drag mode. Keep the conversation selected (NOT toggle selection) and start drag. |
| */ |
| private void beginDragMode() { |
| selectConversation(); |
| |
| // Clip data has form: [conversations_uri, conversationId1, |
| // maxMessageId1, label1, conversationId2, maxMessageId2, label2, ...] |
| final int count = mSelectedConversationSet.size(); |
| String description = Utils.formatPlural(mContext, R.plurals.move_conversation, count); |
| |
| final ClipData data = ClipData.newUri(mContext.getContentResolver(), description, |
| Conversation.MOVE_CONVERSATIONS_URI); |
| for (Conversation conversation : mSelectedConversationSet.values()) { |
| data.addItem(new Item(String.valueOf(conversation.position))); |
| } |
| // Protect against non-existent views: only happens for monkeys |
| final int width = this.getWidth(); |
| final int height = this.getHeight(); |
| final boolean isDimensionNegative = (width < 0) || (height < 0); |
| if (isDimensionNegative) { |
| LogUtils.e(LOG_TAG, "ConversationItemView: dimension is negative: " |
| + "width=%d, height=%d", width, height); |
| return; |
| } |
| // Start drag mode |
| startDrag(data, new ShadowBuilder(this, count, mLastTouchX, mLastTouchY), null, 0); |
| } |
| |
| private class ShadowBuilder extends DragShadowBuilder { |
| private final Drawable mBackground; |
| |
| private final View mView; |
| private final String mDragDesc; |
| private final int mTouchX; |
| private final int mTouchY; |
| private int mDragDescX; |
| private int mDragDescY; |
| |
| public ShadowBuilder(View view, int count, int touchX, int touchY) { |
| super(view); |
| mView = view; |
| mBackground = mView.getResources().getDrawable(R.drawable.list_pressed_holo); |
| mDragDesc = Utils.formatPlural(mView.getContext(), R.plurals.move_conversation, count); |
| mTouchX = touchX; |
| mTouchY = touchY; |
| } |
| |
| @Override |
| public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) { |
| int width = mView.getWidth(); |
| int height = mView.getHeight(); |
| mDragDescX = mCoordinates.sendersX; |
| mDragDescY = getPadding(height, mCoordinates.subjectFontSize) |
| - mCoordinates.subjectAscent; |
| shadowSize.set(width, height); |
| shadowTouchPoint.set(mTouchX, mTouchY); |
| } |
| |
| @Override |
| public void onDrawShadow(Canvas canvas) { |
| mBackground.setBounds(0, 0, mView.getWidth(), mView.getHeight()); |
| mBackground.draw(canvas); |
| sPaint.setTextSize(mCoordinates.subjectFontSize); |
| canvas.drawText(mDragDesc, mDragDescX, mDragDescY, sPaint); |
| } |
| } |
| } |