Home | History | Annotate | Download | only in activity
      1 /*
      2  * Copyright (C) 2009 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.email.activity;
     18 
     19 import android.content.Context;
     20 import android.content.res.Configuration;
     21 import android.content.res.Resources;
     22 import android.graphics.Bitmap;
     23 import android.graphics.BitmapFactory;
     24 import android.graphics.Canvas;
     25 import android.graphics.Paint;
     26 import android.graphics.Typeface;
     27 import android.graphics.drawable.Drawable;
     28 import android.text.Layout.Alignment;
     29 import android.text.Spannable;
     30 import android.text.SpannableString;
     31 import android.text.SpannableStringBuilder;
     32 import android.text.StaticLayout;
     33 import android.text.TextPaint;
     34 import android.text.TextUtils;
     35 import android.text.TextUtils.TruncateAt;
     36 import android.text.format.DateUtils;
     37 import android.text.style.StyleSpan;
     38 import android.util.AttributeSet;
     39 import android.view.MotionEvent;
     40 import android.view.View;
     41 import android.view.accessibility.AccessibilityEvent;
     42 
     43 import com.android.email.R;
     44 import com.android.emailcommon.utility.TextUtilities;
     45 import com.google.common.base.Objects;
     46 
     47 /**
     48  * This custom View is the list item for the MessageList activity, and serves two purposes:
     49  * 1.  It's a container to store message metadata (e.g. the ids of the message, mailbox, & account)
     50  * 2.  It handles internal clicks such as the checkbox or the favorite star
     51  */
     52 public class MessageListItem extends View {
     53     // Note: messagesAdapter directly fiddles with these fields.
     54     /* package */ long mMessageId;
     55     /* package */ long mMailboxId;
     56     /* package */ long mAccountId;
     57 
     58     private MessagesAdapter mAdapter;
     59     private MessageListItemCoordinates mCoordinates;
     60     private Context mContext;
     61 
     62     private boolean mDownEvent;
     63 
     64     public static final String MESSAGE_LIST_ITEMS_CLIP_LABEL =
     65         "com.android.email.MESSAGE_LIST_ITEMS";
     66 
     67     public MessageListItem(Context context) {
     68         super(context);
     69         init(context);
     70     }
     71 
     72     public MessageListItem(Context context, AttributeSet attrs) {
     73         super(context, attrs);
     74         init(context);
     75     }
     76 
     77     public MessageListItem(Context context, AttributeSet attrs, int defStyle) {
     78         super(context, attrs, defStyle);
     79         init(context);
     80     }
     81 
     82     // Wide mode shows sender, snippet, time, and favorite spread out across the screen
     83     private static final int MODE_WIDE = MessageListItemCoordinates.WIDE_MODE;
     84     // Sentinel indicating that the view needs layout
     85     public static final int NEEDS_LAYOUT = -1;
     86 
     87     private static boolean sInit = false;
     88     private static final TextPaint sDefaultPaint = new TextPaint();
     89     private static final TextPaint sBoldPaint = new TextPaint();
     90     private static final TextPaint sDatePaint = new TextPaint();
     91     private static final TextPaint sHighlightPaint = new TextPaint();
     92     private static Bitmap sAttachmentIcon;
     93     private static Bitmap sInviteIcon;
     94     private static int sBadgeMargin;
     95     private static Bitmap sFavoriteIconOff;
     96     private static Bitmap sFavoriteIconOn;
     97     private static Bitmap sSelectedIconOn;
     98     private static Bitmap sSelectedIconOff;
     99     private static Bitmap sStateReplied;
    100     private static Bitmap sStateForwarded;
    101     private static Bitmap sStateRepliedAndForwarded;
    102     private static String sSubjectSnippetDivider;
    103     private static String sSubjectDescription;
    104     private static String sSubjectEmptyDescription;
    105     private static int sFontColorActivated;
    106     private static int sFontColor;
    107 
    108     public String mSender;
    109     public CharSequence mText;
    110     public CharSequence mSnippet;
    111     private String mSubject;
    112     private StaticLayout mSubjectLayout;
    113     public boolean mRead;
    114     public boolean mHasAttachment = false;
    115     public boolean mHasInvite = true;
    116     public boolean mIsFavorite = false;
    117     public boolean mHasBeenRepliedTo = false;
    118     public boolean mHasBeenForwarded = false;
    119     /** {@link Paint} for account color chips.  null if no chips should be drawn.  */
    120     public Paint mColorChipPaint;
    121 
    122     private int mMode = -1;
    123 
    124     private int mViewWidth = 0;
    125     private int mViewHeight = 0;
    126 
    127     private static int sItemHeightWide;
    128     private static int sItemHeightNormal;
    129 
    130     // Note: these cannot be shared Drawables because they are selectors which have state.
    131     private Drawable mReadSelector;
    132     private Drawable mUnreadSelector;
    133     private Drawable mWideReadSelector;
    134     private Drawable mWideUnreadSelector;
    135 
    136     private CharSequence mFormattedSender;
    137     // We must initialize this to something, in case the timestamp of the message is zero (which
    138     // should be very rare); this is otherwise set in setTimestamp
    139     private CharSequence mFormattedDate = "";
    140 
    141     private void init(Context context) {
    142         mContext = context;
    143         if (!sInit) {
    144             Resources r = context.getResources();
    145             sSubjectDescription = r.getString(R.string.message_subject_description).concat(", ");
    146             sSubjectEmptyDescription = r.getString(R.string.message_is_empty_description);
    147             sSubjectSnippetDivider = r.getString(R.string.message_list_subject_snippet_divider);
    148             sItemHeightWide =
    149                 r.getDimensionPixelSize(R.dimen.message_list_item_height_wide);
    150             sItemHeightNormal =
    151                 r.getDimensionPixelSize(R.dimen.message_list_item_height_normal);
    152 
    153             sDefaultPaint.setTypeface(Typeface.DEFAULT);
    154             sDefaultPaint.setAntiAlias(true);
    155             sDatePaint.setTypeface(Typeface.DEFAULT);
    156             sDatePaint.setAntiAlias(true);
    157             sBoldPaint.setTypeface(Typeface.DEFAULT_BOLD);
    158             sBoldPaint.setAntiAlias(true);
    159             sHighlightPaint.setColor(TextUtilities.HIGHLIGHT_COLOR_INT);
    160             sAttachmentIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_attachment);
    161             sInviteIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_invite_holo_light);
    162             sBadgeMargin = r.getDimensionPixelSize(R.dimen.message_list_badge_margin);
    163             sFavoriteIconOff =
    164                 BitmapFactory.decodeResource(r, R.drawable.btn_star_off_normal_email_holo_light);
    165             sFavoriteIconOn =
    166                 BitmapFactory.decodeResource(r, R.drawable.btn_star_on_normal_email_holo_light);
    167             sSelectedIconOff =
    168                 BitmapFactory.decodeResource(r, R.drawable.btn_check_off_normal_holo_light);
    169             sSelectedIconOn =
    170                 BitmapFactory.decodeResource(r, R.drawable.btn_check_on_normal_holo_light);
    171 
    172             sStateReplied =
    173                 BitmapFactory.decodeResource(r, R.drawable.ic_badge_reply_holo_light);
    174             sStateForwarded =
    175                 BitmapFactory.decodeResource(r, R.drawable.ic_badge_forward_holo_light);
    176             sStateRepliedAndForwarded =
    177                 BitmapFactory.decodeResource(r, R.drawable.ic_badge_reply_forward_holo_light);
    178 
    179             sFontColor = r.getColor(android.R.color.black);
    180             sFontColorActivated = r.getColor(android.R.color.white);
    181 
    182             sInit = true;
    183         }
    184     }
    185 
    186     /**
    187      * Sets message subject and snippet safely, ensuring the cache is invalidated.
    188      */
    189     public void setText(String subject, String snippet, boolean forceUpdate) {
    190         boolean changed = false;
    191         if (!Objects.equal(mSubject, subject)) {
    192             mSubject = subject;
    193             changed = true;
    194             populateContentDescription();
    195         }
    196 
    197         if (!Objects.equal(mSnippet, snippet)) {
    198             mSnippet = snippet;
    199             changed = true;
    200         }
    201 
    202         if (forceUpdate || changed || (mSubject == null && mSnippet == null) /* first time */) {
    203             SpannableStringBuilder ssb = new SpannableStringBuilder();
    204             boolean hasSubject = false;
    205             if (!TextUtils.isEmpty(mSubject)) {
    206                 SpannableString ss = new SpannableString(mSubject);
    207                 ss.setSpan(new StyleSpan(mRead ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(),
    208                         Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
    209                 ssb.append(ss);
    210                 hasSubject = true;
    211             }
    212             if (!TextUtils.isEmpty(mSnippet)) {
    213                 if (hasSubject) {
    214                     ssb.append(sSubjectSnippetDivider);
    215                 }
    216                 ssb.append(mSnippet);
    217             }
    218             mText = ssb;
    219             requestLayout();
    220         }
    221     }
    222 
    223     long mTimeFormatted = 0;
    224     public void setTimestamp(long timestamp) {
    225         if (mTimeFormatted != timestamp) {
    226             mFormattedDate = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString();
    227             mTimeFormatted = timestamp;
    228         }
    229     }
    230 
    231     /**
    232      * Determine the mode of this view (WIDE or NORMAL)
    233      *
    234      * @param width The width of the view
    235      * @return The mode of the view
    236      */
    237     private int getViewMode(int width) {
    238         return MessageListItemCoordinates.getMode(mContext, width);
    239     }
    240 
    241     private Drawable mCurentBackground = null; // Only used by updateBackground()
    242 
    243     private void updateBackground() {
    244         final Drawable newBackground;
    245         if (mRead) {
    246             if (mMode == MODE_WIDE) {
    247                 if (mWideReadSelector == null) {
    248                     mWideReadSelector = getContext().getResources()
    249                             .getDrawable(R.drawable.message_list_wide_read_selector);
    250                 }
    251                 newBackground = mWideReadSelector;
    252             } else {
    253                 if (mReadSelector == null) {
    254                     mReadSelector = getContext().getResources()
    255                             .getDrawable(R.drawable.message_list_read_selector);
    256                 }
    257                 newBackground = mReadSelector;
    258             }
    259         } else {
    260             if (mMode == MODE_WIDE) {
    261                 if (mWideUnreadSelector == null) {
    262                     mWideUnreadSelector = getContext().getResources()
    263                             .getDrawable(R.drawable.message_list_wide_unread_selector);
    264                 }
    265                 newBackground = mWideUnreadSelector;
    266             } else {
    267                 if (mUnreadSelector == null) {
    268                     mUnreadSelector = getContext().getResources()
    269                             .getDrawable(R.drawable.message_list_unread_selector);
    270                 }
    271                 newBackground = mUnreadSelector;
    272             }
    273         }
    274         if (newBackground != mCurentBackground) {
    275             // setBackgroundDrawable is a heavy operation.  Only call it when really needed.
    276             setBackgroundDrawable(newBackground);
    277             mCurentBackground = newBackground;
    278         }
    279     }
    280 
    281     private void calculateDrawingData() {
    282         sDefaultPaint.setTextSize(mCoordinates.subjectFontSize);
    283         mSubjectLayout = new StaticLayout(mText, sDefaultPaint,
    284                 mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, false /* includePad */);
    285         if (mCoordinates.subjectLineCount < mSubjectLayout.getLineCount()) {
    286             // TODO: ellipsize.
    287             int end = mSubjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1);
    288             mSubjectLayout = new StaticLayout(mText.subSequence(0, end),
    289                     sDefaultPaint, mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
    290         }
    291 
    292         // Now, format the sender for its width
    293         TextPaint senderPaint = mRead ? sDefaultPaint : sBoldPaint;
    294         // And get the ellipsized string for the calculated width
    295         if (TextUtils.isEmpty(mSender)) {
    296             mFormattedSender = "";
    297         } else {
    298             int senderWidth = mCoordinates.sendersWidth;
    299             senderPaint.setTextSize(mCoordinates.sendersFontSize);
    300             mFormattedSender = TextUtils.ellipsize(mSender, senderPaint, senderWidth,
    301                     TruncateAt.END);
    302         }
    303     }
    304 
    305     @Override
    306     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    307         if (widthMeasureSpec != 0 || mViewWidth == 0) {
    308             mViewWidth = MeasureSpec.getSize(widthMeasureSpec);
    309             int mode = getViewMode(mViewWidth);
    310             if (mode != mMode) {
    311                 mMode = mode;
    312             }
    313             mViewHeight = measureHeight(heightMeasureSpec, mMode);
    314         }
    315         setMeasuredDimension(mViewWidth, mViewHeight);
    316     }
    317 
    318     /**
    319      * Determine the height of this view
    320      *
    321      * @param measureSpec A measureSpec packed into an int
    322      * @param mode The current mode of this view
    323      * @return The height of the view, honoring constraints from measureSpec
    324      */
    325     private int measureHeight(int measureSpec, int mode) {
    326         int result = 0;
    327         int specMode = MeasureSpec.getMode(measureSpec);
    328         int specSize = MeasureSpec.getSize(measureSpec);
    329 
    330         if (specMode == MeasureSpec.EXACTLY) {
    331             // We were told how big to be
    332             result = specSize;
    333         } else {
    334             // Measure the text
    335             if (mMode == MODE_WIDE) {
    336                 result = sItemHeightWide;
    337             } else {
    338                 result = sItemHeightNormal;
    339             }
    340             if (specMode == MeasureSpec.AT_MOST) {
    341                 // Respect AT_MOST value if that was what is called for by
    342                 // measureSpec
    343                 result = Math.min(result, specSize);
    344             }
    345         }
    346         return result;
    347     }
    348 
    349     @Override
    350     public void draw(Canvas canvas) {
    351         // Update the background, before View.draw() draws it.
    352         setSelected(mAdapter.isSelected(this));
    353         updateBackground();
    354         super.draw(canvas);
    355     }
    356 
    357     @Override
    358     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    359         super.onLayout(changed, left, top, right, bottom);
    360 
    361         mCoordinates = MessageListItemCoordinates.forWidth(mContext, mViewWidth);
    362         calculateDrawingData();
    363     }
    364 
    365     @Override
    366     protected void onDraw(Canvas canvas) {
    367         // Draw the color chip indicating the mailbox this belongs to
    368         if (mColorChipPaint != null) {
    369             canvas.drawRect(
    370                     mCoordinates.chipX, mCoordinates.chipY,
    371                     mCoordinates.chipX + mCoordinates.chipWidth,
    372                     mCoordinates.chipY + mCoordinates.chipHeight,
    373                     mColorChipPaint);
    374         }
    375 
    376         int fontColor = isActivated() ? sFontColorActivated : sFontColor;
    377 
    378         // Draw the checkbox
    379         canvas.drawBitmap(mAdapter.isSelected(this) ? sSelectedIconOn : sSelectedIconOff,
    380                 mCoordinates.checkmarkX, mCoordinates.checkmarkY, null);
    381 
    382         // Draw the sender name
    383         Paint senderPaint = mRead ? sDefaultPaint : sBoldPaint;
    384         senderPaint.setColor(fontColor);
    385         senderPaint.setTextSize(mCoordinates.sendersFontSize);
    386         canvas.drawText(mFormattedSender, 0, mFormattedSender.length(),
    387                 mCoordinates.sendersX, mCoordinates.sendersY - mCoordinates.sendersAscent,
    388                 senderPaint);
    389 
    390         // Draw the reply state. Draw nothing if neither replied nor forwarded.
    391         if (mHasBeenRepliedTo && mHasBeenForwarded) {
    392             canvas.drawBitmap(sStateRepliedAndForwarded,
    393                     mCoordinates.stateX, mCoordinates.stateY, null);
    394         } else if (mHasBeenRepliedTo) {
    395             canvas.drawBitmap(sStateReplied,
    396                     mCoordinates.stateX, mCoordinates.stateY, null);
    397         } else if (mHasBeenForwarded) {
    398             canvas.drawBitmap(sStateForwarded,
    399                     mCoordinates.stateX, mCoordinates.stateY, null);
    400         }
    401 
    402         // Subject and snippet.
    403         sDefaultPaint.setTextSize(mCoordinates.subjectFontSize);
    404         sDefaultPaint.setColor(fontColor);
    405         canvas.save();
    406         canvas.translate(
    407                 mCoordinates.subjectX,
    408                 mCoordinates.subjectY);
    409         mSubjectLayout.draw(canvas);
    410         canvas.restore();
    411 
    412         // Draw the date
    413         sDatePaint.setTextSize(mCoordinates.dateFontSize);
    414         sDatePaint.setColor(fontColor);
    415         int dateX = mCoordinates.dateXEnd
    416                 - (int) sDatePaint.measureText(mFormattedDate, 0, mFormattedDate.length());
    417 
    418         canvas.drawText(mFormattedDate, 0, mFormattedDate.length(),
    419                 dateX, mCoordinates.dateY - mCoordinates.dateAscent, sDatePaint);
    420 
    421         // Draw the favorite icon
    422         canvas.drawBitmap(mIsFavorite ? sFavoriteIconOn : sFavoriteIconOff,
    423                 mCoordinates.starX, mCoordinates.starY, null);
    424 
    425         // TODO: deal with the icon layouts better from the coordinate class so that this logic
    426         // doesn't have to exist.
    427         // Draw the attachment and invite icons, if necessary.
    428         int iconsLeft = dateX - sBadgeMargin;
    429         if (mHasAttachment) {
    430             iconsLeft = iconsLeft - sAttachmentIcon.getWidth();
    431             canvas.drawBitmap(sAttachmentIcon, iconsLeft, mCoordinates.paperclipY, null);
    432         }
    433         if (mHasInvite) {
    434             iconsLeft -= sInviteIcon.getWidth();
    435             canvas.drawBitmap(sInviteIcon, iconsLeft, mCoordinates.paperclipY, null);
    436         }
    437 
    438     }
    439 
    440     /**
    441      * Called by the adapter at bindView() time
    442      *
    443      * @param adapter the adapter that creates this view
    444      */
    445     public void bindViewInit(MessagesAdapter adapter) {
    446         mAdapter = adapter;
    447     }
    448 
    449 
    450     private static final int TOUCH_SLOP = 24;
    451     private static int sScaledTouchSlop = -1;
    452 
    453     private void initializeSlop(Context context) {
    454         if (sScaledTouchSlop == -1) {
    455             final Resources res = context.getResources();
    456             final Configuration config = res.getConfiguration();
    457             final float density = res.getDisplayMetrics().density;
    458             final float sizeAndDensity;
    459             if (config.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_XLARGE)) {
    460                 sizeAndDensity = density * 1.5f;
    461             } else {
    462                 sizeAndDensity = density;
    463             }
    464             sScaledTouchSlop = (int) (sizeAndDensity * TOUCH_SLOP + 0.5f);
    465         }
    466     }
    467 
    468     /**
    469      * Overriding this method allows us to "catch" clicks in the checkbox or star
    470      * and process them accordingly.
    471      */
    472     @Override
    473     public boolean onTouchEvent(MotionEvent event) {
    474         initializeSlop(getContext());
    475 
    476         boolean handled = false;
    477         int touchX = (int) event.getX();
    478         int checkRight = mCoordinates.checkmarkX
    479                 + mCoordinates.checkmarkWidthIncludingMargins + sScaledTouchSlop;
    480         int starLeft = mCoordinates.starX - sScaledTouchSlop;
    481 
    482         switch (event.getAction()) {
    483             case MotionEvent.ACTION_DOWN:
    484                 if (touchX < checkRight || touchX > starLeft) {
    485                     mDownEvent = true;
    486                     if ((touchX < checkRight) || (touchX > starLeft)) {
    487                         handled = true;
    488                     }
    489                 }
    490                 break;
    491 
    492             case MotionEvent.ACTION_CANCEL:
    493                 mDownEvent = false;
    494                 break;
    495 
    496             case MotionEvent.ACTION_UP:
    497                 if (mDownEvent) {
    498                     if (touchX < checkRight) {
    499                         mAdapter.toggleSelected(this);
    500                         handled = true;
    501                     } else if (touchX > starLeft) {
    502                         mIsFavorite = !mIsFavorite;
    503                         mAdapter.updateFavorite(this, mIsFavorite);
    504                         handled = true;
    505                     }
    506                 }
    507                 break;
    508         }
    509 
    510         if (handled) {
    511             invalidate();
    512         } else {
    513             handled = super.onTouchEvent(event);
    514         }
    515 
    516         return handled;
    517     }
    518 
    519     @Override
    520     public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
    521         event.setClassName(getClass().getName());
    522         event.setPackageName(getContext().getPackageName());
    523         event.setEnabled(true);
    524         event.setContentDescription(getContentDescription());
    525         return true;
    526     }
    527 
    528     /**
    529      * Sets the content description for this item, used for accessibility.
    530      */
    531     private void populateContentDescription() {
    532         if (!TextUtils.isEmpty(mSubject)) {
    533             setContentDescription(sSubjectDescription + mSubject);
    534         } else {
    535             setContentDescription(sSubjectEmptyDescription);
    536         }
    537     }
    538 }
    539