SimpleMonthView.java revision da7cc2d2ae2cf8742d12773852d8de6cd16201d7
1/*
2 * Copyright (C) 2014 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 android.widget;
18
19import android.annotation.Nullable;
20import android.content.Context;
21import android.content.res.ColorStateList;
22import android.content.res.Resources;
23import android.content.res.TypedArray;
24import android.graphics.Canvas;
25import android.graphics.Paint;
26import android.graphics.Paint.Align;
27import android.graphics.Paint.Style;
28import android.graphics.Rect;
29import android.graphics.Typeface;
30import android.icu.text.SimpleDateFormat;
31import android.os.Bundle;
32import android.text.TextPaint;
33import android.text.format.DateFormat;
34import android.util.AttributeSet;
35import android.util.IntArray;
36import android.util.Log;
37import android.util.MathUtils;
38import android.util.StateSet;
39import android.view.KeyEvent;
40import android.view.MotionEvent;
41import android.view.View;
42import android.view.ViewParent;
43import android.view.accessibility.AccessibilityEvent;
44import android.view.accessibility.AccessibilityNodeInfo;
45import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
46
47import com.android.internal.R;
48import com.android.internal.widget.ExploreByTouchHelper;
49
50import java.text.NumberFormat;
51import java.util.Arrays;
52import java.util.Calendar;
53import java.util.Locale;
54
55/**
56 * A calendar-like view displaying a specified month and the appropriate selectable day numbers
57 * within the specified month.
58 */
59class SimpleMonthView extends View {
60    private static final String LOG_TAG = "SimpleMonthView";
61
62    private static final int DAYS_IN_WEEK = 7;
63    private static final int MAX_WEEKS_IN_MONTH = 6;
64
65    private static final int DEFAULT_SELECTED_DAY = -1;
66    private static final int DEFAULT_WEEK_START = Calendar.SUNDAY;
67
68    private static final String MONTH_YEAR_FORMAT = "MMMMy";
69    private static final String DAY_OF_WEEK_FORMAT = "EEEEE";
70
71    private static final int SELECTED_HIGHLIGHT_ALPHA = 0xB0;
72
73    /** Temporary until we figure out why the date gets messed up. */
74    private static final boolean DEBUG_WRONG_DATE = true;
75
76    private final TextPaint mMonthPaint = new TextPaint();
77    private final TextPaint mDayOfWeekPaint = new TextPaint();
78    private final TextPaint mDayPaint = new TextPaint();
79    private final Paint mDaySelectorPaint = new Paint();
80    private final Paint mDayHighlightPaint = new Paint();
81    private final Paint mDayHighlightSelectorPaint = new Paint();
82
83    private final String[] mDayOfWeekLabels = new String[7];
84
85    private final Calendar mCalendar;
86    private final Locale mLocale;
87
88    private final MonthViewTouchHelper mTouchHelper;
89
90    private final NumberFormat mDayFormatter;
91
92    // Desired dimensions.
93    private final int mDesiredMonthHeight;
94    private final int mDesiredDayOfWeekHeight;
95    private final int mDesiredDayHeight;
96    private final int mDesiredCellWidth;
97    private final int mDesiredDaySelectorRadius;
98
99    private String mMonthYearLabel;
100
101    private int mMonth;
102    private int mYear;
103
104    // Dimensions as laid out.
105    private int mMonthHeight;
106    private int mDayOfWeekHeight;
107    private int mDayHeight;
108    private int mCellWidth;
109    private int mDaySelectorRadius;
110
111    private int mPaddedWidth;
112    private int mPaddedHeight;
113
114    /** The day of month for the selected day, or -1 if no day is selected. */
115    private int mActivatedDay = -1;
116
117    /**
118     * The day of month for today, or -1 if the today is not in the current
119     * month.
120     */
121    private int mToday = DEFAULT_SELECTED_DAY;
122
123    /** The first day of the week (ex. Calendar.SUNDAY). */
124    private int mWeekStart = DEFAULT_WEEK_START;
125
126    /** The number of days (ex. 28) in the current month. */
127    private int mDaysInMonth;
128
129    /**
130     * The day of week (ex. Calendar.SUNDAY) for the first day of the current
131     * month.
132     */
133    private int mDayOfWeekStart;
134
135    /** The day of month for the first (inclusive) enabled day. */
136    private int mEnabledDayStart = 1;
137
138    /** The day of month for the last (inclusive) enabled day. */
139    private int mEnabledDayEnd = 31;
140
141    /** Optional listener for handling day click actions. */
142    private OnDayClickListener mOnDayClickListener;
143
144    private ColorStateList mDayTextColor;
145
146    private int mHighlightedDay = -1;
147    private int mPreviouslyHighlightedDay = -1;
148    private boolean mIsTouchHighlighted = false;
149
150    public SimpleMonthView(Context context) {
151        this(context, null);
152    }
153
154    public SimpleMonthView(Context context, AttributeSet attrs) {
155        this(context, attrs, R.attr.datePickerStyle);
156    }
157
158    public SimpleMonthView(Context context, AttributeSet attrs, int defStyleAttr) {
159        this(context, attrs, defStyleAttr, 0);
160    }
161
162    public SimpleMonthView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
163        super(context, attrs, defStyleAttr, defStyleRes);
164
165        final Resources res = context.getResources();
166        mDesiredMonthHeight = res.getDimensionPixelSize(R.dimen.date_picker_month_height);
167        mDesiredDayOfWeekHeight = res.getDimensionPixelSize(R.dimen.date_picker_day_of_week_height);
168        mDesiredDayHeight = res.getDimensionPixelSize(R.dimen.date_picker_day_height);
169        mDesiredCellWidth = res.getDimensionPixelSize(R.dimen.date_picker_day_width);
170        mDesiredDaySelectorRadius = res.getDimensionPixelSize(
171                R.dimen.date_picker_day_selector_radius);
172
173        // Set up accessibility components.
174        mTouchHelper = new MonthViewTouchHelper(this);
175        setAccessibilityDelegate(mTouchHelper);
176        setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
177
178        mLocale = res.getConfiguration().locale;
179        mCalendar = Calendar.getInstance(mLocale);
180
181        mDayFormatter = NumberFormat.getIntegerInstance(mLocale);
182
183        updateMonthYearLabel();
184        updateDayOfWeekLabels();
185
186        initPaints(res);
187    }
188
189    private void updateMonthYearLabel() {
190        final String format = DateFormat.getBestDateTimePattern(mLocale, MONTH_YEAR_FORMAT);
191        final SimpleDateFormat formatter = new SimpleDateFormat(format, mLocale);
192        mMonthYearLabel = formatter.format(mCalendar.getTime());
193    }
194
195    private void updateDayOfWeekLabels() {
196        if (DEBUG_WRONG_DATE) {
197            Log.d(LOG_TAG, "enter updateDayOfWeekLabels()", new Exception());
198            Log.d(LOG_TAG, "mLocale => " + mLocale);
199            Log.d(LOG_TAG, "mWeekStart => " + mWeekStart);
200        }
201
202        final Calendar calendar = Calendar.getInstance(mLocale);
203        calendar.setFirstDayOfWeek(mWeekStart);
204
205        final SimpleDateFormat formatter = new SimpleDateFormat(DAY_OF_WEEK_FORMAT, mLocale);
206        for (int i = 0; i < 7; i++) {
207            calendar.set(Calendar.DAY_OF_WEEK, i);
208            mDayOfWeekLabels[i] = formatter.format(calendar.getTime());
209        }
210
211        if (DEBUG_WRONG_DATE) {
212            Log.d(LOG_TAG, "mDayOfWeekLabels <= " + Arrays.toString(mDayOfWeekLabels));
213        }
214    }
215
216    /**
217     * Applies the specified text appearance resource to a paint, returning the
218     * text color if one is set in the text appearance.
219     *
220     * @param p the paint to modify
221     * @param resId the resource ID of the text appearance
222     * @return the text color, if available
223     */
224    private ColorStateList applyTextAppearance(Paint p, int resId) {
225        final TypedArray ta = mContext.obtainStyledAttributes(null,
226                R.styleable.TextAppearance, 0, resId);
227
228        final String fontFamily = ta.getString(R.styleable.TextAppearance_fontFamily);
229        if (fontFamily != null) {
230            p.setTypeface(Typeface.create(fontFamily, 0));
231        }
232
233        p.setTextSize(ta.getDimensionPixelSize(
234                R.styleable.TextAppearance_textSize, (int) p.getTextSize()));
235
236        final ColorStateList textColor = ta.getColorStateList(R.styleable.TextAppearance_textColor);
237        if (textColor != null) {
238            final int enabledColor = textColor.getColorForState(ENABLED_STATE_SET, 0);
239            p.setColor(enabledColor);
240        }
241
242        ta.recycle();
243
244        return textColor;
245    }
246
247    public int getMonthHeight() {
248        return mMonthHeight;
249    }
250
251    public int getCellWidth() {
252        return mCellWidth;
253    }
254
255    public void setMonthTextAppearance(int resId) {
256        applyTextAppearance(mMonthPaint, resId);
257
258        invalidate();
259    }
260
261    public void setDayOfWeekTextAppearance(int resId) {
262        applyTextAppearance(mDayOfWeekPaint, resId);
263        invalidate();
264    }
265
266    public void setDayTextAppearance(int resId) {
267        final ColorStateList textColor = applyTextAppearance(mDayPaint, resId);
268        if (textColor != null) {
269            mDayTextColor = textColor;
270        }
271
272        invalidate();
273    }
274
275    /**
276     * Sets up the text and style properties for painting.
277     */
278    private void initPaints(Resources res) {
279        final String monthTypeface = res.getString(R.string.date_picker_month_typeface);
280        final String dayOfWeekTypeface = res.getString(R.string.date_picker_day_of_week_typeface);
281        final String dayTypeface = res.getString(R.string.date_picker_day_typeface);
282
283        final int monthTextSize = res.getDimensionPixelSize(
284                R.dimen.date_picker_month_text_size);
285        final int dayOfWeekTextSize = res.getDimensionPixelSize(
286                R.dimen.date_picker_day_of_week_text_size);
287        final int dayTextSize = res.getDimensionPixelSize(
288                R.dimen.date_picker_day_text_size);
289
290        mMonthPaint.setAntiAlias(true);
291        mMonthPaint.setTextSize(monthTextSize);
292        mMonthPaint.setTypeface(Typeface.create(monthTypeface, 0));
293        mMonthPaint.setTextAlign(Align.CENTER);
294        mMonthPaint.setStyle(Style.FILL);
295
296        mDayOfWeekPaint.setAntiAlias(true);
297        mDayOfWeekPaint.setTextSize(dayOfWeekTextSize);
298        mDayOfWeekPaint.setTypeface(Typeface.create(dayOfWeekTypeface, 0));
299        mDayOfWeekPaint.setTextAlign(Align.CENTER);
300        mDayOfWeekPaint.setStyle(Style.FILL);
301
302        mDaySelectorPaint.setAntiAlias(true);
303        mDaySelectorPaint.setStyle(Style.FILL);
304
305        mDayHighlightPaint.setAntiAlias(true);
306        mDayHighlightPaint.setStyle(Style.FILL);
307
308        mDayHighlightSelectorPaint.setAntiAlias(true);
309        mDayHighlightSelectorPaint.setStyle(Style.FILL);
310
311        mDayPaint.setAntiAlias(true);
312        mDayPaint.setTextSize(dayTextSize);
313        mDayPaint.setTypeface(Typeface.create(dayTypeface, 0));
314        mDayPaint.setTextAlign(Align.CENTER);
315        mDayPaint.setStyle(Style.FILL);
316    }
317
318    void setMonthTextColor(ColorStateList monthTextColor) {
319        final int enabledColor = monthTextColor.getColorForState(ENABLED_STATE_SET, 0);
320        mMonthPaint.setColor(enabledColor);
321        invalidate();
322    }
323
324    void setDayOfWeekTextColor(ColorStateList dayOfWeekTextColor) {
325        final int enabledColor = dayOfWeekTextColor.getColorForState(ENABLED_STATE_SET, 0);
326        mDayOfWeekPaint.setColor(enabledColor);
327        invalidate();
328    }
329
330    void setDayTextColor(ColorStateList dayTextColor) {
331        mDayTextColor = dayTextColor;
332        invalidate();
333    }
334
335    void setDaySelectorColor(ColorStateList dayBackgroundColor) {
336        final int activatedColor = dayBackgroundColor.getColorForState(
337                StateSet.get(StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED), 0);
338        mDaySelectorPaint.setColor(activatedColor);
339        mDayHighlightSelectorPaint.setColor(activatedColor);
340        mDayHighlightSelectorPaint.setAlpha(SELECTED_HIGHLIGHT_ALPHA);
341        invalidate();
342    }
343
344    void setDayHighlightColor(ColorStateList dayHighlightColor) {
345        final int pressedColor = dayHighlightColor.getColorForState(
346                StateSet.get(StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_PRESSED), 0);
347        mDayHighlightPaint.setColor(pressedColor);
348        invalidate();
349    }
350
351    public void setOnDayClickListener(OnDayClickListener listener) {
352        mOnDayClickListener = listener;
353    }
354
355    @Override
356    public boolean dispatchHoverEvent(MotionEvent event) {
357        // First right-of-refusal goes the touch exploration helper.
358        return mTouchHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event);
359    }
360
361    @Override
362    public boolean onTouchEvent(MotionEvent event) {
363        final int x = (int) (event.getX() + 0.5f);
364        final int y = (int) (event.getY() + 0.5f);
365
366        final int action = event.getAction();
367        switch (action) {
368            case MotionEvent.ACTION_DOWN:
369            case MotionEvent.ACTION_MOVE:
370                final int touchedItem = getDayAtLocation(x, y);
371                mIsTouchHighlighted = true;
372                if (mHighlightedDay != touchedItem) {
373                    mHighlightedDay = touchedItem;
374                    mPreviouslyHighlightedDay = touchedItem;
375                    invalidate();
376                }
377                if (action == MotionEvent.ACTION_DOWN && touchedItem < 0) {
378                    // Touch something that's not an item, reject event.
379                    return false;
380                }
381                break;
382
383            case MotionEvent.ACTION_UP:
384                final int clickedDay = getDayAtLocation(x, y);
385                onDayClicked(clickedDay);
386                // Fall through.
387            case MotionEvent.ACTION_CANCEL:
388                // Reset touched day on stream end.
389                mHighlightedDay = -1;
390                mIsTouchHighlighted = false;
391                invalidate();
392                break;
393        }
394        return true;
395    }
396
397    @Override
398    public boolean onKeyDown(int keyCode, KeyEvent event) {
399        // We need to handle focus change within the SimpleMonthView because we are simulating
400        // multiple Views. The arrow keys will move between days until there is no space (no
401        // day to the left, top, right, or bottom). Focus forward and back jumps out of the
402        // SimpleMonthView, skipping over other SimpleMonthViews in the parent ViewPager
403        // to the next focusable View in the hierarchy.
404        boolean focusChanged = false;
405        switch (event.getKeyCode()) {
406            case KeyEvent.KEYCODE_DPAD_LEFT:
407                if (event.hasNoModifiers()) {
408                    focusChanged = moveOneDay(isLayoutRtl());
409                }
410                break;
411            case KeyEvent.KEYCODE_DPAD_RIGHT:
412                if (event.hasNoModifiers()) {
413                    focusChanged = moveOneDay(!isLayoutRtl());
414                }
415                break;
416            case KeyEvent.KEYCODE_DPAD_UP:
417                if (event.hasNoModifiers()) {
418                    ensureFocusedDay();
419                    if (mHighlightedDay > 7) {
420                        mHighlightedDay -= 7;
421                        focusChanged = true;
422                    }
423                }
424                break;
425            case KeyEvent.KEYCODE_DPAD_DOWN:
426                if (event.hasNoModifiers()) {
427                    ensureFocusedDay();
428                    if (mHighlightedDay <= mDaysInMonth - 7) {
429                        mHighlightedDay += 7;
430                        focusChanged = true;
431                    }
432                }
433                break;
434            case KeyEvent.KEYCODE_DPAD_CENTER:
435            case KeyEvent.KEYCODE_ENTER:
436                if (mHighlightedDay != -1) {
437                    onDayClicked(mHighlightedDay);
438                    return true;
439                }
440                break;
441            case KeyEvent.KEYCODE_TAB: {
442                int focusChangeDirection = 0;
443                if (event.hasNoModifiers()) {
444                    focusChangeDirection = View.FOCUS_FORWARD;
445                } else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {
446                    focusChangeDirection = View.FOCUS_BACKWARD;
447                }
448                if (focusChangeDirection != 0) {
449                    final ViewParent parent = getParent();
450                    // move out of the ViewPager next/previous
451                    View nextFocus = this;
452                    do {
453                        nextFocus = nextFocus.focusSearch(focusChangeDirection);
454                    } while (nextFocus != null && nextFocus != this &&
455                            nextFocus.getParent() == parent);
456                    if (nextFocus != null) {
457                        nextFocus.requestFocus();
458                        return true;
459                    }
460                }
461                break;
462            }
463        }
464        if (focusChanged) {
465            invalidate();
466            return true;
467        } else {
468            return super.onKeyDown(keyCode, event);
469        }
470    }
471
472    private boolean moveOneDay(boolean positive) {
473        ensureFocusedDay();
474        boolean focusChanged = false;
475        if (positive) {
476            if (!isLastDayOfWeek(mHighlightedDay) && mHighlightedDay < mDaysInMonth) {
477                mHighlightedDay++;
478                focusChanged = true;
479            }
480        } else {
481            if (!isFirstDayOfWeek(mHighlightedDay) && mHighlightedDay > 1) {
482                mHighlightedDay--;
483                focusChanged = true;
484            }
485        }
486        return focusChanged;
487    }
488
489    @Override
490    protected void onFocusChanged(boolean gainFocus, @FocusDirection int direction,
491            @Nullable Rect previouslyFocusedRect) {
492        if (gainFocus) {
493            // If we've gained focus through arrow keys, we should find the day closest
494            // to the focus rect. If we've gained focus through forward/back, we should
495            // focus on the selected day if there is one.
496            final int offset = findDayOffset();
497            switch(direction) {
498                case View.FOCUS_RIGHT: {
499                    int row = findClosestRow(previouslyFocusedRect);
500                    mHighlightedDay = row == 0 ? 1 : (row * DAYS_IN_WEEK) - offset + 1;
501                    break;
502                }
503                case View.FOCUS_LEFT: {
504                    int row = findClosestRow(previouslyFocusedRect) + 1;
505                    mHighlightedDay = Math.min(mDaysInMonth, (row * DAYS_IN_WEEK) - offset);
506                    break;
507                }
508                case View.FOCUS_DOWN: {
509                    final int col = findClosestColumn(previouslyFocusedRect);
510                    final int day = col - offset + 1;
511                    mHighlightedDay = day < 1 ? day + DAYS_IN_WEEK : day;
512                    break;
513                }
514                case View.FOCUS_UP: {
515                    final int col = findClosestColumn(previouslyFocusedRect);
516                    final int maxWeeks = (offset + mDaysInMonth) / DAYS_IN_WEEK;
517                    final int day = col - offset + (DAYS_IN_WEEK * maxWeeks) + 1;
518                    mHighlightedDay = day > mDaysInMonth ? day - DAYS_IN_WEEK : day;
519                    break;
520                }
521            }
522            ensureFocusedDay();
523            invalidate();
524        }
525        super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
526    }
527
528    /**
529     * Returns the row (0 indexed) closest to previouslyFocusedRect or center if null.
530     */
531    private int findClosestRow(@Nullable Rect previouslyFocusedRect) {
532        if (previouslyFocusedRect == null) {
533            return 3;
534        } else {
535            int centerY = previouslyFocusedRect.centerY();
536
537            final TextPaint p = mDayPaint;
538            final int headerHeight = mMonthHeight + mDayOfWeekHeight;
539            final int rowHeight = mDayHeight;
540
541            // Text is vertically centered within the row height.
542            final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
543            final int rowCenter = headerHeight + rowHeight / 2;
544
545            centerY -= rowCenter - halfLineHeight;
546            int row = Math.round(centerY / (float) rowHeight);
547            final int maxDay = findDayOffset() + mDaysInMonth;
548            final int maxRows = (maxDay / DAYS_IN_WEEK) - ((maxDay % DAYS_IN_WEEK == 0) ? 1 : 0);
549
550            row = MathUtils.constrain(row, 0, maxRows);
551            return row;
552        }
553    }
554
555    /**
556     * Returns the column (0 indexed) closest to the previouslyFocusedRect or center if null.
557     * The 0 index is related to the first day of the week.
558     */
559    private int findClosestColumn(@Nullable Rect previouslyFocusedRect) {
560        if (previouslyFocusedRect == null) {
561            return DAYS_IN_WEEK / 2;
562        } else {
563            int centerX = previouslyFocusedRect.centerX() - mPaddingLeft;
564            final int columnFromLeft =
565                    MathUtils.constrain(centerX / mCellWidth, 0, DAYS_IN_WEEK - 1);
566            return isLayoutRtl() ? DAYS_IN_WEEK - columnFromLeft - 1: columnFromLeft;
567        }
568    }
569
570    @Override
571    public void getFocusedRect(Rect r) {
572        if (mHighlightedDay > 0) {
573            getBoundsForDay(mHighlightedDay, r);
574        } else {
575            super.getFocusedRect(r);
576        }
577    }
578
579    @Override
580    protected void onFocusLost() {
581        if (!mIsTouchHighlighted) {
582            // Unhighlight a day.
583            mPreviouslyHighlightedDay = mHighlightedDay;
584            mHighlightedDay = -1;
585            invalidate();
586        }
587        super.onFocusLost();
588    }
589
590    /**
591     * Ensure some day is highlighted. If a day isn't highlighted, it chooses the selected day,
592     * if possible, or the first day of the month if not.
593     */
594    private void ensureFocusedDay() {
595        if (mHighlightedDay != -1) {
596            return;
597        }
598        if (mPreviouslyHighlightedDay != -1) {
599            mHighlightedDay = mPreviouslyHighlightedDay;
600            return;
601        }
602        if (mActivatedDay != -1) {
603            mHighlightedDay = mActivatedDay;
604            return;
605        }
606        mHighlightedDay = 1;
607    }
608
609    private boolean isFirstDayOfWeek(int day) {
610        final int offset = findDayOffset();
611        return (offset + day - 1) % DAYS_IN_WEEK == 0;
612    }
613
614    private boolean isLastDayOfWeek(int day) {
615        final int offset = findDayOffset();
616        return (offset + day) % DAYS_IN_WEEK == 0;
617    }
618
619    @Override
620    protected void onDraw(Canvas canvas) {
621        final int paddingLeft = getPaddingLeft();
622        final int paddingTop = getPaddingTop();
623        canvas.translate(paddingLeft, paddingTop);
624
625        drawMonth(canvas);
626        drawDaysOfWeek(canvas);
627        drawDays(canvas);
628
629        canvas.translate(-paddingLeft, -paddingTop);
630    }
631
632    private void drawMonth(Canvas canvas) {
633        final float x = mPaddedWidth / 2f;
634
635        // Vertically centered within the month header height.
636        final float lineHeight = mMonthPaint.ascent() + mMonthPaint.descent();
637        final float y = (mMonthHeight - lineHeight) / 2f;
638
639        canvas.drawText(mMonthYearLabel, x, y, mMonthPaint);
640    }
641
642    public String getMonthYearLabel() {
643        return mMonthYearLabel;
644    }
645
646    private void drawDaysOfWeek(Canvas canvas) {
647        final TextPaint p = mDayOfWeekPaint;
648        final int headerHeight = mMonthHeight;
649        final int rowHeight = mDayOfWeekHeight;
650        final int colWidth = mCellWidth;
651
652        // Text is vertically centered within the day of week height.
653        final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
654        final int rowCenter = headerHeight + rowHeight / 2;
655
656        for (int col = 0; col < DAYS_IN_WEEK; col++) {
657            final int colCenter = colWidth * col + colWidth / 2;
658            final int colCenterRtl;
659            if (isLayoutRtl()) {
660                colCenterRtl = mPaddedWidth - colCenter;
661            } else {
662                colCenterRtl = colCenter;
663            }
664
665            final int dayOfWeek = (col + mWeekStart) % DAYS_IN_WEEK;
666            final String label = mDayOfWeekLabels[dayOfWeek];
667            canvas.drawText(label, colCenterRtl, rowCenter - halfLineHeight, p);
668        }
669    }
670
671    /**
672     * Draws the month days.
673     */
674    private void drawDays(Canvas canvas) {
675        final TextPaint p = mDayPaint;
676        final int headerHeight = mMonthHeight + mDayOfWeekHeight;
677        final int rowHeight = mDayHeight;
678        final int colWidth = mCellWidth;
679
680        // Text is vertically centered within the row height.
681        final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
682        int rowCenter = headerHeight + rowHeight / 2;
683
684        for (int day = 1, col = findDayOffset(); day <= mDaysInMonth; day++) {
685            final int colCenter = colWidth * col + colWidth / 2;
686            final int colCenterRtl;
687            if (isLayoutRtl()) {
688                colCenterRtl = mPaddedWidth - colCenter;
689            } else {
690                colCenterRtl = colCenter;
691            }
692
693            int stateMask = 0;
694
695            final boolean isDayEnabled = isDayEnabled(day);
696            if (isDayEnabled) {
697                stateMask |= StateSet.VIEW_STATE_ENABLED;
698            }
699
700            final boolean isDayActivated = mActivatedDay == day;
701            final boolean isDayHighlighted = mHighlightedDay == day;
702            if (isDayActivated) {
703                stateMask |= StateSet.VIEW_STATE_ACTIVATED;
704
705                // Adjust the circle to be centered on the row.
706                final Paint paint = isDayHighlighted ? mDayHighlightSelectorPaint :
707                        mDaySelectorPaint;
708                canvas.drawCircle(colCenterRtl, rowCenter, mDaySelectorRadius, paint);
709            } else if (isDayHighlighted) {
710                stateMask |= StateSet.VIEW_STATE_PRESSED;
711
712                if (isDayEnabled) {
713                    // Adjust the circle to be centered on the row.
714                    canvas.drawCircle(colCenterRtl, rowCenter,
715                            mDaySelectorRadius, mDayHighlightPaint);
716                }
717            }
718
719            final boolean isDayToday = mToday == day;
720            final int dayTextColor;
721            if (isDayToday && !isDayActivated) {
722                dayTextColor = mDaySelectorPaint.getColor();
723            } else {
724                final int[] stateSet = StateSet.get(stateMask);
725                dayTextColor = mDayTextColor.getColorForState(stateSet, 0);
726            }
727            p.setColor(dayTextColor);
728
729            canvas.drawText(mDayFormatter.format(day), colCenterRtl, rowCenter - halfLineHeight, p);
730
731            col++;
732
733            if (col == DAYS_IN_WEEK) {
734                col = 0;
735                rowCenter += rowHeight;
736            }
737        }
738    }
739
740    private boolean isDayEnabled(int day) {
741        return day >= mEnabledDayStart && day <= mEnabledDayEnd;
742    }
743
744    private boolean isValidDayOfMonth(int day) {
745        return day >= 1 && day <= mDaysInMonth;
746    }
747
748    private static boolean isValidDayOfWeek(int day) {
749        return day >= Calendar.SUNDAY && day <= Calendar.SATURDAY;
750    }
751
752    private static boolean isValidMonth(int month) {
753        return month >= Calendar.JANUARY && month <= Calendar.DECEMBER;
754    }
755
756    /**
757     * Sets the selected day.
758     *
759     * @param dayOfMonth the selected day of the month, or {@code -1} to clear
760     *                   the selection
761     */
762    public void setSelectedDay(int dayOfMonth) {
763        mActivatedDay = dayOfMonth;
764
765        // Invalidate cached accessibility information.
766        mTouchHelper.invalidateRoot();
767        invalidate();
768    }
769
770    /**
771     * Sets the first day of the week.
772     *
773     * @param weekStart which day the week should start on, valid values are
774     *                  {@link Calendar#SUNDAY} through {@link Calendar#SATURDAY}
775     */
776    public void setFirstDayOfWeek(int weekStart) {
777        if (DEBUG_WRONG_DATE) {
778            Log.d(LOG_TAG, "enter setFirstDayOfWeek(" + weekStart + ")", new Exception());
779        }
780
781        if (isValidDayOfWeek(weekStart)) {
782            mWeekStart = weekStart;
783        } else {
784            mWeekStart = mCalendar.getFirstDayOfWeek();
785        }
786
787        if (DEBUG_WRONG_DATE) {
788            Log.d(LOG_TAG, "mWeekStart <=" + mWeekStart);
789        }
790
791        updateDayOfWeekLabels();
792
793        // Invalidate cached accessibility information.
794        mTouchHelper.invalidateRoot();
795        invalidate();
796    }
797
798    /**
799     * Sets all the parameters for displaying this week.
800     * <p>
801     * Parameters have a default value and will only update if a new value is
802     * included, except for focus month, which will always default to no focus
803     * month if no value is passed in. The only required parameter is the week
804     * start.
805     *
806     * @param selectedDay the selected day of the month, or -1 for no selection
807     * @param month the month
808     * @param year the year
809     * @param weekStart which day the week should start on, valid values are
810     *                  {@link Calendar#SUNDAY} through {@link Calendar#SATURDAY}
811     * @param enabledDayStart the first enabled day
812     * @param enabledDayEnd the last enabled day
813     */
814    void setMonthParams(int selectedDay, int month, int year, int weekStart, int enabledDayStart,
815            int enabledDayEnd) {
816        mActivatedDay = selectedDay;
817
818        if (isValidMonth(month)) {
819            mMonth = month;
820        }
821        mYear = year;
822
823        mCalendar.set(Calendar.MONTH, mMonth);
824        mCalendar.set(Calendar.YEAR, mYear);
825        mCalendar.set(Calendar.DAY_OF_MONTH, 1);
826        mDayOfWeekStart = mCalendar.get(Calendar.DAY_OF_WEEK);
827
828        if (isValidDayOfWeek(weekStart)) {
829            mWeekStart = weekStart;
830        } else {
831            mWeekStart = mCalendar.getFirstDayOfWeek();
832        }
833
834        // Figure out what day today is.
835        final Calendar today = Calendar.getInstance();
836        mToday = -1;
837        mDaysInMonth = getDaysInMonth(mMonth, mYear);
838        for (int i = 0; i < mDaysInMonth; i++) {
839            final int day = i + 1;
840            if (sameDay(day, today)) {
841                mToday = day;
842            }
843        }
844
845        mEnabledDayStart = MathUtils.constrain(enabledDayStart, 1, mDaysInMonth);
846        mEnabledDayEnd = MathUtils.constrain(enabledDayEnd, mEnabledDayStart, mDaysInMonth);
847
848        // Invalidate cached accessibility information.
849        mTouchHelper.invalidateRoot();
850
851        updateMonthYearLabel();
852    }
853
854    private static int getDaysInMonth(int month, int year) {
855        switch (month) {
856            case Calendar.JANUARY:
857            case Calendar.MARCH:
858            case Calendar.MAY:
859            case Calendar.JULY:
860            case Calendar.AUGUST:
861            case Calendar.OCTOBER:
862            case Calendar.DECEMBER:
863                return 31;
864            case Calendar.APRIL:
865            case Calendar.JUNE:
866            case Calendar.SEPTEMBER:
867            case Calendar.NOVEMBER:
868                return 30;
869            case Calendar.FEBRUARY:
870                return (year % 4 == 0) ? 29 : 28;
871            default:
872                throw new IllegalArgumentException("Invalid Month");
873        }
874    }
875
876    private boolean sameDay(int day, Calendar today) {
877        return mYear == today.get(Calendar.YEAR) && mMonth == today.get(Calendar.MONTH)
878                && day == today.get(Calendar.DAY_OF_MONTH);
879    }
880
881    @Override
882    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
883        final int preferredHeight = mDesiredDayHeight * MAX_WEEKS_IN_MONTH
884                + mDesiredDayOfWeekHeight + mDesiredMonthHeight
885                + getPaddingTop() + getPaddingBottom();
886        final int preferredWidth = mDesiredCellWidth * DAYS_IN_WEEK
887                + getPaddingStart() + getPaddingEnd();
888        final int resolvedWidth = resolveSize(preferredWidth, widthMeasureSpec);
889        final int resolvedHeight = resolveSize(preferredHeight, heightMeasureSpec);
890        setMeasuredDimension(resolvedWidth, resolvedHeight);
891    }
892
893    @Override
894    public void onRtlPropertiesChanged(@ResolvedLayoutDir int layoutDirection) {
895        super.onRtlPropertiesChanged(layoutDirection);
896
897        requestLayout();
898    }
899
900    @Override
901    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
902        if (!changed) {
903            return;
904        }
905
906        // Let's initialize a completely reasonable number of variables.
907        final int w = right - left;
908        final int h = bottom - top;
909        final int paddingLeft = getPaddingLeft();
910        final int paddingTop = getPaddingTop();
911        final int paddingRight = getPaddingRight();
912        final int paddingBottom = getPaddingBottom();
913        final int paddedRight = w - paddingRight;
914        final int paddedBottom = h - paddingBottom;
915        final int paddedWidth = paddedRight - paddingLeft;
916        final int paddedHeight = paddedBottom - paddingTop;
917        if (paddedWidth == mPaddedWidth || paddedHeight == mPaddedHeight) {
918            return;
919        }
920
921        mPaddedWidth = paddedWidth;
922        mPaddedHeight = paddedHeight;
923
924        // We may have been laid out smaller than our preferred size. If so,
925        // scale all dimensions to fit.
926        final int measuredPaddedHeight = getMeasuredHeight() - paddingTop - paddingBottom;
927        final float scaleH = paddedHeight / (float) measuredPaddedHeight;
928        final int monthHeight = (int) (mDesiredMonthHeight * scaleH);
929        final int cellWidth = mPaddedWidth / DAYS_IN_WEEK;
930        mMonthHeight = monthHeight;
931        mDayOfWeekHeight = (int) (mDesiredDayOfWeekHeight * scaleH);
932        mDayHeight = (int) (mDesiredDayHeight * scaleH);
933        mCellWidth = cellWidth;
934
935        // Compute the largest day selector radius that's still within the clip
936        // bounds and desired selector radius.
937        final int maxSelectorWidth = cellWidth / 2 + Math.min(paddingLeft, paddingRight);
938        final int maxSelectorHeight = mDayHeight / 2 + paddingBottom;
939        mDaySelectorRadius = Math.min(mDesiredDaySelectorRadius,
940                Math.min(maxSelectorWidth, maxSelectorHeight));
941
942        // Invalidate cached accessibility information.
943        mTouchHelper.invalidateRoot();
944    }
945
946    private int findDayOffset() {
947        final int offset = mDayOfWeekStart - mWeekStart;
948        if (mDayOfWeekStart < mWeekStart) {
949            return offset + DAYS_IN_WEEK;
950        }
951        return offset;
952    }
953
954    /**
955     * Calculates the day of the month at the specified touch position. Returns
956     * the day of the month or -1 if the position wasn't in a valid day.
957     *
958     * @param x the x position of the touch event
959     * @param y the y position of the touch event
960     * @return the day of the month at (x, y), or -1 if the position wasn't in
961     *         a valid day
962     */
963    private int getDayAtLocation(int x, int y) {
964        final int paddedX = x - getPaddingLeft();
965        if (paddedX < 0 || paddedX >= mPaddedWidth) {
966            return -1;
967        }
968
969        final int headerHeight = mMonthHeight + mDayOfWeekHeight;
970        final int paddedY = y - getPaddingTop();
971        if (paddedY < headerHeight || paddedY >= mPaddedHeight) {
972            return -1;
973        }
974
975        // Adjust for RTL after applying padding.
976        final int paddedXRtl;
977        if (isLayoutRtl()) {
978            paddedXRtl = mPaddedWidth - paddedX;
979        } else {
980            paddedXRtl = paddedX;
981        }
982
983        final int row = (paddedY - headerHeight) / mDayHeight;
984        final int col = (paddedXRtl * DAYS_IN_WEEK) / mPaddedWidth;
985        final int index = col + row * DAYS_IN_WEEK;
986        final int day = index + 1 - findDayOffset();
987        if (!isValidDayOfMonth(day)) {
988            return -1;
989        }
990
991        return day;
992    }
993
994    /**
995     * Calculates the bounds of the specified day.
996     *
997     * @param id the day of the month
998     * @param outBounds the rect to populate with bounds
999     */
1000    private boolean getBoundsForDay(int id, Rect outBounds) {
1001        if (!isValidDayOfMonth(id)) {
1002            return false;
1003        }
1004
1005        final int index = id - 1 + findDayOffset();
1006
1007        // Compute left edge, taking into account RTL.
1008        final int col = index % DAYS_IN_WEEK;
1009        final int colWidth = mCellWidth;
1010        final int left;
1011        if (isLayoutRtl()) {
1012            left = getWidth() - getPaddingRight() - (col + 1) * colWidth;
1013        } else {
1014            left = getPaddingLeft() + col * colWidth;
1015        }
1016
1017        // Compute top edge.
1018        final int row = index / DAYS_IN_WEEK;
1019        final int rowHeight = mDayHeight;
1020        final int headerHeight = mMonthHeight + mDayOfWeekHeight;
1021        final int top = getPaddingTop() + headerHeight + row * rowHeight;
1022
1023        outBounds.set(left, top, left + colWidth, top + rowHeight);
1024
1025        return true;
1026    }
1027
1028    /**
1029     * Called when the user clicks on a day. Handles callbacks to the
1030     * {@link OnDayClickListener} if one is set.
1031     *
1032     * @param day the day that was clicked
1033     */
1034    private boolean onDayClicked(int day) {
1035        if (!isValidDayOfMonth(day) || !isDayEnabled(day)) {
1036            return false;
1037        }
1038
1039        if (mOnDayClickListener != null) {
1040            final Calendar date = Calendar.getInstance();
1041            date.set(mYear, mMonth, day);
1042            mOnDayClickListener.onDayClick(this, date);
1043        }
1044
1045        // This is a no-op if accessibility is turned off.
1046        mTouchHelper.sendEventForVirtualView(day, AccessibilityEvent.TYPE_VIEW_CLICKED);
1047        return true;
1048    }
1049
1050    /**
1051     * Provides a virtual view hierarchy for interfacing with an accessibility
1052     * service.
1053     */
1054    private class MonthViewTouchHelper extends ExploreByTouchHelper {
1055        private static final String DATE_FORMAT = "dd MMMM yyyy";
1056
1057        private final Rect mTempRect = new Rect();
1058        private final Calendar mTempCalendar = Calendar.getInstance();
1059
1060        public MonthViewTouchHelper(View host) {
1061            super(host);
1062        }
1063
1064        @Override
1065        protected int getVirtualViewAt(float x, float y) {
1066            final int day = getDayAtLocation((int) (x + 0.5f), (int) (y + 0.5f));
1067            if (day != -1) {
1068                return day;
1069            }
1070            return ExploreByTouchHelper.INVALID_ID;
1071        }
1072
1073        @Override
1074        protected void getVisibleVirtualViews(IntArray virtualViewIds) {
1075            for (int day = 1; day <= mDaysInMonth; day++) {
1076                virtualViewIds.add(day);
1077            }
1078        }
1079
1080        @Override
1081        protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
1082            event.setContentDescription(getDayDescription(virtualViewId));
1083        }
1084
1085        @Override
1086        protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node) {
1087            final boolean hasBounds = getBoundsForDay(virtualViewId, mTempRect);
1088
1089            if (!hasBounds) {
1090                // The day is invalid, kill the node.
1091                mTempRect.setEmpty();
1092                node.setContentDescription("");
1093                node.setBoundsInParent(mTempRect);
1094                node.setVisibleToUser(false);
1095                return;
1096            }
1097
1098            node.setText(getDayText(virtualViewId));
1099            node.setContentDescription(getDayDescription(virtualViewId));
1100            node.setBoundsInParent(mTempRect);
1101
1102            final boolean isDayEnabled = isDayEnabled(virtualViewId);
1103            if (isDayEnabled) {
1104                node.addAction(AccessibilityAction.ACTION_CLICK);
1105            }
1106
1107            node.setEnabled(isDayEnabled);
1108
1109            if (virtualViewId == mActivatedDay) {
1110                // TODO: This should use activated once that's supported.
1111                node.setChecked(true);
1112            }
1113
1114        }
1115
1116        @Override
1117        protected boolean onPerformActionForVirtualView(int virtualViewId, int action,
1118                Bundle arguments) {
1119            switch (action) {
1120                case AccessibilityNodeInfo.ACTION_CLICK:
1121                    return onDayClicked(virtualViewId);
1122            }
1123
1124            return false;
1125        }
1126
1127        /**
1128         * Generates a description for a given virtual view.
1129         *
1130         * @param id the day to generate a description for
1131         * @return a description of the virtual view
1132         */
1133        private CharSequence getDayDescription(int id) {
1134            if (isValidDayOfMonth(id)) {
1135                mTempCalendar.set(mYear, mMonth, id);
1136                return DateFormat.format(DATE_FORMAT, mTempCalendar.getTimeInMillis());
1137            }
1138
1139            return "";
1140        }
1141
1142        /**
1143         * Generates displayed text for a given virtual view.
1144         *
1145         * @param id the day to generate text for
1146         * @return the visible text of the virtual view
1147         */
1148        private CharSequence getDayText(int id) {
1149            if (isValidDayOfMonth(id)) {
1150                return mDayFormatter.format(id);
1151            }
1152
1153            return null;
1154        }
1155    }
1156
1157    /**
1158     * Handles callbacks when the user clicks on a time object.
1159     */
1160    public interface OnDayClickListener {
1161        void onDayClick(SimpleMonthView view, Calendar day);
1162    }
1163}
1164