MessageListItem.java revision 56593af65ad25a69f81fb471d346c7519b58fbc4
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 Bitmap sFavoriteIconOff;
97    private static Bitmap sFavoriteIconOn;
98    private static Bitmap sSelectedIconOn;
99    private static Bitmap sSelectedIconOff;
100    private static Bitmap sStateReplied;
101    private static Bitmap sStateForwarded;
102    private static Bitmap sStateRepliedAndForwarded;
103    private static String sSubjectSnippetDivider;
104    private static String sSubjectDescription;
105    private static String sSubjectEmptyDescription;
106
107    public String mSender;
108    public CharSequence mText;
109    public CharSequence mSnippet;
110    private String mSubject;
111    private StaticLayout mSubjectLayout;
112    public boolean mRead;
113    public boolean mHasAttachment = false;
114    public boolean mHasInvite = true;
115    public boolean mIsFavorite = false;
116    public boolean mHasBeenRepliedTo = false;
117    public boolean mHasBeenForwarded = false;
118    /** {@link Paint} for account color chips.  null if no chips should be drawn.  */
119    public Paint mColorChipPaint;
120
121    private int mMode = -1;
122
123    private int mViewWidth = 0;
124    private int mViewHeight = 0;
125
126    private static int sItemHeightWide;
127    private static int sItemHeightNormal;
128
129    // Note: these cannot be shared Drawables because they are selectors which have state.
130    private Drawable mReadSelector;
131    private Drawable mUnreadSelector;
132    private Drawable mWideReadSelector;
133    private Drawable mWideUnreadSelector;
134
135    private int mSnippetLineCount = NEEDS_LAYOUT;
136    private CharSequence mFormattedSender;
137    private CharSequence mFormattedDate;
138
139    private void init(Context context) {
140        mContext = context;
141        if (!sInit) {
142            Resources r = context.getResources();
143            sSubjectDescription = r.getString(R.string.message_subject_description).concat(", ");
144            sSubjectEmptyDescription = r.getString(R.string.message_is_empty_description);
145            sSubjectSnippetDivider = r.getString(R.string.message_list_subject_snippet_divider);
146            sItemHeightWide =
147                r.getDimensionPixelSize(R.dimen.message_list_item_height_wide);
148            sItemHeightNormal =
149                r.getDimensionPixelSize(R.dimen.message_list_item_height_normal);
150
151            sDefaultPaint.setTypeface(Typeface.DEFAULT);
152            sDefaultPaint.setAntiAlias(true);
153            sDatePaint.setTypeface(Typeface.DEFAULT);
154            sDatePaint.setAntiAlias(true);
155            sBoldPaint.setTypeface(Typeface.DEFAULT_BOLD);
156            sBoldPaint.setAntiAlias(true);
157            sHighlightPaint.setColor(TextUtilities.HIGHLIGHT_COLOR_INT);
158            sAttachmentIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_attachment);
159            sInviteIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_invite);
160            sFavoriteIconOff =
161                BitmapFactory.decodeResource(r, R.drawable.btn_star_off_normal_holo_light);
162            sFavoriteIconOn =
163                BitmapFactory.decodeResource(r, R.drawable.btn_star_on_normal_holo_light);
164            sSelectedIconOff =
165                BitmapFactory.decodeResource(r, R.drawable.btn_check_off_normal_holo_light);
166            sSelectedIconOn =
167                BitmapFactory.decodeResource(r, R.drawable.btn_check_on_normal_holo_light);
168
169            //TODO: put the actual drawables when they exist. these are temps for visibile testing.
170            sStateReplied =
171                BitmapFactory.decodeResource(r, R.drawable.ic_reply_holo_dark);
172            sStateForwarded =
173                BitmapFactory.decodeResource(r, R.drawable.ic_forward_holo_dark);
174            sStateRepliedAndForwarded =
175                BitmapFactory.decodeResource(r, R.drawable.ic_reply_all_holo_dark);
176
177            sInit = true;
178        }
179    }
180
181    /**
182     * Sets message subject and snippet safely, ensuring the cache is invalidated.
183     */
184    public void setText(String subject, String snippet) {
185        boolean changed = false;
186        if (!Objects.equal(mSubject, subject)) {
187            mSubject = subject;
188            changed = true;
189            populateContentDescription();
190        }
191
192        if (!Objects.equal(mSnippet, snippet)) {
193            mSnippet = snippet;
194            mSnippetLineCount = NEEDS_LAYOUT;
195            changed = true;
196        }
197
198        if (changed || (mSubject == null && mSnippet == null) /* first time */) {
199            SpannableStringBuilder ssb = new SpannableStringBuilder();
200            boolean hasSubject = false;
201            if (!TextUtils.isEmpty(mSubject)) {
202                SpannableString ss = new SpannableString(mSubject);
203                ss.setSpan(new StyleSpan(mRead ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(),
204                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
205                ssb.append(ss);
206                hasSubject = true;
207            }
208            if (!TextUtils.isEmpty(mSnippet)) {
209                if (hasSubject) {
210                    ssb.append(sSubjectSnippetDivider);
211                }
212                ssb.append(mSnippet);
213            }
214            mText = ssb;
215        }
216    }
217
218    public void setTimestamp(long timestamp) {
219        mFormattedDate = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString();
220    }
221
222    /**
223     * Determine the mode of this view (WIDE or NORMAL)
224     *
225     * @param width The width of the view
226     * @return The mode of the view
227     */
228    private int getViewMode(int width) {
229        return MessageListItemCoordinates.getMode(mContext, width);
230    }
231
232    private Drawable mCurentBackground = null; // Only used by updateBackground()
233
234    private void updateBackground() {
235        final Drawable newBackground;
236        if (mRead) {
237            if (mMode == MODE_WIDE) {
238                if (mWideReadSelector == null) {
239                    mWideReadSelector = getContext().getResources()
240                            .getDrawable(R.drawable.message_list_wide_read_selector);
241                }
242                newBackground = mWideReadSelector;
243            } else {
244                if (mReadSelector == null) {
245                    mReadSelector = getContext().getResources()
246                            .getDrawable(R.drawable.message_list_read_selector);
247                }
248                newBackground = mReadSelector;
249            }
250        } else {
251            if (mMode == MODE_WIDE) {
252                if (mWideUnreadSelector == null) {
253                    mWideUnreadSelector = getContext().getResources()
254                            .getDrawable(R.drawable.message_list_wide_unread_selector);
255                }
256                newBackground = mWideUnreadSelector;
257            } else {
258                if (mUnreadSelector == null) {
259                    mUnreadSelector = getContext().getResources()
260                            .getDrawable(R.drawable.message_list_unread_selector);
261                }
262                newBackground = mUnreadSelector;
263            }
264        }
265        if (newBackground != mCurentBackground) {
266            // setBackgroundDrawable is a heavy operation.  Only call it when really needed.
267            setBackgroundDrawable(newBackground);
268            mCurentBackground = newBackground;
269        }
270    }
271
272    private void calculateDrawingData() {
273        sDefaultPaint.setTextSize(mCoordinates.subjectFontSize);
274        sDefaultPaint.setColor(mContext.getResources().getColor(
275                isActivated() ? android.R.color.white : android.R.color.black));
276        mSubjectLayout = new StaticLayout(mText, sDefaultPaint,
277                mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, false /* includePad */);
278        if (mCoordinates.subjectLineCount < mSubjectLayout.getLineCount()) {
279            // TODO: ellipsize.
280            int end = mSubjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1);
281            mSubjectLayout = new StaticLayout(mText.subSequence(0, end),
282                    sDefaultPaint, mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
283        }
284
285        // Now, format the sender for its width
286        TextPaint senderPaint = mRead ? sDefaultPaint : sBoldPaint;
287        // And get the ellipsized string for the calculated width
288        if (TextUtils.isEmpty(mSender)) {
289            mFormattedSender = "";
290        } else {
291            int senderWidth = mCoordinates.sendersWidth;
292            senderPaint.setTextSize(mCoordinates.sendersFontSize);
293            mFormattedSender = TextUtils.ellipsize(mSender, senderPaint, senderWidth,
294                    TruncateAt.END);
295        }
296    }
297
298    @Override
299    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
300        if (widthMeasureSpec != 0 || mViewWidth == 0) {
301            mViewWidth = MeasureSpec.getSize(widthMeasureSpec);
302            int mode = getViewMode(mViewWidth);
303            if (mode != mMode) {
304                // If the mode has changed, set the snippet line count to indicate layout required
305                mMode = mode;
306                mSnippetLineCount = NEEDS_LAYOUT;
307            }
308            mViewHeight = measureHeight(heightMeasureSpec, mMode);
309        }
310        setMeasuredDimension(mViewWidth, mViewHeight);
311    }
312
313    /**
314     * Determine the height of this view
315     *
316     * @param measureSpec A measureSpec packed into an int
317     * @param mode The current mode of this view
318     * @return The height of the view, honoring constraints from measureSpec
319     */
320    private int measureHeight(int measureSpec, int mode) {
321        int result = 0;
322        int specMode = MeasureSpec.getMode(measureSpec);
323        int specSize = MeasureSpec.getSize(measureSpec);
324
325        if (specMode == MeasureSpec.EXACTLY) {
326            // We were told how big to be
327            result = specSize;
328        } else {
329            // Measure the text
330            if (mMode == MODE_WIDE) {
331                result = sItemHeightWide;
332            } else {
333                result = sItemHeightNormal;
334            }
335            if (specMode == MeasureSpec.AT_MOST) {
336                // Respect AT_MOST value if that was what is called for by
337                // measureSpec
338                result = Math.min(result, specSize);
339            }
340        }
341        return result;
342    }
343
344    @Override
345    public void draw(Canvas canvas) {
346        // Update the background, before View.draw() draws it.
347        setSelected(mAdapter.isSelected(this));
348        updateBackground();
349        super.draw(canvas);
350    }
351
352    @Override
353    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
354        super.onLayout(changed, left, top, right, bottom);
355
356        mCoordinates = MessageListItemCoordinates.forWidth(mContext, mViewWidth);
357    }
358
359    @Override
360    protected void onDraw(Canvas canvas) {
361        if (mSnippetLineCount == NEEDS_LAYOUT) {
362            calculateDrawingData();
363        }
364
365        // Draw the color chip indicating the mailbox this belongs to
366        if (mColorChipPaint != null) {
367            canvas.drawRect(
368                    mCoordinates.chipX, mCoordinates.chipY,
369                    mCoordinates.chipX + mCoordinates.chipWidth,
370                    mCoordinates.chipY + mCoordinates.chipHeight,
371                    mColorChipPaint);
372        }
373
374        int fontColor = mContext.getResources().getColor(
375                (isActivated() || isPressed()) ? android.R.color.white : android.R.color.black);
376
377        // Draw the checkbox
378        canvas.drawBitmap(mAdapter.isSelected(this) ? sSelectedIconOn : sSelectedIconOff,
379                mCoordinates.checkmarkX, mCoordinates.checkmarkY, null);
380
381        // Draw the sender name
382        Paint senderPaint = mRead ? sDefaultPaint : sBoldPaint;
383        senderPaint.setColor(fontColor);
384        canvas.drawText(mFormattedSender, 0, mFormattedSender.length(),
385                mCoordinates.sendersX, mCoordinates.sendersY - mCoordinates.sendersAscent,
386                senderPaint);
387
388        // Draw the reply state. Draw nothing if neither replied nor forwarded.
389        if (mHasBeenRepliedTo && mHasBeenForwarded) {
390            canvas.drawBitmap(sStateRepliedAndForwarded,
391                    mCoordinates.stateX, mCoordinates.stateY, null);
392        } else if (mHasBeenRepliedTo) {
393            canvas.drawBitmap(sStateReplied,
394                    mCoordinates.stateX, mCoordinates.stateY, null);
395        } else if (mHasBeenForwarded) {
396            canvas.drawBitmap(sStateForwarded,
397                    mCoordinates.stateX, mCoordinates.stateY, null);
398        }
399
400        // Subject and snippet.
401        sDefaultPaint.setTextSize(mCoordinates.subjectFontSize);
402        sDefaultPaint.setTypeface(Typeface.DEFAULT);
403        sDefaultPaint.setColor(fontColor);
404        canvas.save();
405        canvas.translate(
406                mCoordinates.subjectX,
407                mCoordinates.subjectY);
408        mSubjectLayout.draw(canvas);
409        canvas.restore();
410
411        // Draw the date
412        sDatePaint.setTextSize(mCoordinates.dateFontSize);
413        sDatePaint.setColor(fontColor);
414        int dateX = mCoordinates.dateXEnd
415                - (int) sDatePaint.measureText(mFormattedDate, 0, mFormattedDate.length());
416
417        canvas.drawText(mFormattedDate, 0, mFormattedDate.length(),
418                dateX, mCoordinates.dateY - mCoordinates.dateAscent, sDatePaint);
419
420        // Draw the favorite icon
421        canvas.drawBitmap(mIsFavorite ? sFavoriteIconOn : sFavoriteIconOff,
422                mCoordinates.starX, mCoordinates.starY, null);
423
424        // TODO: deal with the icon layouts better from the coordinate class so that this logic
425        // doesn't have to exist.
426        // Draw the attachment and invite icons, if necessary.
427        int iconsLeft = dateX;
428        if (mHasAttachment) {
429            iconsLeft = iconsLeft - sAttachmentIcon.getWidth();
430            canvas.drawBitmap(sAttachmentIcon, iconsLeft, mCoordinates.paperclipY, null);
431        }
432        if (mHasInvite) {
433            iconsLeft -= sInviteIcon.getWidth();
434            canvas.drawBitmap(sInviteIcon, iconsLeft, mCoordinates.paperclipY, null);
435        }
436
437    }
438
439    /**
440     * Called by the adapter at bindView() time
441     *
442     * @param adapter the adapter that creates this view
443     */
444    public void bindViewInit(MessagesAdapter adapter) {
445        mAdapter = adapter;
446    }
447
448
449    private static final int TOUCH_SLOP = 16;
450    private static int sScaledTouchSlop = -1;
451
452    private void initializeSlop(Context context) {
453        if (sScaledTouchSlop == -1) {
454            final Resources res = context.getResources();
455            final Configuration config = res.getConfiguration();
456            final float density = res.getDisplayMetrics().density;
457            final float sizeAndDensity;
458            if (config.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_XLARGE)) {
459                sizeAndDensity = density * 1.5f;
460            } else {
461                sizeAndDensity = density;
462            }
463            sScaledTouchSlop = (int) (sizeAndDensity * TOUCH_SLOP + 0.5f);
464        }
465    }
466
467    /**
468     * Overriding this method allows us to "catch" clicks in the checkbox or star
469     * and process them accordingly.
470     */
471    @Override
472    public boolean onTouchEvent(MotionEvent event) {
473        initializeSlop(getContext());
474
475        boolean handled = false;
476        int touchX = (int) event.getX();
477        int checkRight = mCoordinates.checkmarkX
478                + mCoordinates.checkmarkWidthIncludingMargins + sScaledTouchSlop;
479        int starLeft = mCoordinates.starX;
480
481        switch (event.getAction()) {
482            case MotionEvent.ACTION_DOWN:
483                if (touchX < checkRight || touchX > starLeft) {
484                    mDownEvent = true;
485                    if ((touchX < checkRight) || (touchX > starLeft)) {
486                        handled = true;
487                    }
488                }
489                break;
490
491            case MotionEvent.ACTION_CANCEL:
492                mDownEvent = false;
493                break;
494
495            case MotionEvent.ACTION_UP:
496                if (mDownEvent) {
497                    if (touchX < checkRight) {
498                        mAdapter.toggleSelected(this);
499                        handled = true;
500                    } else if (touchX > starLeft) {
501                        mIsFavorite = !mIsFavorite;
502                        mAdapter.updateFavorite(this, mIsFavorite);
503                        handled = true;
504                    }
505                }
506                break;
507        }
508
509        if (handled) {
510            invalidate();
511        } else {
512            handled = super.onTouchEvent(event);
513        }
514
515        return handled;
516    }
517
518    @Override
519    public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
520        event.setClassName(getClass().getName());
521        event.setPackageName(getContext().getPackageName());
522        event.setEnabled(true);
523        event.setContentDescription(getContentDescription());
524        return true;
525    }
526
527    /**
528     * Sets the content description for this item, used for accessibility.
529     */
530    private void populateContentDescription() {
531        if (!TextUtils.isEmpty(mSubject)) {
532            setContentDescription(sSubjectDescription + mSubject);
533        } else {
534            setContentDescription(sSubjectEmptyDescription);
535        }
536    }
537}
538