SimpleMonthView.java revision d5c85c824bfe343144af68ede9e86f12d5bba1f7
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 com.android.internal.R;
20import com.android.internal.widget.ExploreByTouchHelper;
21
22import android.annotation.Nullable;
23import android.content.Context;
24import android.content.res.ColorStateList;
25import android.content.res.Resources;
26import android.content.res.TypedArray;
27import android.graphics.Canvas;
28import android.graphics.Paint;
29import android.graphics.Paint.Align;
30import android.graphics.Paint.Style;
31import android.graphics.Rect;
32import android.graphics.Typeface;
33import android.icu.text.SimpleDateFormat;
34import android.os.Bundle;
35import android.text.TextPaint;
36import android.text.format.DateFormat;
37import android.util.AttributeSet;
38import android.util.IntArray;
39import android.util.Log;
40import android.util.MathUtils;
41import android.util.StateSet;
42import android.view.KeyEvent;
43import android.view.MotionEvent;
44import android.view.View;
45import android.view.ViewParent;
46import android.view.accessibility.AccessibilityEvent;
47import android.view.accessibility.AccessibilityNodeInfo;
48import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
49
50import java.text.NumberFormat;
51import java.util.Arrays;
52import java.util.Calendar;
53import java.util.Locale;
54
55import libcore.icu.LocaleData;
56
57/**
58 * A calendar-like view displaying a specified month and the appropriate selectable day numbers
59 * within the specified month.
60 */
61class SimpleMonthView extends View {
62    private static final String LOG_TAG = "SimpleMonthView";
63
64    private static final int DAYS_IN_WEEK = 7;
65    private static final int MAX_WEEKS_IN_MONTH = 6;
66
67    private static final int DEFAULT_SELECTED_DAY = -1;
68    private static final int DEFAULT_WEEK_START = Calendar.SUNDAY;
69
70    private static final String MONTH_YEAR_FORMAT = "MMMMy";
71
72    private static final int SELECTED_HIGHLIGHT_ALPHA = 0xB0;
73
74    /** Temporary until we figure out why the date gets messed up. */
75    private static final boolean DEBUG_WRONG_DATE = true;
76
77    private final TextPaint mMonthPaint = new TextPaint();
78    private final TextPaint mDayOfWeekPaint = new TextPaint();
79    private final TextPaint mDayPaint = new TextPaint();
80    private final Paint mDaySelectorPaint = new Paint();
81    private final Paint mDayHighlightPaint = new Paint();
82    private final Paint mDayHighlightSelectorPaint = new Paint();
83
84    /** Array of single-character weekday labels ordered by column index. */
85    private final String[] mDayOfWeekLabels = new String[7];
86
87    private final Calendar mCalendar;
88    private final Locale mLocale;
89
90    private final MonthViewTouchHelper mTouchHelper;
91
92    private final NumberFormat mDayFormatter;
93
94    // Desired dimensions.
95    private final int mDesiredMonthHeight;
96    private final int mDesiredDayOfWeekHeight;
97    private final int mDesiredDayHeight;
98    private final int mDesiredCellWidth;
99    private final int mDesiredDaySelectorRadius;
100
101    private String mMonthYearLabel;
102
103    private int mMonth;
104    private int mYear;
105
106    // Dimensions as laid out.
107    private int mMonthHeight;
108    private int mDayOfWeekHeight;
109    private int mDayHeight;
110    private int mCellWidth;
111    private int mDaySelectorRadius;
112
113    private int mPaddedWidth;
114    private int mPaddedHeight;
115
116    /** The day of month for the selected day, or -1 if no day is selected. */
117    private int mActivatedDay = -1;
118
119    /**
120     * The day of month for today, or -1 if the today is not in the current
121     * month.
122     */
123    private int mToday = DEFAULT_SELECTED_DAY;
124
125    /** The first day of the week (ex. Calendar.SUNDAY) indexed from one. */
126    private int mWeekStart = DEFAULT_WEEK_START;
127
128    /** The number of days (ex. 28) in the current month. */
129    private int mDaysInMonth;
130
131    /**
132     * The day of week (ex. Calendar.SUNDAY) for the first day of the current
133     * month.
134     */
135    private int mDayOfWeekStart;
136
137    /** The day of month for the first (inclusive) enabled day. */
138    private int mEnabledDayStart = 1;
139
140    /** The day of month for the last (inclusive) enabled day. */
141    private int mEnabledDayEnd = 31;
142
143    /** Optional listener for handling day click actions. */
144    private OnDayClickListener mOnDayClickListener;
145
146    private ColorStateList mDayTextColor;
147
148    private int mHighlightedDay = -1;
149    private int mPreviouslyHighlightedDay = -1;
150    private boolean mIsTouchHighlighted = false;
151
152    public SimpleMonthView(Context context) {
153        this(context, null);
154    }
155
156    public SimpleMonthView(Context context, AttributeSet attrs) {
157        this(context, attrs, R.attr.datePickerStyle);
158    }
159
160    public SimpleMonthView(Context context, AttributeSet attrs, int defStyleAttr) {
161        this(context, attrs, defStyleAttr, 0);
162    }
163
164    public SimpleMonthView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
165        super(context, attrs, defStyleAttr, defStyleRes);
166
167        final Resources res = context.getResources();
168        mDesiredMonthHeight = res.getDimensionPixelSize(R.dimen.date_picker_month_height);
169        mDesiredDayOfWeekHeight = res.getDimensionPixelSize(R.dimen.date_picker_day_of_week_height);
170        mDesiredDayHeight = res.getDimensionPixelSize(R.dimen.date_picker_day_height);
171        mDesiredCellWidth = res.getDimensionPixelSize(R.dimen.date_picker_day_width);
172        mDesiredDaySelectorRadius = res.getDimensionPixelSize(
173                R.dimen.date_picker_day_selector_radius);
174
175        // Set up accessibility components.
176        mTouchHelper = new MonthViewTouchHelper(this);
177        setAccessibilityDelegate(mTouchHelper);
178        setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
179
180        mLocale = res.getConfiguration().locale;
181        mCalendar = Calendar.getInstance(mLocale);
182
183        mDayFormatter = NumberFormat.getIntegerInstance(mLocale);
184
185        updateMonthYearLabel();
186        updateDayOfWeekLabels();
187
188        initPaints(res);
189    }
190
191    private void updateMonthYearLabel() {
192        final String format = DateFormat.getBestDateTimePattern(mLocale, MONTH_YEAR_FORMAT);
193        final SimpleDateFormat formatter = new SimpleDateFormat(format, mLocale);
194        mMonthYearLabel = formatter.format(mCalendar.getTime());
195    }
196
197    private void updateDayOfWeekLabels() {
198        if (DEBUG_WRONG_DATE) {
199            Log.d(LOG_TAG, "enter updateDayOfWeekLabels()", new Exception());
200            Log.d(LOG_TAG, "mLocale => " + mLocale);
201            Log.d(LOG_TAG, "mWeekStart => " + mWeekStart);
202        }
203
204        // Use tiny (e.g. single-character) weekday names from ICU. The indices
205        // for this list correspond to Calendar days, e.g. SUNDAY is index 1.
206        final String[] tinyWeekdayNames = LocaleData.get(mLocale).tinyWeekdayNames;
207        for (int i = 0; i < DAYS_IN_WEEK; i++) {
208            mDayOfWeekLabels[i] = tinyWeekdayNames[(mWeekStart + i - 1) % DAYS_IN_WEEK + 1];
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 String label = mDayOfWeekLabels[col];
666            canvas.drawText(label, colCenterRtl, rowCenter - halfLineHeight, p);
667        }
668    }
669
670    /**
671     * Draws the month days.
672     */
673    private void drawDays(Canvas canvas) {
674        final TextPaint p = mDayPaint;
675        final int headerHeight = mMonthHeight + mDayOfWeekHeight;
676        final int rowHeight = mDayHeight;
677        final int colWidth = mCellWidth;
678
679        // Text is vertically centered within the row height.
680        final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
681        int rowCenter = headerHeight + rowHeight / 2;
682
683        for (int day = 1, col = findDayOffset(); day <= mDaysInMonth; day++) {
684            final int colCenter = colWidth * col + colWidth / 2;
685            final int colCenterRtl;
686            if (isLayoutRtl()) {
687                colCenterRtl = mPaddedWidth - colCenter;
688            } else {
689                colCenterRtl = colCenter;
690            }
691
692            int stateMask = 0;
693
694            final boolean isDayEnabled = isDayEnabled(day);
695            if (isDayEnabled) {
696                stateMask |= StateSet.VIEW_STATE_ENABLED;
697            }
698
699            final boolean isDayActivated = mActivatedDay == day;
700            final boolean isDayHighlighted = mHighlightedDay == day;
701            if (isDayActivated) {
702                stateMask |= StateSet.VIEW_STATE_ACTIVATED;
703
704                // Adjust the circle to be centered on the row.
705                final Paint paint = isDayHighlighted ? mDayHighlightSelectorPaint :
706                        mDaySelectorPaint;
707                canvas.drawCircle(colCenterRtl, rowCenter, mDaySelectorRadius, paint);
708            } else if (isDayHighlighted) {
709                stateMask |= StateSet.VIEW_STATE_PRESSED;
710
711                if (isDayEnabled) {
712                    // Adjust the circle to be centered on the row.
713                    canvas.drawCircle(colCenterRtl, rowCenter,
714                            mDaySelectorRadius, mDayHighlightPaint);
715                }
716            }
717
718            final boolean isDayToday = mToday == day;
719            final int dayTextColor;
720            if (isDayToday && !isDayActivated) {
721                dayTextColor = mDaySelectorPaint.getColor();
722            } else {
723                final int[] stateSet = StateSet.get(stateMask);
724                dayTextColor = mDayTextColor.getColorForState(stateSet, 0);
725            }
726            p.setColor(dayTextColor);
727
728            canvas.drawText(mDayFormatter.format(day), colCenterRtl, rowCenter - halfLineHeight, p);
729
730            col++;
731
732            if (col == DAYS_IN_WEEK) {
733                col = 0;
734                rowCenter += rowHeight;
735            }
736        }
737    }
738
739    private boolean isDayEnabled(int day) {
740        return day >= mEnabledDayStart && day <= mEnabledDayEnd;
741    }
742
743    private boolean isValidDayOfMonth(int day) {
744        return day >= 1 && day <= mDaysInMonth;
745    }
746
747    private static boolean isValidDayOfWeek(int day) {
748        return day >= Calendar.SUNDAY && day <= Calendar.SATURDAY;
749    }
750
751    private static boolean isValidMonth(int month) {
752        return month >= Calendar.JANUARY && month <= Calendar.DECEMBER;
753    }
754
755    /**
756     * Sets the selected day.
757     *
758     * @param dayOfMonth the selected day of the month, or {@code -1} to clear
759     *                   the selection
760     */
761    public void setSelectedDay(int dayOfMonth) {
762        mActivatedDay = dayOfMonth;
763
764        // Invalidate cached accessibility information.
765        mTouchHelper.invalidateRoot();
766        invalidate();
767    }
768
769    /**
770     * Sets the first day of the week.
771     *
772     * @param weekStart which day the week should start on, valid values are
773     *                  {@link Calendar#SUNDAY} through {@link Calendar#SATURDAY}
774     */
775    public void setFirstDayOfWeek(int weekStart) {
776        if (DEBUG_WRONG_DATE) {
777            Log.d(LOG_TAG, "enter setFirstDayOfWeek(" + weekStart + ")", new Exception());
778        }
779
780        if (isValidDayOfWeek(weekStart)) {
781            mWeekStart = weekStart;
782        } else {
783            mWeekStart = mCalendar.getFirstDayOfWeek();
784        }
785
786        if (DEBUG_WRONG_DATE) {
787            Log.d(LOG_TAG, "mWeekStart <=" + mWeekStart);
788        }
789
790        updateDayOfWeekLabels();
791
792        // Invalidate cached accessibility information.
793        mTouchHelper.invalidateRoot();
794        invalidate();
795    }
796
797    /**
798     * Sets all the parameters for displaying this week.
799     * <p>
800     * Parameters have a default value and will only update if a new value is
801     * included, except for focus month, which will always default to no focus
802     * month if no value is passed in. The only required parameter is the week
803     * start.
804     *
805     * @param selectedDay the selected day of the month, or -1 for no selection
806     * @param month the month
807     * @param year the year
808     * @param weekStart which day the week should start on, valid values are
809     *                  {@link Calendar#SUNDAY} through {@link Calendar#SATURDAY}
810     * @param enabledDayStart the first enabled day
811     * @param enabledDayEnd the last enabled day
812     */
813    void setMonthParams(int selectedDay, int month, int year, int weekStart, int enabledDayStart,
814            int enabledDayEnd) {
815        if (DEBUG_WRONG_DATE) {
816            Log.d(LOG_TAG, "setMonthParams(" + selectedDay + ", " + month + ", " + year + ", "
817                    + weekStart + ", " + enabledDayStart + ", " + enabledDayEnd + ")");
818        }
819
820        mActivatedDay = selectedDay;
821
822        if (isValidMonth(month)) {
823            mMonth = month;
824        }
825        mYear = year;
826
827        mCalendar.set(Calendar.MONTH, mMonth);
828        mCalendar.set(Calendar.YEAR, mYear);
829        mCalendar.set(Calendar.DAY_OF_MONTH, 1);
830        mDayOfWeekStart = mCalendar.get(Calendar.DAY_OF_WEEK);
831
832        if (isValidDayOfWeek(weekStart)) {
833            mWeekStart = weekStart;
834        } else {
835            mWeekStart = mCalendar.getFirstDayOfWeek();
836        }
837
838        // Figure out what day today is.
839        final Calendar today = Calendar.getInstance();
840        mToday = -1;
841        mDaysInMonth = getDaysInMonth(mMonth, mYear);
842        for (int i = 0; i < mDaysInMonth; i++) {
843            final int day = i + 1;
844            if (sameDay(day, today)) {
845                mToday = day;
846            }
847        }
848
849        mEnabledDayStart = MathUtils.constrain(enabledDayStart, 1, mDaysInMonth);
850        mEnabledDayEnd = MathUtils.constrain(enabledDayEnd, mEnabledDayStart, mDaysInMonth);
851
852        // Invalidate cached accessibility information.
853        mTouchHelper.invalidateRoot();
854
855        updateMonthYearLabel();
856
857        if (DEBUG_WRONG_DATE) {
858            Log.d(LOG_TAG, "mMonth = " + mMonth);
859            Log.d(LOG_TAG, "mDayOfWeekStart = " + mDayOfWeekStart);
860            Log.d(LOG_TAG, "mWeekStart = " + mWeekStart);
861            Log.d(LOG_TAG, "mDaysInMonth = " + mDaysInMonth);
862            Log.d(LOG_TAG, "mToday = " + mToday);
863        }
864    }
865
866    private static int getDaysInMonth(int month, int year) {
867        switch (month) {
868            case Calendar.JANUARY:
869            case Calendar.MARCH:
870            case Calendar.MAY:
871            case Calendar.JULY:
872            case Calendar.AUGUST:
873            case Calendar.OCTOBER:
874            case Calendar.DECEMBER:
875                return 31;
876            case Calendar.APRIL:
877            case Calendar.JUNE:
878            case Calendar.SEPTEMBER:
879            case Calendar.NOVEMBER:
880                return 30;
881            case Calendar.FEBRUARY:
882                return (year % 4 == 0) ? 29 : 28;
883            default:
884                throw new IllegalArgumentException("Invalid Month");
885        }
886    }
887
888    private boolean sameDay(int day, Calendar today) {
889        return mYear == today.get(Calendar.YEAR) && mMonth == today.get(Calendar.MONTH)
890                && day == today.get(Calendar.DAY_OF_MONTH);
891    }
892
893    @Override
894    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
895        final int preferredHeight = mDesiredDayHeight * MAX_WEEKS_IN_MONTH
896                + mDesiredDayOfWeekHeight + mDesiredMonthHeight
897                + getPaddingTop() + getPaddingBottom();
898        final int preferredWidth = mDesiredCellWidth * DAYS_IN_WEEK
899                + getPaddingStart() + getPaddingEnd();
900        final int resolvedWidth = resolveSize(preferredWidth, widthMeasureSpec);
901        final int resolvedHeight = resolveSize(preferredHeight, heightMeasureSpec);
902        setMeasuredDimension(resolvedWidth, resolvedHeight);
903    }
904
905    @Override
906    public void onRtlPropertiesChanged(@ResolvedLayoutDir int layoutDirection) {
907        super.onRtlPropertiesChanged(layoutDirection);
908
909        requestLayout();
910    }
911
912    @Override
913    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
914        if (!changed) {
915            return;
916        }
917
918        // Let's initialize a completely reasonable number of variables.
919        final int w = right - left;
920        final int h = bottom - top;
921        final int paddingLeft = getPaddingLeft();
922        final int paddingTop = getPaddingTop();
923        final int paddingRight = getPaddingRight();
924        final int paddingBottom = getPaddingBottom();
925        final int paddedRight = w - paddingRight;
926        final int paddedBottom = h - paddingBottom;
927        final int paddedWidth = paddedRight - paddingLeft;
928        final int paddedHeight = paddedBottom - paddingTop;
929        if (paddedWidth == mPaddedWidth || paddedHeight == mPaddedHeight) {
930            return;
931        }
932
933        mPaddedWidth = paddedWidth;
934        mPaddedHeight = paddedHeight;
935
936        // We may have been laid out smaller than our preferred size. If so,
937        // scale all dimensions to fit.
938        final int measuredPaddedHeight = getMeasuredHeight() - paddingTop - paddingBottom;
939        final float scaleH = paddedHeight / (float) measuredPaddedHeight;
940        final int monthHeight = (int) (mDesiredMonthHeight * scaleH);
941        final int cellWidth = mPaddedWidth / DAYS_IN_WEEK;
942        mMonthHeight = monthHeight;
943        mDayOfWeekHeight = (int) (mDesiredDayOfWeekHeight * scaleH);
944        mDayHeight = (int) (mDesiredDayHeight * scaleH);
945        mCellWidth = cellWidth;
946
947        // Compute the largest day selector radius that's still within the clip
948        // bounds and desired selector radius.
949        final int maxSelectorWidth = cellWidth / 2 + Math.min(paddingLeft, paddingRight);
950        final int maxSelectorHeight = mDayHeight / 2 + paddingBottom;
951        mDaySelectorRadius = Math.min(mDesiredDaySelectorRadius,
952                Math.min(maxSelectorWidth, maxSelectorHeight));
953
954        // Invalidate cached accessibility information.
955        mTouchHelper.invalidateRoot();
956    }
957
958    private int findDayOffset() {
959        final int offset = mDayOfWeekStart - mWeekStart;
960        if (mDayOfWeekStart < mWeekStart) {
961            return offset + DAYS_IN_WEEK;
962        }
963        return offset;
964    }
965
966    /**
967     * Calculates the day of the month at the specified touch position. Returns
968     * the day of the month or -1 if the position wasn't in a valid day.
969     *
970     * @param x the x position of the touch event
971     * @param y the y position of the touch event
972     * @return the day of the month at (x, y), or -1 if the position wasn't in
973     *         a valid day
974     */
975    private int getDayAtLocation(int x, int y) {
976        final int paddedX = x - getPaddingLeft();
977        if (paddedX < 0 || paddedX >= mPaddedWidth) {
978            return -1;
979        }
980
981        final int headerHeight = mMonthHeight + mDayOfWeekHeight;
982        final int paddedY = y - getPaddingTop();
983        if (paddedY < headerHeight || paddedY >= mPaddedHeight) {
984            return -1;
985        }
986
987        // Adjust for RTL after applying padding.
988        final int paddedXRtl;
989        if (isLayoutRtl()) {
990            paddedXRtl = mPaddedWidth - paddedX;
991        } else {
992            paddedXRtl = paddedX;
993        }
994
995        final int row = (paddedY - headerHeight) / mDayHeight;
996        final int col = (paddedXRtl * DAYS_IN_WEEK) / mPaddedWidth;
997        final int index = col + row * DAYS_IN_WEEK;
998        final int day = index + 1 - findDayOffset();
999        if (!isValidDayOfMonth(day)) {
1000            return -1;
1001        }
1002
1003        return day;
1004    }
1005
1006    /**
1007     * Calculates the bounds of the specified day.
1008     *
1009     * @param id the day of the month
1010     * @param outBounds the rect to populate with bounds
1011     */
1012    private boolean getBoundsForDay(int id, Rect outBounds) {
1013        if (!isValidDayOfMonth(id)) {
1014            return false;
1015        }
1016
1017        final int index = id - 1 + findDayOffset();
1018
1019        // Compute left edge, taking into account RTL.
1020        final int col = index % DAYS_IN_WEEK;
1021        final int colWidth = mCellWidth;
1022        final int left;
1023        if (isLayoutRtl()) {
1024            left = getWidth() - getPaddingRight() - (col + 1) * colWidth;
1025        } else {
1026            left = getPaddingLeft() + col * colWidth;
1027        }
1028
1029        // Compute top edge.
1030        final int row = index / DAYS_IN_WEEK;
1031        final int rowHeight = mDayHeight;
1032        final int headerHeight = mMonthHeight + mDayOfWeekHeight;
1033        final int top = getPaddingTop() + headerHeight + row * rowHeight;
1034
1035        outBounds.set(left, top, left + colWidth, top + rowHeight);
1036
1037        return true;
1038    }
1039
1040    /**
1041     * Called when the user clicks on a day. Handles callbacks to the
1042     * {@link OnDayClickListener} if one is set.
1043     *
1044     * @param day the day that was clicked
1045     */
1046    private boolean onDayClicked(int day) {
1047        if (!isValidDayOfMonth(day) || !isDayEnabled(day)) {
1048            return false;
1049        }
1050
1051        if (mOnDayClickListener != null) {
1052            final Calendar date = Calendar.getInstance();
1053            date.set(mYear, mMonth, day);
1054            mOnDayClickListener.onDayClick(this, date);
1055        }
1056
1057        // This is a no-op if accessibility is turned off.
1058        mTouchHelper.sendEventForVirtualView(day, AccessibilityEvent.TYPE_VIEW_CLICKED);
1059        return true;
1060    }
1061
1062    /**
1063     * Provides a virtual view hierarchy for interfacing with an accessibility
1064     * service.
1065     */
1066    private class MonthViewTouchHelper extends ExploreByTouchHelper {
1067        private static final String DATE_FORMAT = "dd MMMM yyyy";
1068
1069        private final Rect mTempRect = new Rect();
1070        private final Calendar mTempCalendar = Calendar.getInstance();
1071
1072        public MonthViewTouchHelper(View host) {
1073            super(host);
1074        }
1075
1076        @Override
1077        protected int getVirtualViewAt(float x, float y) {
1078            final int day = getDayAtLocation((int) (x + 0.5f), (int) (y + 0.5f));
1079            if (day != -1) {
1080                return day;
1081            }
1082            return ExploreByTouchHelper.INVALID_ID;
1083        }
1084
1085        @Override
1086        protected void getVisibleVirtualViews(IntArray virtualViewIds) {
1087            for (int day = 1; day <= mDaysInMonth; day++) {
1088                virtualViewIds.add(day);
1089            }
1090        }
1091
1092        @Override
1093        protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
1094            event.setContentDescription(getDayDescription(virtualViewId));
1095        }
1096
1097        @Override
1098        protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node) {
1099            final boolean hasBounds = getBoundsForDay(virtualViewId, mTempRect);
1100
1101            if (!hasBounds) {
1102                // The day is invalid, kill the node.
1103                mTempRect.setEmpty();
1104                node.setContentDescription("");
1105                node.setBoundsInParent(mTempRect);
1106                node.setVisibleToUser(false);
1107                return;
1108            }
1109
1110            node.setText(getDayText(virtualViewId));
1111            node.setContentDescription(getDayDescription(virtualViewId));
1112            node.setBoundsInParent(mTempRect);
1113
1114            final boolean isDayEnabled = isDayEnabled(virtualViewId);
1115            if (isDayEnabled) {
1116                node.addAction(AccessibilityAction.ACTION_CLICK);
1117            }
1118
1119            node.setEnabled(isDayEnabled);
1120
1121            if (virtualViewId == mActivatedDay) {
1122                // TODO: This should use activated once that's supported.
1123                node.setChecked(true);
1124            }
1125
1126        }
1127
1128        @Override
1129        protected boolean onPerformActionForVirtualView(int virtualViewId, int action,
1130                Bundle arguments) {
1131            switch (action) {
1132                case AccessibilityNodeInfo.ACTION_CLICK:
1133                    return onDayClicked(virtualViewId);
1134            }
1135
1136            return false;
1137        }
1138
1139        /**
1140         * Generates a description for a given virtual view.
1141         *
1142         * @param id the day to generate a description for
1143         * @return a description of the virtual view
1144         */
1145        private CharSequence getDayDescription(int id) {
1146            if (isValidDayOfMonth(id)) {
1147                mTempCalendar.set(mYear, mMonth, id);
1148                return DateFormat.format(DATE_FORMAT, mTempCalendar.getTimeInMillis());
1149            }
1150
1151            return "";
1152        }
1153
1154        /**
1155         * Generates displayed text for a given virtual view.
1156         *
1157         * @param id the day to generate text for
1158         * @return the visible text of the virtual view
1159         */
1160        private CharSequence getDayText(int id) {
1161            if (isValidDayOfMonth(id)) {
1162                return mDayFormatter.format(id);
1163            }
1164
1165            return null;
1166        }
1167    }
1168
1169    /**
1170     * Handles callbacks when the user clicks on a time object.
1171     */
1172    public interface OnDayClickListener {
1173        void onDayClick(SimpleMonthView view, Calendar day);
1174    }
1175}
1176