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