MessageListItem.java revision a7f49a7c0b4a67b4dc7d306a646a5b1e15a808c8
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 com.android.email.R;
20
21import android.content.Context;
22import android.content.res.Resources;
23import android.graphics.Bitmap;
24import android.graphics.BitmapFactory;
25import android.graphics.Canvas;
26import android.graphics.Paint;
27import android.graphics.Paint.Align;
28import android.graphics.Paint.FontMetricsInt;
29import android.graphics.Typeface;
30import android.graphics.drawable.Drawable;
31import android.text.Layout.Alignment;
32import android.text.Spannable;
33import android.text.SpannableString;
34import android.text.SpannableStringBuilder;
35import android.text.StaticLayout;
36import android.text.TextPaint;
37import android.text.TextUtils;
38import android.text.TextUtils.TruncateAt;
39import android.text.format.DateUtils;
40import android.text.style.StyleSpan;
41import android.util.AttributeSet;
42import android.view.MotionEvent;
43import android.view.View;
44
45/**
46 * This custom View is the list item for the MessageList activity, and serves two purposes:
47 * 1.  It's a container to store message metadata (e.g. the ids of the message, mailbox, & account)
48 * 2.  It handles internal clicks such as the checkbox or the favorite star
49 */
50public class MessageListItem extends View {
51    // Note: messagesAdapter directly fiddles with these fields.
52    /* package */ long mMessageId;
53    /* package */ long mMailboxId;
54    /* package */ long mAccountId;
55
56    private MessagesAdapter mAdapter;
57
58    private boolean mDownEvent;
59
60    public static final String MESSAGE_LIST_ITEMS_CLIP_LABEL =
61        "com.android.email.MESSAGE_LIST_ITEMS";
62
63    public MessageListItem(Context context) {
64        super(context);
65        init(context);
66    }
67
68    public MessageListItem(Context context, AttributeSet attrs) {
69        super(context, attrs);
70        init(context);
71    }
72
73    public MessageListItem(Context context, AttributeSet attrs, int defStyle) {
74        super(context, attrs, defStyle);
75        init(context);
76    }
77
78    // We always show two lines of subject/snippet
79    private static final int MAX_SUBJECT_SNIPPET_LINES = 2;
80    // Narrow mode shows sender/snippet and time/favorite stacked to save real estate; due to this,
81    // it is also somewhat taller
82    private static final int MODE_NARROW = 1;
83    // Wide mode shows sender, snippet, time, and favorite spread out across the screen
84    private static final int MODE_WIDE = 2;
85    // Sentinel indicating that the view needs layout
86    public static final int NEEDS_LAYOUT = -1;
87
88    private static boolean sInit = false;
89    private static final TextPaint sDefaultPaint = new TextPaint();
90    private static final TextPaint sBoldPaint = new TextPaint();
91    private static final TextPaint sDatePaint = new TextPaint();
92    private static Bitmap sAttachmentIcon;
93    private static Bitmap sInviteIcon;
94    private static Bitmap sFavoriteIconOff;
95    private static Bitmap sFavoriteIconOn;
96    private static int sFavoriteIconWidth;
97    private static Bitmap sSelectedIconOn;
98    private static Bitmap sSelectedIconOff;
99    private static String sSubjectSnippetDivider;
100
101    public String mSender;
102    public CharSequence mText;
103    public String mSnippet;
104    public String mSubject;
105    public boolean mRead;
106    public long mTimestamp;
107    public boolean mHasAttachment = false;
108    public boolean mHasInvite = true;
109    public boolean mIsFavorite = false;
110    /** {@link Paint} for account color chips.  null if no chips should be drawn.  */
111    public Paint mColorChipPaint;
112
113    private int mMode = -1;
114
115    private int mViewWidth = 0;
116    private int mViewHeight = 0;
117    private int mSenderSnippetWidth;
118    private int mSnippetWidth;
119    private int mDateFaveWidth;
120
121    private static int sCheckboxHitWidth;
122    private static int sDateIconWidthWide;
123    private static int sDateIconWidthNarrow;
124    private static int sFavoriteHitWidth;
125    private static int sFavoritePaddingRight;
126    private static int sBadgePaddingTop;
127    private static int sBadgePaddingRight;
128    private static int sSenderPaddingTopNarrow;
129    private static int sSenderWidth;
130    private static int sPaddingLarge;
131    private static int sPaddingVerySmall;
132    private static int sPaddingSmall;
133    private static int sPaddingMedium;
134    private static int sTextSize;
135    private static int sItemHeightWide;
136    private static int sItemHeightNarrow;
137    private static int sMinimumWidthWideMode;
138    private static int sColorTipWidth;
139    private static int sColorTipHeight;
140    private static int sColorTipRightMarginOnNarrow;
141    private static int sColorTipRightMarginOnWide;
142
143    // Note: these cannot be shared Drawables because they are selectors which have state.
144    private Drawable mReadSelector;
145    private Drawable mUnreadSelector;
146    private Drawable mWideReadSelector;
147    private Drawable mWideUnreadSelector;
148
149    public int mSnippetLineCount = NEEDS_LAYOUT;
150    private final CharSequence[] mSnippetLines = new CharSequence[MAX_SUBJECT_SNIPPET_LINES];
151    private CharSequence mFormattedSender;
152    private CharSequence mFormattedDate;
153
154    private void init(Context context) {
155        if (!sInit) {
156            Resources r = context.getResources();
157            sSubjectSnippetDivider = r.getString(R.string.message_list_subject_snippet_divider);
158            sCheckboxHitWidth =
159                r.getDimensionPixelSize(R.dimen.message_list_item_checkbox_hit_width);
160            sFavoriteHitWidth =
161                r.getDimensionPixelSize(R.dimen.message_list_item_favorite_hit_width);
162            sFavoritePaddingRight =
163                r.getDimensionPixelSize(R.dimen.message_list_item_favorite_padding_right);
164            sBadgePaddingTop =
165                r.getDimensionPixelSize(R.dimen.message_list_item_badge_padding_top);
166            sBadgePaddingRight =
167                r.getDimensionPixelSize(R.dimen.message_list_item_badge_padding_right);
168            sSenderPaddingTopNarrow =
169                r.getDimensionPixelSize(R.dimen.message_list_item_sender_padding_top_narrow);
170            sDateIconWidthWide =
171                r.getDimensionPixelSize(R.dimen.message_list_item_date_icon_width_wide);
172            sDateIconWidthNarrow =
173                r.getDimensionPixelSize(R.dimen.message_list_item_date_icon_width_narrow);
174            sSenderWidth =
175                r.getDimensionPixelSize(R.dimen.message_list_item_sender_width);
176            sPaddingLarge =
177                r.getDimensionPixelSize(R.dimen.message_list_item_padding_large);
178            sPaddingMedium =
179                r.getDimensionPixelSize(R.dimen.message_list_item_padding_medium);
180            sPaddingSmall =
181                r.getDimensionPixelSize(R.dimen.message_list_item_padding_small);
182            sPaddingVerySmall =
183                r.getDimensionPixelSize(R.dimen.message_list_item_padding_very_small);
184            sTextSize =
185                r.getDimensionPixelSize(R.dimen.message_list_item_text_size);
186            sItemHeightWide =
187                r.getDimensionPixelSize(R.dimen.message_list_item_height_wide);
188            sItemHeightNarrow =
189                r.getDimensionPixelSize(R.dimen.message_list_item_height_narrow);
190            sMinimumWidthWideMode =
191                r.getDimensionPixelSize(R.dimen.message_list_item_minimum_width_wide_mode);
192            sColorTipWidth =
193                r.getDimensionPixelSize(R.dimen.message_list_item_color_tip_width);
194            sColorTipHeight =
195                r.getDimensionPixelSize(R.dimen.message_list_item_color_tip_height);
196            sColorTipRightMarginOnNarrow =
197                r.getDimensionPixelSize(R.dimen.message_list_item_color_tip_right_margin_on_narrow);
198            sColorTipRightMarginOnWide =
199                r.getDimensionPixelSize(R.dimen.message_list_item_color_tip_right_margin_on_wide);
200
201            sDefaultPaint.setTypeface(Typeface.DEFAULT);
202            sDefaultPaint.setTextSize(sTextSize);
203            sDefaultPaint.setAntiAlias(true);
204            sDatePaint.setTypeface(Typeface.DEFAULT);
205            sDatePaint.setTextSize(sTextSize - 1);
206            sDatePaint.setAntiAlias(true);
207            sDatePaint.setTextAlign(Align.RIGHT);
208            sBoldPaint.setTypeface(Typeface.DEFAULT_BOLD);
209            sBoldPaint.setTextSize(sTextSize);
210            sBoldPaint.setAntiAlias(true);
211            sAttachmentIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_attachment);
212            sInviteIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_invite);
213            sFavoriteIconOff =
214                BitmapFactory.decodeResource(r, R.drawable.btn_star_off_normal_email_holo_light);
215            sFavoriteIconOn =
216                BitmapFactory.decodeResource(r, R.drawable.btn_star_on_normal_email_holo_light);
217            sSelectedIconOff =
218                BitmapFactory.decodeResource(r, R.drawable.btn_check_off_normal_holo_light);
219            sSelectedIconOn =
220                BitmapFactory.decodeResource(r, R.drawable.btn_check_on_normal_holo_light);
221
222            sFavoriteIconWidth = sFavoriteIconOff.getWidth();
223            sInit = true;
224        }
225    }
226
227    /**
228     * Determine the mode of this view (WIDE or NORMAL)
229     *
230     * @param width The width of the view
231     * @return The mode of the view
232     */
233    private int getViewMode(int width) {
234        int mode = MODE_NARROW;
235        if (width > sMinimumWidthWideMode) {
236            mode = MODE_WIDE;
237        }
238        return mode;
239    }
240
241    private Drawable mCurentBackground = null; // Only used by updateBackground()
242
243    /* package */ 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        SpannableStringBuilder ssb = new SpannableStringBuilder();
283        boolean hasSubject = false;
284        if (!TextUtils.isEmpty(mSubject)) {
285            SpannableString ss = new SpannableString(mSubject);
286            ss.setSpan(new StyleSpan(mRead ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(),
287                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
288            ssb.append(ss);
289            hasSubject = true;
290        }
291        if (!TextUtils.isEmpty(mSnippet)) {
292            if (hasSubject) {
293                ssb.append(sSubjectSnippetDivider);
294            }
295            ssb.append(mSnippet);
296        }
297        mText = ssb;
298
299        if (mMode == MODE_WIDE) {
300            mDateFaveWidth = sFavoriteHitWidth + sDateIconWidthWide;
301        } else {
302            mDateFaveWidth = sDateIconWidthNarrow;
303        }
304        mSenderSnippetWidth = mViewWidth - mDateFaveWidth - sCheckboxHitWidth;
305
306        // In wide mode, we use 3/4 for snippet and 1/4 for sender
307        mSnippetWidth = mSenderSnippetWidth;
308        if (mMode == MODE_WIDE) {
309            mSnippetWidth = mSenderSnippetWidth - sSenderWidth - sPaddingLarge;
310        }
311
312        // Create a StaticLayout with our snippet to get the line breaks
313        StaticLayout layout = new StaticLayout(mText, 0, mText.length(), sDefaultPaint,
314                mSnippetWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
315        // Get the number of lines needed to render the whole snippet
316        mSnippetLineCount = layout.getLineCount();
317        // Go through our maximum number of lines, and save away what we'll end up displaying
318        // for those lines
319        for (int i = 0; i < MAX_SUBJECT_SNIPPET_LINES; i++) {
320            int start = layout.getLineStart(i);
321            if (i == MAX_SUBJECT_SNIPPET_LINES - 1) {
322                int end = mText.length();
323                if (start > end) continue;
324                // For the final line, ellipsize the text to our width
325                mSnippetLines[i] = TextUtils.ellipsize(mText.subSequence(start, end), sDefaultPaint,
326                        mSnippetWidth, TruncateAt.END);
327            } else {
328                // Just extract from start to end
329                mSnippetLines[i] = mText.subSequence(start, layout.getLineEnd(i));
330            }
331        }
332
333        // Now, format the sender for its width
334        TextPaint senderPaint = mRead ? sDefaultPaint : sBoldPaint;
335        int senderWidth = (mMode == MODE_WIDE) ? sSenderWidth : mSenderSnippetWidth;
336        // And get the ellipsized string for the calculated width
337        if (TextUtils.isEmpty(mSender)) {
338            mFormattedSender = "";
339        } else {
340            mFormattedSender = TextUtils.ellipsize(mSender, senderPaint, senderWidth,
341                    TruncateAt.END);
342        }
343        // Get a nicely formatted date string (relative to today)
344        String date = DateUtils.getRelativeTimeSpanString(getContext(), mTimestamp).toString();
345        // And make it fit to our size
346        mFormattedDate = TextUtils.ellipsize(date, sDatePaint, sDateIconWidthWide, TruncateAt.END);
347    }
348
349    @Override
350    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
351        if (widthMeasureSpec != 0 || mViewWidth == 0) {
352            mViewWidth = MeasureSpec.getSize(widthMeasureSpec);
353            int mode = getViewMode(mViewWidth);
354            if (mode != mMode) {
355                // If the mode has changed, set the snippet line count to indicate layout required
356                mMode = mode;
357                mSnippetLineCount = NEEDS_LAYOUT;
358            }
359            mViewHeight = measureHeight(heightMeasureSpec, mMode);
360        }
361        setMeasuredDimension(mViewWidth, mViewHeight);
362    }
363
364    /**
365     * Determine the height of this view
366     *
367     * @param measureSpec A measureSpec packed into an int
368     * @param mode The current mode of this view
369     * @return The height of the view, honoring constraints from measureSpec
370     */
371    private int measureHeight(int measureSpec, int mode) {
372        int result = 0;
373        int specMode = MeasureSpec.getMode(measureSpec);
374        int specSize = MeasureSpec.getSize(measureSpec);
375
376        if (specMode == MeasureSpec.EXACTLY) {
377            // We were told how big to be
378            result = specSize;
379        } else {
380            // Measure the text
381            if (mMode == MODE_WIDE) {
382                result = sItemHeightWide;
383            } else {
384                result = sItemHeightNarrow;
385            }
386            if (specMode == MeasureSpec.AT_MOST) {
387                // Respect AT_MOST value if that was what is called for by
388                // measureSpec
389                result = Math.min(result, specSize);
390            }
391        }
392        return result;
393    }
394
395    @Override
396    public void draw(Canvas canvas) {
397        // Update the background, before View.draw() draws it.
398        updateBackground();
399        super.draw(canvas);
400    }
401
402    @Override
403    protected void onDraw(Canvas canvas) {
404        if (mSnippetLineCount == NEEDS_LAYOUT) {
405            calculateDrawingData();
406        }
407        // Snippet starts at right of checkbox
408        int snippetX = sCheckboxHitWidth;
409        int snippetY;
410        int lineHeight = (int)sDefaultPaint.getFontSpacing() + sPaddingVerySmall;
411        FontMetricsInt fontMetrics = sDefaultPaint.getFontMetricsInt();
412        int ascent = fontMetrics.ascent;
413        int descent = fontMetrics.descent;
414        int senderY;
415
416        if (mMode == MODE_WIDE) {
417            // Get the right starting point for the snippet
418            snippetX += sSenderWidth + sPaddingLarge;
419            // And center the sender and snippet
420            senderY = (mViewHeight - descent - ascent) / 2;
421            snippetY = ((mViewHeight - (2 * lineHeight)) / 2) - ascent;
422        } else {
423            senderY = -ascent + sSenderPaddingTopNarrow;
424            snippetY = senderY + lineHeight + sPaddingVerySmall;
425        }
426
427        // Draw the color chip
428        if (mColorChipPaint != null) {
429            final int rightMargin = (mMode == MODE_WIDE)
430                    ? sColorTipRightMarginOnWide : sColorTipRightMarginOnNarrow;
431            final int x = mViewWidth - rightMargin - sColorTipWidth;
432            canvas.drawRect(x, 0, x + sColorTipWidth, sColorTipHeight, mColorChipPaint);
433        }
434
435        // Draw the checkbox
436        int checkboxLeft = (sCheckboxHitWidth - sSelectedIconOff.getWidth()) / 2;
437        int checkboxTop = (mViewHeight - sSelectedIconOff.getHeight()) / 2;
438        canvas.drawBitmap(mAdapter.isSelected(this) ? sSelectedIconOn : sSelectedIconOff,
439                checkboxLeft, checkboxTop, sDefaultPaint);
440
441        // Draw the sender name
442        canvas.drawText(mFormattedSender, 0, mFormattedSender.length(), sCheckboxHitWidth, senderY,
443                mRead ? sDefaultPaint : sBoldPaint);
444
445        // Draw each of the snippet lines
446        int subjectEnd = (mSubject == null) ? 0 : mSubject.length();
447        int lineStart = 0;
448        TextPaint subjectPaint = mRead ? sDefaultPaint : sBoldPaint;
449        for (int i = 0; i < MAX_SUBJECT_SNIPPET_LINES && i < mSnippetLineCount; i++) {
450            CharSequence line = mSnippetLines[i];
451            int drawX = snippetX;
452            if (line != null) {
453                int defaultPaintStart = 0;
454                if (lineStart <= subjectEnd) {
455                    int boldPaintEnd = subjectEnd - lineStart;
456                    if (boldPaintEnd > line.length()) {
457                        boldPaintEnd = line.length();
458                    }
459                    // From 0 to end, do in bold or default depending on the read flag
460                    canvas.drawText(line, 0, boldPaintEnd, drawX, snippetY, subjectPaint);
461                    defaultPaintStart = boldPaintEnd;
462                    drawX += subjectPaint.measureText(line, 0, boldPaintEnd);
463                }
464                canvas.drawText(line, defaultPaintStart, line.length(), drawX, snippetY,
465                        sDefaultPaint);
466                snippetY += lineHeight;
467                lineStart += line.length();
468            }
469        }
470
471        // Draw the attachment and invite icons, if necessary
472        int datePaddingRight;
473        if (mMode == MODE_WIDE) {
474            datePaddingRight = sFavoriteHitWidth;
475        } else {
476            datePaddingRight = sPaddingLarge;
477        }
478        int left = mViewWidth - datePaddingRight - (int)sDefaultPaint.measureText(mFormattedDate,
479                0, mFormattedDate.length()) - sPaddingMedium;
480
481        int iconTop;
482        if (mHasAttachment) {
483            left -= sAttachmentIcon.getWidth();
484            if (mMode == MODE_WIDE) {
485                iconTop = (mViewHeight - sAttachmentIcon.getHeight()) / 2;
486                left -= sPaddingSmall;
487            } else {
488                iconTop = senderY - sAttachmentIcon.getHeight() + sBadgePaddingTop;
489                left -= sBadgePaddingRight;
490            }
491            canvas.drawBitmap(sAttachmentIcon, left, iconTop, sDefaultPaint);
492        }
493        if (mHasInvite) {
494            left -= sInviteIcon.getWidth();
495            if (mMode == MODE_WIDE) {
496                iconTop = (mViewHeight - sInviteIcon.getHeight()) / 2;
497                left -= sPaddingSmall;
498            } else {
499                iconTop = senderY - sInviteIcon.getHeight() + sBadgePaddingTop;
500                left -= sBadgePaddingRight;
501            }
502            canvas.drawBitmap(sInviteIcon, left, iconTop, sDefaultPaint);
503        }
504
505        // Draw the date
506        canvas.drawText(mFormattedDate, 0, mFormattedDate.length(), mViewWidth - datePaddingRight,
507                senderY, sDatePaint);
508
509        // Draw the favorite icon
510        int faveLeft = mViewWidth - sFavoriteIconWidth;
511        if (mMode == MODE_WIDE) {
512            faveLeft -= sFavoritePaddingRight;
513        } else {
514            faveLeft -= sPaddingLarge;
515        }
516        int faveTop = (mViewHeight - sFavoriteIconOff.getHeight()) / 2;
517        if (mMode == MODE_NARROW) {
518            faveTop += sSenderPaddingTopNarrow;
519        }
520        canvas.drawBitmap(mIsFavorite ? sFavoriteIconOn : sFavoriteIconOff, faveLeft, faveTop,
521                sDefaultPaint);
522    }
523
524    /**
525     * Called by the adapter at bindView() time
526     *
527     * @param adapter the adapter that creates this view
528     */
529    public void bindViewInit(MessagesAdapter adapter) {
530        mAdapter = adapter;
531    }
532
533    /**
534     * Overriding this method allows us to "catch" clicks in the checkbox or star
535     * and process them accordingly.
536     */
537    @Override
538    public boolean onTouchEvent(MotionEvent event) {
539        boolean handled = false;
540        int touchX = (int) event.getX();
541        int checkRight = sCheckboxHitWidth;
542        int starLeft = mViewWidth - sFavoriteHitWidth;
543
544        switch (event.getAction()) {
545            case MotionEvent.ACTION_DOWN:
546                if (touchX < checkRight || touchX > starLeft) {
547                    mDownEvent = true;
548                    if ((touchX < checkRight) || (touchX > starLeft)) {
549                        handled = true;
550                    }
551                }
552                break;
553
554            case MotionEvent.ACTION_CANCEL:
555                mDownEvent = false;
556                break;
557
558            case MotionEvent.ACTION_UP:
559                if (mDownEvent) {
560                    if (touchX < checkRight) {
561                        mAdapter.toggleSelected(this);
562                        handled = true;
563                    } else if (touchX > starLeft) {
564                        mIsFavorite = !mIsFavorite;
565                        mAdapter.updateFavorite(this, mIsFavorite);
566                        handled = true;
567                    }
568                }
569                break;
570        }
571
572        if (handled) {
573            invalidate();
574        } else {
575            handled = super.onTouchEvent(event);
576        }
577
578        return handled;
579    }
580}
581