/* * Copyright (C) 2009 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.email.activity; import android.content.Context; import android.content.res.Configuration; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Paint; 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.ForegroundColorSpan; import android.text.style.StyleSpan; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.view.accessibility.AccessibilityEvent; import com.android.email.R; import com.android.emailcommon.utility.TextUtilities; import com.google.common.base.Objects; /** * This custom View is the list item for the MessageList activity, and serves two purposes: * 1. It's a container to store message metadata (e.g. the ids of the message, mailbox, & account) * 2. It handles internal clicks such as the checkbox or the favorite star */ public class MessageListItem extends View { // Note: messagesAdapter directly fiddles with these fields. /* package */ long mMessageId; /* package */ long mMailboxId; /* package */ long mAccountId; private ThreePaneLayout mLayout; private MessagesAdapter mAdapter; private MessageListItemCoordinates mCoordinates; private Context mContext; private boolean mDownEvent; public static final String MESSAGE_LIST_ITEMS_CLIP_LABEL = "com.android.email.MESSAGE_LIST_ITEMS"; public MessageListItem(Context context) { super(context); init(context); } public MessageListItem(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public MessageListItem(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context); } // Wide mode shows sender, snippet, time, and favorite spread out across the screen private static final int MODE_WIDE = MessageListItemCoordinates.WIDE_MODE; // Sentinel indicating that the view needs layout public static final int NEEDS_LAYOUT = -1; private static boolean sInit = false; private static final TextPaint sDefaultPaint = new TextPaint(); private static final TextPaint sBoldPaint = new TextPaint(); private static final TextPaint sDatePaint = new TextPaint(); private static Bitmap sAttachmentIcon; private static Bitmap sInviteIcon; private static int sBadgeMargin; private static Bitmap sFavoriteIconOff; private static Bitmap sFavoriteIconOn; private static Bitmap sSelectedIconOn; private static Bitmap sSelectedIconOff; private static Bitmap sStateReplied; private static Bitmap sStateForwarded; private static Bitmap sStateRepliedAndForwarded; private static String sSubjectSnippetDivider; private static String sSubjectDescription; private static String sSubjectEmptyDescription; // Static colors. private static int DEFAULT_TEXT_COLOR; private static int ACTIVATED_TEXT_COLOR; private static int LIGHT_TEXT_COLOR; private static int DRAFT_TEXT_COLOR; private static int SUBJECT_TEXT_COLOR_READ; private static int SUBJECT_TEXT_COLOR_UNREAD; private static int SNIPPET_TEXT_COLOR_READ; private static int SNIPPET_TEXT_COLOR_UNREAD; private static int SENDERS_TEXT_COLOR_READ; private static int SENDERS_TEXT_COLOR_UNREAD; private static int DATE_TEXT_COLOR_READ; private static int DATE_TEXT_COLOR_UNREAD; public String mSender; public SpannableStringBuilder mText; public CharSequence mSnippet; private String mSubject; private StaticLayout mSubjectLayout; public boolean mRead; public boolean mHasAttachment = false; public boolean mHasInvite = true; public boolean mIsFavorite = false; public boolean mHasBeenRepliedTo = false; public boolean mHasBeenForwarded = false; /** {@link Paint} for account color chips. null if no chips should be drawn. */ public Paint mColorChipPaint; private int mMode = -1; private int mViewWidth = 0; private int mViewHeight = 0; private static int sItemHeightWide; private static int sItemHeightNormal; // Note: these cannot be shared Drawables because they are selectors which have state. private Drawable mReadSelector; private Drawable mUnreadSelector; private Drawable mWideReadSelector; private Drawable mWideUnreadSelector; private CharSequence mFormattedSender; // We must initialize this to something, in case the timestamp of the message is zero (which // should be very rare); this is otherwise set in setTimestamp private CharSequence mFormattedDate = ""; private void init(Context context) { mContext = context; if (!sInit) { Resources r = context.getResources(); sSubjectDescription = r.getString(R.string.message_subject_description).concat(", "); sSubjectEmptyDescription = r.getString(R.string.message_is_empty_description); sSubjectSnippetDivider = r.getString(R.string.message_list_subject_snippet_divider); sItemHeightWide = r.getDimensionPixelSize(R.dimen.message_list_item_height_wide); sItemHeightNormal = r.getDimensionPixelSize(R.dimen.message_list_item_height_normal); sDefaultPaint.setTypeface(Typeface.DEFAULT); sDefaultPaint.setAntiAlias(true); sDatePaint.setTypeface(Typeface.DEFAULT); sDatePaint.setAntiAlias(true); sBoldPaint.setTypeface(Typeface.DEFAULT_BOLD); sBoldPaint.setAntiAlias(true); sAttachmentIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_attachment); sInviteIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_invite_holo_light); sBadgeMargin = r.getDimensionPixelSize(R.dimen.message_list_badge_margin); sFavoriteIconOff = BitmapFactory.decodeResource(r, R.drawable.btn_star_off_normal_email_holo_light); sFavoriteIconOn = BitmapFactory.decodeResource(r, R.drawable.btn_star_on_normal_email_holo_light); sSelectedIconOff = BitmapFactory.decodeResource(r, R.drawable.btn_check_off_normal_holo_light); sSelectedIconOn = BitmapFactory.decodeResource(r, R.drawable.btn_check_on_normal_holo_light); sStateReplied = BitmapFactory.decodeResource(r, R.drawable.ic_badge_reply_holo_light); sStateForwarded = BitmapFactory.decodeResource(r, R.drawable.ic_badge_forward_holo_light); sStateRepliedAndForwarded = BitmapFactory.decodeResource(r, R.drawable.ic_badge_reply_forward_holo_light); DEFAULT_TEXT_COLOR = r.getColor(R.color.default_text_color); ACTIVATED_TEXT_COLOR = r.getColor(android.R.color.white); SUBJECT_TEXT_COLOR_READ = r.getColor(R.color.subject_text_color_read); SUBJECT_TEXT_COLOR_UNREAD = r.getColor(R.color.subject_text_color_unread); SNIPPET_TEXT_COLOR_READ = r.getColor(R.color.snippet_text_color_read); SNIPPET_TEXT_COLOR_UNREAD = r.getColor(R.color.snippet_text_color_unread); SENDERS_TEXT_COLOR_READ = r.getColor(R.color.senders_text_color_read); SENDERS_TEXT_COLOR_UNREAD = r.getColor(R.color.senders_text_color_unread); DATE_TEXT_COLOR_READ = r.getColor(R.color.date_text_color_read); DATE_TEXT_COLOR_UNREAD = r.getColor(R.color.date_text_color_unread); sInit = true; } } /** * Invalidate all drawing caches associated with drawing message list items. * This is an expensive operation, and should be done rarely, such as when system font size * changes occurs. */ public static void resetDrawingCaches() { MessageListItemCoordinates.resetCaches(); sInit = false; } /** * Sets message subject and snippet safely, ensuring the cache is invalidated. */ public void setText(String subject, String snippet, boolean forceUpdate) { boolean changed = false; if (!Objects.equal(mSubject, subject)) { mSubject = subject; changed = true; populateContentDescription(); } if (!Objects.equal(mSnippet, snippet)) { mSnippet = snippet; changed = true; } if (forceUpdate || changed || (mSubject == null && mSnippet == null) /* first time */) { SpannableStringBuilder ssb = new SpannableStringBuilder(); boolean hasSubject = false; if (!TextUtils.isEmpty(mSubject)) { SpannableString ss = new SpannableString(mSubject); ss.setSpan(new StyleSpan(mRead ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); ssb.append(ss); hasSubject = true; } if (!TextUtils.isEmpty(mSnippet)) { if (hasSubject) { ssb.append(sSubjectSnippetDivider); } ssb.append(mSnippet); } mText = ssb; requestLayout(); } } long mTimeFormatted = 0; public void setTimestamp(long timestamp) { if (mTimeFormatted != timestamp) { mFormattedDate = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString(); mTimeFormatted = timestamp; } } /** * Determine the mode of this view (WIDE or NORMAL) * * @param width The width of the view * @return The mode of the view */ private int getViewMode(int width) { return MessageListItemCoordinates.getMode(mContext, width); } private Drawable mCurentBackground = null; // Only used by updateBackground() private void updateBackground() { final Drawable newBackground; boolean isMultiPane = MessageListItemCoordinates.isMultiPane(mContext); if (mRead) { if (isMultiPane && mLayout.isLeftPaneVisible()) { if (mWideReadSelector == null) { mWideReadSelector = getContext().getResources() .getDrawable(R.drawable.conversation_wide_read_selector); } newBackground = mWideReadSelector; } else { if (mReadSelector == null) { mReadSelector = getContext().getResources() .getDrawable(R.drawable.conversation_read_selector); } newBackground = mReadSelector; } } else { if (isMultiPane && mLayout.isLeftPaneVisible()) { if (mWideUnreadSelector == null) { mWideUnreadSelector = getContext().getResources().getDrawable( R.drawable.conversation_wide_unread_selector); } newBackground = mWideUnreadSelector; } else { if (mUnreadSelector == null) { mUnreadSelector = getContext().getResources() .getDrawable(R.drawable.conversation_unread_selector); } newBackground = mUnreadSelector; } } if (newBackground != mCurentBackground) { // setBackgroundDrawable is a heavy operation. Only call it when really needed. setBackgroundDrawable(newBackground); mCurentBackground = newBackground; } } private void calculateSubjectText() { if (mText == null || mText.length() == 0) { return; } boolean hasSubject = false; int snippetStart = 0; if (!TextUtils.isEmpty(mSubject)) { int subjectColor = getFontColor(mRead ? SUBJECT_TEXT_COLOR_READ : SUBJECT_TEXT_COLOR_UNREAD); mText.setSpan(new ForegroundColorSpan(subjectColor), 0, mSubject.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); snippetStart = mSubject.length() + 1; } if (!TextUtils.isEmpty(mSnippet)) { int snippetColor = getFontColor(mRead ? SNIPPET_TEXT_COLOR_READ : SNIPPET_TEXT_COLOR_UNREAD); mText.setSpan(new ForegroundColorSpan(snippetColor), snippetStart, mText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } } private void calculateDrawingData() { sDefaultPaint.setTextSize(mCoordinates.subjectFontSize); calculateSubjectText(); mSubjectLayout = new StaticLayout(mText, sDefaultPaint, mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, false /* includePad */); if (mCoordinates.subjectLineCount < mSubjectLayout.getLineCount()) { // TODO: ellipsize. int end = mSubjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1); mSubjectLayout = new StaticLayout(mText.subSequence(0, end), sDefaultPaint, mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true); } // Now, format the sender for its width TextPaint senderPaint = mRead ? sDefaultPaint : sBoldPaint; // And get the ellipsized string for the calculated width if (TextUtils.isEmpty(mSender)) { mFormattedSender = ""; } else { int senderWidth = mCoordinates.sendersWidth; senderPaint.setTextSize(mCoordinates.sendersFontSize); senderPaint.setColor(getFontColor(mRead ? SENDERS_TEXT_COLOR_READ : SENDERS_TEXT_COLOR_UNREAD)); mFormattedSender = TextUtils.ellipsize(mSender, senderPaint, senderWidth, TruncateAt.END); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (widthMeasureSpec != 0 || mViewWidth == 0) { mViewWidth = MeasureSpec.getSize(widthMeasureSpec); int mode = getViewMode(mViewWidth); if (mode != mMode) { mMode = mode; } mViewHeight = measureHeight(heightMeasureSpec, mMode); } setMeasuredDimension(mViewWidth, mViewHeight); } /** * 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 if (mMode == MODE_WIDE) { result = sItemHeightWide; } else { result = sItemHeightNormal; } 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; } @Override public void draw(Canvas canvas) { // Update the background, before View.draw() draws it. setSelected(mAdapter.isSelected(this)); updateBackground(); super.draw(canvas); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); mCoordinates = MessageListItemCoordinates.forWidth(mContext, mViewWidth); calculateDrawingData(); } private int getFontColor(int defaultColor) { return isActivated() ? ACTIVATED_TEXT_COLOR : defaultColor; } @Override protected void onDraw(Canvas canvas) { // Draw the color chip indicating the mailbox this belongs to if (mColorChipPaint != null) { canvas.drawRect( mCoordinates.chipX, mCoordinates.chipY, mCoordinates.chipX + mCoordinates.chipWidth, mCoordinates.chipY + mCoordinates.chipHeight, mColorChipPaint); } // Draw the checkbox canvas.drawBitmap(mAdapter.isSelected(this) ? sSelectedIconOn : sSelectedIconOff, mCoordinates.checkmarkX, mCoordinates.checkmarkY, null); // Draw the sender name Paint senderPaint = mRead ? sDefaultPaint : sBoldPaint; senderPaint.setColor(getFontColor(mRead ? SENDERS_TEXT_COLOR_READ : SENDERS_TEXT_COLOR_UNREAD)); senderPaint.setTextSize(mCoordinates.sendersFontSize); canvas.drawText(mFormattedSender, 0, mFormattedSender.length(), mCoordinates.sendersX, mCoordinates.sendersY - mCoordinates.sendersAscent, senderPaint); // Draw the reply state. Draw nothing if neither replied nor forwarded. if (mHasBeenRepliedTo && mHasBeenForwarded) { canvas.drawBitmap(sStateRepliedAndForwarded, mCoordinates.stateX, mCoordinates.stateY, null); } else if (mHasBeenRepliedTo) { canvas.drawBitmap(sStateReplied, mCoordinates.stateX, mCoordinates.stateY, null); } else if (mHasBeenForwarded) { canvas.drawBitmap(sStateForwarded, mCoordinates.stateX, mCoordinates.stateY, null); } // Subject and snippet. sDefaultPaint.setTextSize(mCoordinates.subjectFontSize); canvas.save(); canvas.translate( mCoordinates.subjectX, mCoordinates.subjectY); mSubjectLayout.draw(canvas); canvas.restore(); // Draw the date sDatePaint.setTextSize(mCoordinates.dateFontSize); sDatePaint.setColor(mRead ? DATE_TEXT_COLOR_READ : DATE_TEXT_COLOR_UNREAD); int dateX = mCoordinates.dateXEnd - (int) sDatePaint.measureText(mFormattedDate, 0, mFormattedDate.length()); canvas.drawText(mFormattedDate, 0, mFormattedDate.length(), dateX, mCoordinates.dateY - mCoordinates.dateAscent, sDatePaint); // Draw the favorite icon canvas.drawBitmap(mIsFavorite ? sFavoriteIconOn : sFavoriteIconOff, mCoordinates.starX, mCoordinates.starY, null); // TODO: deal with the icon layouts better from the coordinate class so that this logic // doesn't have to exist. // Draw the attachment and invite icons, if necessary. int iconsLeft = dateX - sBadgeMargin; if (mHasAttachment) { iconsLeft = iconsLeft - sAttachmentIcon.getWidth(); canvas.drawBitmap(sAttachmentIcon, iconsLeft, mCoordinates.paperclipY, null); } if (mHasInvite) { iconsLeft -= sInviteIcon.getWidth(); canvas.drawBitmap(sInviteIcon, iconsLeft, mCoordinates.paperclipY, null); } } /** * Called by the adapter at bindView() time * * @param adapter the adapter that creates this view * @param layout If this is a three pane implementation, the * ThreePaneLayout. Otherwise, null. */ public void bindViewInit(MessagesAdapter adapter, ThreePaneLayout layout) { mLayout = layout; mAdapter = adapter; requestLayout(); } private static final int TOUCH_SLOP = 24; private static int sScaledTouchSlop = -1; private void initializeSlop(Context context) { if (sScaledTouchSlop == -1) { final Resources res = context.getResources(); final Configuration config = res.getConfiguration(); final float density = res.getDisplayMetrics().density; final float sizeAndDensity; if (config.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_XLARGE)) { sizeAndDensity = density * 1.5f; } else { sizeAndDensity = density; } sScaledTouchSlop = (int) (sizeAndDensity * TOUCH_SLOP + 0.5f); } } /** * Overriding this method allows us to "catch" clicks in the checkbox or star * and process them accordingly. */ @Override public boolean onTouchEvent(MotionEvent event) { initializeSlop(getContext()); boolean handled = false; int touchX = (int) event.getX(); int checkRight = mCoordinates.checkmarkX + mCoordinates.checkmarkWidthIncludingMargins + sScaledTouchSlop; int starLeft = mCoordinates.starX - sScaledTouchSlop; switch (event.getAction()) { case MotionEvent.ACTION_DOWN: if (touchX < checkRight || touchX > starLeft) { mDownEvent = true; if ((touchX < checkRight) || (touchX > starLeft)) { handled = true; } } break; case MotionEvent.ACTION_CANCEL: mDownEvent = false; break; case MotionEvent.ACTION_UP: if (mDownEvent) { if (touchX < checkRight) { mAdapter.toggleSelected(this); handled = true; } else if (touchX > starLeft) { mIsFavorite = !mIsFavorite; mAdapter.updateFavorite(this, mIsFavorite); handled = true; } } break; } if (handled) { invalidate(); } else { handled = super.onTouchEvent(event); } return handled; } @Override public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { event.setClassName(getClass().getName()); event.setPackageName(getContext().getPackageName()); event.setEnabled(true); event.setContentDescription(getContentDescription()); return true; } /** * Sets the content description for this item, used for accessibility. */ private void populateContentDescription() { if (!TextUtils.isEmpty(mSubject)) { setContentDescription(sSubjectDescription + mSubject); } else { setContentDescription(sSubjectEmptyDescription); } } }