MessageListItem.java revision 891da84a254d55c68df21998fbce9c1772eb6172
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
17package com.android.email.activity;
18
19import android.content.Context;
20import android.content.res.Configuration;
21import android.content.res.Resources;
22import android.graphics.Bitmap;
23import android.graphics.BitmapFactory;
24import android.graphics.Canvas;
25import android.graphics.Paint;
26import android.graphics.Typeface;
27import android.graphics.drawable.Drawable;
28import android.text.Layout.Alignment;
29import android.text.Spannable;
30import android.text.SpannableString;
31import android.text.SpannableStringBuilder;
32import android.text.StaticLayout;
33import android.text.TextPaint;
34import android.text.TextUtils;
35import android.text.TextUtils.TruncateAt;
36import android.text.format.DateUtils;
37import android.text.style.StyleSpan;
38import android.util.AttributeSet;
39import android.view.MotionEvent;
40import android.view.View;
41import android.view.accessibility.AccessibilityEvent;
42
43import com.android.email.R;
44import com.android.emailcommon.utility.TextUtilities;
45
46import com.google.common.annotations.VisibleForTesting;
47import com.google.common.base.Objects;
48
49/**
50 * This custom View is the list item for the MessageList activity, and serves two purposes:
51 * 1.  It's a container to store message metadata (e.g. the ids of the message, mailbox, & account)
52 * 2.  It handles internal clicks such as the checkbox or the favorite star
53 */
54public class MessageListItem extends View {
55    // Note: messagesAdapter directly fiddles with these fields.
56    /* package */ long mMessageId;
57    /* package */ long mMailboxId;
58    /* package */ long mAccountId;
59
60    private MessagesAdapter mAdapter;
61    private MessageListItemCoordinates mCoordinates;
62    private Context mContext;
63
64    private boolean mDownEvent;
65
66    public static final String MESSAGE_LIST_ITEMS_CLIP_LABEL =
67        "com.android.email.MESSAGE_LIST_ITEMS";
68
69    public MessageListItem(Context context) {
70        super(context);
71        init(context);
72    }
73
74    public MessageListItem(Context context, AttributeSet attrs) {
75        super(context, attrs);
76        init(context);
77    }
78
79    public MessageListItem(Context context, AttributeSet attrs, int defStyle) {
80        super(context, attrs, defStyle);
81        init(context);
82    }
83
84    // Wide mode shows sender, snippet, time, and favorite spread out across the screen
85    private static final int MODE_WIDE = MessageListItemCoordinates.WIDE_MODE;
86    // Sentinel indicating that the view needs layout
87    public static final int NEEDS_LAYOUT = -1;
88
89    private static boolean sInit = false;
90    private static final TextPaint sDefaultPaint = new TextPaint();
91    private static final TextPaint sBoldPaint = new TextPaint();
92    private static final TextPaint sDatePaint = new TextPaint();
93    private static final TextPaint sHighlightPaint = new TextPaint();
94    private static Bitmap sAttachmentIcon;
95    private static Bitmap sInviteIcon;
96    private static int sBadgeMargin;
97    private static Bitmap sFavoriteIconOff;
98    private static Bitmap sFavoriteIconOn;
99    private static Bitmap sSelectedIconOn;
100    private static Bitmap sSelectedIconOff;
101    private static Bitmap sStateReplied;
102    private static Bitmap sStateForwarded;
103    private static Bitmap sStateRepliedAndForwarded;
104    private static String sSubjectSnippetDivider;
105    private static String sSubjectDescription;
106    private static String sSubjectEmptyDescription;
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 int mSnippetLineCount = NEEDS_LAYOUT;
137    private CharSequence mFormattedSender;
138    private CharSequence mFormattedDate;
139
140    private void init(Context context) {
141        mContext = context;
142        if (!sInit) {
143            Resources r = context.getResources();
144            sSubjectDescription = r.getString(R.string.message_subject_description).concat(", ");
145            sSubjectEmptyDescription = r.getString(R.string.message_is_empty_description);
146            sSubjectSnippetDivider = r.getString(R.string.message_list_subject_snippet_divider);
147            sItemHeightWide =
148                r.getDimensionPixelSize(R.dimen.message_list_item_height_wide);
149            sItemHeightNormal =
150                r.getDimensionPixelSize(R.dimen.message_list_item_height_normal);
151
152            sDefaultPaint.setTypeface(Typeface.DEFAULT);
153            sDefaultPaint.setAntiAlias(true);
154            sDatePaint.setTypeface(Typeface.DEFAULT);
155            sDatePaint.setAntiAlias(true);
156            sBoldPaint.setTypeface(Typeface.DEFAULT_BOLD);
157            sBoldPaint.setAntiAlias(true);
158            sHighlightPaint.setColor(TextUtilities.HIGHLIGHT_COLOR_INT);
159            sAttachmentIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_attachment);
160            sInviteIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_invite_holo_light);
161            sBadgeMargin = r.getDimensionPixelSize(R.dimen.message_list_badge_margin);
162            sFavoriteIconOff =
163                BitmapFactory.decodeResource(r, R.drawable.btn_star_off_normal_email_holo_light);
164            sFavoriteIconOn =
165                BitmapFactory.decodeResource(r, R.drawable.btn_star_on_normal_email_holo_light);
166            sSelectedIconOff =
167                BitmapFactory.decodeResource(r, R.drawable.btn_check_off_normal_holo_light);
168            sSelectedIconOn =
169                BitmapFactory.decodeResource(r, R.drawable.btn_check_on_normal_holo_light);
170
171            sStateReplied =
172                BitmapFactory.decodeResource(r, R.drawable.ic_badge_reply_holo_light);
173            sStateForwarded =
174                BitmapFactory.decodeResource(r, R.drawable.ic_badge_forward_holo_light);
175            sStateRepliedAndForwarded =
176                BitmapFactory.decodeResource(r, R.drawable.ic_badge_reply_forward_holo_light);
177
178            sInit = true;
179        }
180    }
181
182    /**
183     * Sets message subject and snippet safely, ensuring the cache is invalidated.
184     */
185    public void setText(String subject, String snippet, boolean forceUpdate) {
186        boolean changed = false;
187        if (!Objects.equal(mSubject, subject)) {
188            mSubject = subject;
189            changed = true;
190            populateContentDescription();
191        }
192
193        if (!Objects.equal(mSnippet, snippet)) {
194            mSnippet = snippet;
195            mSnippetLineCount = NEEDS_LAYOUT;
196            changed = true;
197        }
198
199        if (forceUpdate || changed || (mSubject == null && mSnippet == null) /* first time */) {
200            SpannableStringBuilder ssb = new SpannableStringBuilder();
201            boolean hasSubject = false;
202            if (!TextUtils.isEmpty(mSubject)) {
203                SpannableString ss = new SpannableString(mSubject);
204                ss.setSpan(new StyleSpan(mRead ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(),
205                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
206                ssb.append(ss);
207                hasSubject = true;
208            }
209            if (!TextUtils.isEmpty(mSnippet)) {
210                if (hasSubject) {
211                    ssb.append(sSubjectSnippetDivider);
212                }
213                ssb.append(mSnippet);
214            }
215            mText = ssb;
216        }
217    }
218
219    public void setTimestamp(long timestamp) {
220        mFormattedDate = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString();
221    }
222
223    /**
224     * Determine the mode of this view (WIDE or NORMAL)
225     *
226     * @param width The width of the view
227     * @return The mode of the view
228     */
229    private int getViewMode(int width) {
230        return MessageListItemCoordinates.getMode(mContext, width);
231    }
232
233    private Drawable mCurentBackground = null; // Only used by updateBackground()
234
235    private void updateBackground() {
236        final Drawable newBackground;
237        if (mRead) {
238            if (mMode == MODE_WIDE) {
239                if (mWideReadSelector == null) {
240                    mWideReadSelector = getContext().getResources()
241                            .getDrawable(R.drawable.message_list_wide_read_selector);
242                }
243                newBackground = mWideReadSelector;
244            } else {
245                if (mReadSelector == null) {
246                    mReadSelector = getContext().getResources()
247                            .getDrawable(R.drawable.message_list_read_selector);
248                }
249                newBackground = mReadSelector;
250            }
251        } else {
252            if (mMode == MODE_WIDE) {
253                if (mWideUnreadSelector == null) {
254                    mWideUnreadSelector = getContext().getResources()
255                            .getDrawable(R.drawable.message_list_wide_unread_selector);
256                }
257                newBackground = mWideUnreadSelector;
258            } else {
259                if (mUnreadSelector == null) {
260                    mUnreadSelector = getContext().getResources()
261                            .getDrawable(R.drawable.message_list_unread_selector);
262                }
263                newBackground = mUnreadSelector;
264            }
265        }
266        if (newBackground != mCurentBackground) {
267            // setBackgroundDrawable is a heavy operation.  Only call it when really needed.
268            setBackgroundDrawable(newBackground);
269            mCurentBackground = newBackground;
270        }
271    }
272
273    private void calculateDrawingData() {
274        sDefaultPaint.setTextSize(mCoordinates.subjectFontSize);
275        sDefaultPaint.setColor(mContext.getResources().getColor(
276                isActivated() ? android.R.color.white : android.R.color.black));
277        mSubjectLayout = new StaticLayout(mText, sDefaultPaint,
278                mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, false /* includePad */);
279        if (mCoordinates.subjectLineCount < mSubjectLayout.getLineCount()) {
280            // TODO: ellipsize.
281            int end = mSubjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1);
282            mSubjectLayout = new StaticLayout(mText.subSequence(0, end),
283                    sDefaultPaint, mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
284        }
285
286        // Now, format the sender for its width
287        TextPaint senderPaint = mRead ? sDefaultPaint : sBoldPaint;
288        // And get the ellipsized string for the calculated width
289        if (TextUtils.isEmpty(mSender)) {
290            mFormattedSender = "";
291        } else {
292            int senderWidth = mCoordinates.sendersWidth;
293            senderPaint.setTextSize(mCoordinates.sendersFontSize);
294            mFormattedSender = TextUtils.ellipsize(mSender, senderPaint, senderWidth,
295                    TruncateAt.END);
296        }
297    }
298
299    @Override
300    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
301        if (widthMeasureSpec != 0 || mViewWidth == 0) {
302            mViewWidth = MeasureSpec.getSize(widthMeasureSpec);
303            int mode = getViewMode(mViewWidth);
304            if (mode != mMode) {
305                // If the mode has changed, set the snippet line count to indicate layout required
306                mMode = mode;
307                mSnippetLineCount = NEEDS_LAYOUT;
308            }
309            mViewHeight = measureHeight(heightMeasureSpec, mMode);
310        }
311        setMeasuredDimension(mViewWidth, mViewHeight);
312    }
313
314    /**
315     * Determine the height of this view
316     *
317     * @param measureSpec A measureSpec packed into an int
318     * @param mode The current mode of this view
319     * @return The height of the view, honoring constraints from measureSpec
320     */
321    private int measureHeight(int measureSpec, int mode) {
322        int result = 0;
323        int specMode = MeasureSpec.getMode(measureSpec);
324        int specSize = MeasureSpec.getSize(measureSpec);
325
326        if (specMode == MeasureSpec.EXACTLY) {
327            // We were told how big to be
328            result = specSize;
329        } else {
330            // Measure the text
331            if (mMode == MODE_WIDE) {
332                result = sItemHeightWide;
333            } else {
334                result = sItemHeightNormal;
335            }
336            if (specMode == MeasureSpec.AT_MOST) {
337                // Respect AT_MOST value if that was what is called for by
338                // measureSpec
339                result = Math.min(result, specSize);
340            }
341        }
342        return result;
343    }
344
345    @Override
346    public void draw(Canvas canvas) {
347        // Update the background, before View.draw() draws it.
348        setSelected(mAdapter.isSelected(this));
349        updateBackground();
350        super.draw(canvas);
351    }
352
353    @Override
354    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
355        super.onLayout(changed, left, top, right, bottom);
356
357        mCoordinates = MessageListItemCoordinates.forWidth(mContext, mViewWidth);
358    }
359
360    @Override
361    protected void onDraw(Canvas canvas) {
362        if (mSnippetLineCount == NEEDS_LAYOUT) {
363            calculateDrawingData();
364        }
365
366        // Draw the color chip indicating the mailbox this belongs to
367        if (mColorChipPaint != null) {
368            canvas.drawRect(
369                    mCoordinates.chipX, mCoordinates.chipY,
370                    mCoordinates.chipX + mCoordinates.chipWidth,
371                    mCoordinates.chipY + mCoordinates.chipHeight,
372                    mColorChipPaint);
373        }
374
375        int fontColor = mContext.getResources().getColor(
376                (isActivated() || isPressed()) ? android.R.color.white : android.R.color.black);
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        canvas.drawText(mFormattedSender, 0, mFormattedSender.length(),
386                mCoordinates.sendersX, mCoordinates.sendersY - mCoordinates.sendersAscent,
387                senderPaint);
388
389        // Draw the reply state. Draw nothing if neither replied nor forwarded.
390        if (mHasBeenRepliedTo && mHasBeenForwarded) {
391            canvas.drawBitmap(sStateRepliedAndForwarded,
392                    mCoordinates.stateX, mCoordinates.stateY, null);
393        } else if (mHasBeenRepliedTo) {
394            canvas.drawBitmap(sStateReplied,
395                    mCoordinates.stateX, mCoordinates.stateY, null);
396        } else if (mHasBeenForwarded) {
397            canvas.drawBitmap(sStateForwarded,
398                    mCoordinates.stateX, mCoordinates.stateY, null);
399        }
400
401        // Subject and snippet.
402        sDefaultPaint.setTextSize(mCoordinates.subjectFontSize);
403        sDefaultPaint.setTypeface(Typeface.DEFAULT);
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