SimpleMonthView.java revision ddf655c49f4173aa55c9ba1a2622cf75cf5bc2f2
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.content.Context;
20import android.content.res.ColorStateList;
21import android.content.res.Resources;
22import android.content.res.TypedArray;
23import android.graphics.Canvas;
24import android.graphics.Paint;
25import android.graphics.Paint.Align;
26import android.graphics.Paint.Style;
27import android.graphics.Rect;
28import android.graphics.Typeface;
29import android.os.Bundle;
30import android.text.TextPaint;
31import android.text.format.DateFormat;
32import android.util.AttributeSet;
33import android.util.IntArray;
34import android.util.StateSet;
35import android.view.MotionEvent;
36import android.view.View;
37import android.view.accessibility.AccessibilityEvent;
38import android.view.accessibility.AccessibilityNodeInfo;
39import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
40
41import com.android.internal.R;
42import com.android.internal.widget.ExploreByTouchHelper;
43
44import java.text.SimpleDateFormat;
45import java.util.Calendar;
46import java.util.Locale;
47
48/**
49 * A calendar-like view displaying a specified month and the appropriate selectable day numbers
50 * within the specified month.
51 */
52class SimpleMonthView extends View {
53    private static final int DAYS_IN_WEEK = 7;
54    private static final int MAX_WEEKS_IN_MONTH = 6;
55
56    private static final int DEFAULT_SELECTED_DAY = -1;
57    private static final int DEFAULT_WEEK_START = Calendar.SUNDAY;
58
59    private static final String DEFAULT_TITLE_FORMAT = "MMMMy";
60    private static final String DAY_OF_WEEK_FORMAT = "EEEEE";
61
62    private final TextPaint mMonthPaint = new TextPaint();
63    private final TextPaint mDayOfWeekPaint = new TextPaint();
64    private final TextPaint mDayPaint = new TextPaint();
65    private final Paint mDaySelectorPaint = new Paint();
66    private final Paint mDayHighlightPaint = new Paint();
67
68    private final Calendar mCalendar = Calendar.getInstance();
69    private final Calendar mDayOfWeekLabelCalendar = Calendar.getInstance();
70
71    private final MonthViewTouchHelper mTouchHelper;
72
73    private final SimpleDateFormat mTitleFormatter;
74    private final SimpleDateFormat mDayOfWeekFormatter;
75
76    // Desired dimensions.
77    private final int mDesiredMonthHeight;
78    private final int mDesiredDayOfWeekHeight;
79    private final int mDesiredDayHeight;
80    private final int mDesiredCellWidth;
81    private final int mDesiredDaySelectorRadius;
82
83    private CharSequence mTitle;
84
85    private int mMonth;
86    private int mYear;
87
88    // Dimensions as laid out.
89    private int mMonthHeight;
90    private int mDayOfWeekHeight;
91    private int mDayHeight;
92    private int mCellWidth;
93    private int mDaySelectorRadius;
94
95    private int mPaddedWidth;
96    private int mPaddedHeight;
97
98    /** The day of month for the selected day, or -1 if no day is selected. */
99    private int mActivatedDay = -1;
100
101    /**
102     * The day of month for today, or -1 if the today is not in the current
103     * month.
104     */
105    private int mToday = DEFAULT_SELECTED_DAY;
106
107    /** The first day of the week (ex. Calendar.SUNDAY). */
108    private int mWeekStart = DEFAULT_WEEK_START;
109
110    /** The number of days (ex. 28) in the current month. */
111    private int mDaysInMonth;
112
113    /**
114     * The day of week (ex. Calendar.SUNDAY) for the first day of the current
115     * month.
116     */
117    private int mDayOfWeekStart;
118
119    /** The day of month for the first (inclusive) enabled day. */
120    private int mEnabledDayStart = 1;
121
122    /** The day of month for the last (inclusive) enabled day. */
123    private int mEnabledDayEnd = 31;
124
125    /** Optional listener for handling day click actions. */
126    private OnDayClickListener mOnDayClickListener;
127
128    private ColorStateList mDayTextColor;
129
130    private int mTouchedItem = -1;
131
132    public SimpleMonthView(Context context) {
133        this(context, null);
134    }
135
136    public SimpleMonthView(Context context, AttributeSet attrs) {
137        this(context, attrs, R.attr.datePickerStyle);
138    }
139
140    public SimpleMonthView(Context context, AttributeSet attrs, int defStyleAttr) {
141        this(context, attrs, defStyleAttr, 0);
142    }
143
144    public SimpleMonthView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
145        super(context, attrs, defStyleAttr, defStyleRes);
146
147        final Resources res = context.getResources();
148        mDesiredMonthHeight = res.getDimensionPixelSize(R.dimen.date_picker_month_height);
149        mDesiredDayOfWeekHeight = res.getDimensionPixelSize(R.dimen.date_picker_day_of_week_height);
150        mDesiredDayHeight = res.getDimensionPixelSize(R.dimen.date_picker_day_height);
151        mDesiredCellWidth = res.getDimensionPixelSize(R.dimen.date_picker_day_width);
152        mDesiredDaySelectorRadius = res.getDimensionPixelSize(
153                R.dimen.date_picker_day_selector_radius);
154
155        // Set up accessibility components.
156        mTouchHelper = new MonthViewTouchHelper(this);
157        setAccessibilityDelegate(mTouchHelper);
158        setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
159
160        final Locale locale = res.getConfiguration().locale;
161        final String titleFormat = DateFormat.getBestDateTimePattern(locale, DEFAULT_TITLE_FORMAT);
162        mTitleFormatter = new SimpleDateFormat(titleFormat, locale);
163        mDayOfWeekFormatter = new SimpleDateFormat(DAY_OF_WEEK_FORMAT, locale);
164
165        initPaints(res);
166    }
167
168    /**
169     * Applies the specified text appearance resource to a paint, returning the
170     * text color if one is set in the text appearance.
171     *
172     * @param p the paint to modify
173     * @param resId the resource ID of the text appearance
174     * @return the text color, if available
175     */
176    private ColorStateList applyTextAppearance(Paint p, int resId) {
177        final TypedArray ta = mContext.obtainStyledAttributes(null,
178                R.styleable.TextAppearance, 0, resId);
179
180        final String fontFamily = ta.getString(R.styleable.TextAppearance_fontFamily);
181        if (fontFamily != null) {
182            p.setTypeface(Typeface.create(fontFamily, 0));
183        }
184
185        p.setTextSize(ta.getDimensionPixelSize(
186                R.styleable.TextAppearance_textSize, (int) p.getTextSize()));
187
188        final ColorStateList textColor = ta.getColorStateList(R.styleable.TextAppearance_textColor);
189        if (textColor != null) {
190            final int enabledColor = textColor.getColorForState(ENABLED_STATE_SET, 0);
191            p.setColor(enabledColor);
192        }
193
194        ta.recycle();
195
196        return textColor;
197    }
198
199    public int getMonthHeight() {
200        return mMonthHeight;
201    }
202
203    public int getCellWidth() {
204        return mCellWidth;
205    }
206
207    public void setMonthTextAppearance(int resId) {
208        applyTextAppearance(mMonthPaint, resId);
209
210        invalidate();
211    }
212
213    public void setDayOfWeekTextAppearance(int resId) {
214        applyTextAppearance(mDayOfWeekPaint, resId);
215        invalidate();
216    }
217
218    public void setDayTextAppearance(int resId) {
219        final ColorStateList textColor = applyTextAppearance(mDayPaint, resId);
220        if (textColor != null) {
221            mDayTextColor = textColor;
222        }
223
224        invalidate();
225    }
226
227    public CharSequence getTitle() {
228        if (mTitle == null) {
229            mTitle = mTitleFormatter.format(mCalendar.getTime());
230        }
231        return mTitle;
232    }
233
234    /**
235     * Sets up the text and style properties for painting.
236     */
237    private void initPaints(Resources res) {
238        final String monthTypeface = res.getString(R.string.date_picker_month_typeface);
239        final String dayOfWeekTypeface = res.getString(R.string.date_picker_day_of_week_typeface);
240        final String dayTypeface = res.getString(R.string.date_picker_day_typeface);
241
242        final int monthTextSize = res.getDimensionPixelSize(
243                R.dimen.date_picker_month_text_size);
244        final int dayOfWeekTextSize = res.getDimensionPixelSize(
245                R.dimen.date_picker_day_of_week_text_size);
246        final int dayTextSize = res.getDimensionPixelSize(
247                R.dimen.date_picker_day_text_size);
248
249        mMonthPaint.setAntiAlias(true);
250        mMonthPaint.setTextSize(monthTextSize);
251        mMonthPaint.setTypeface(Typeface.create(monthTypeface, 0));
252        mMonthPaint.setTextAlign(Align.CENTER);
253        mMonthPaint.setStyle(Style.FILL);
254
255        mDayOfWeekPaint.setAntiAlias(true);
256        mDayOfWeekPaint.setTextSize(dayOfWeekTextSize);
257        mDayOfWeekPaint.setTypeface(Typeface.create(dayOfWeekTypeface, 0));
258        mDayOfWeekPaint.setTextAlign(Align.CENTER);
259        mDayOfWeekPaint.setStyle(Style.FILL);
260
261        mDaySelectorPaint.setAntiAlias(true);
262        mDaySelectorPaint.setStyle(Style.FILL);
263
264        mDayHighlightPaint.setAntiAlias(true);
265        mDayHighlightPaint.setStyle(Style.FILL);
266
267        mDayPaint.setAntiAlias(true);
268        mDayPaint.setTextSize(dayTextSize);
269        mDayPaint.setTypeface(Typeface.create(dayTypeface, 0));
270        mDayPaint.setTextAlign(Align.CENTER);
271        mDayPaint.setStyle(Style.FILL);
272    }
273
274    void setMonthTextColor(ColorStateList monthTextColor) {
275        final int enabledColor = monthTextColor.getColorForState(ENABLED_STATE_SET, 0);
276        mMonthPaint.setColor(enabledColor);
277        invalidate();
278    }
279
280    void setDayOfWeekTextColor(ColorStateList dayOfWeekTextColor) {
281        final int enabledColor = dayOfWeekTextColor.getColorForState(ENABLED_STATE_SET, 0);
282        mDayOfWeekPaint.setColor(enabledColor);
283        invalidate();
284    }
285
286    void setDayTextColor(ColorStateList dayTextColor) {
287        mDayTextColor = dayTextColor;
288        invalidate();
289    }
290
291    void setDaySelectorColor(ColorStateList dayBackgroundColor) {
292        final int activatedColor = dayBackgroundColor.getColorForState(
293                StateSet.get(StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED), 0);
294        mDaySelectorPaint.setColor(activatedColor);
295        invalidate();
296    }
297
298    void setDayHighlightColor(ColorStateList dayHighlightColor) {
299        final int pressedColor = dayHighlightColor.getColorForState(
300                StateSet.get(StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_PRESSED), 0);
301        mDayHighlightPaint.setColor(pressedColor);
302        invalidate();
303    }
304
305    public void setOnDayClickListener(OnDayClickListener listener) {
306        mOnDayClickListener = listener;
307    }
308
309    @Override
310    public boolean dispatchHoverEvent(MotionEvent event) {
311        // First right-of-refusal goes the touch exploration helper.
312        return mTouchHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event);
313    }
314
315    @Override
316    public boolean onTouchEvent(MotionEvent event) {
317        final int x = (int) (event.getX() + 0.5f);
318        final int y = (int) (event.getY() + 0.5f);
319
320        final int action = event.getAction();
321        switch (action) {
322            case MotionEvent.ACTION_DOWN:
323            case MotionEvent.ACTION_MOVE:
324                final int touchedItem = getDayAtLocation(x, y);
325                if (mTouchedItem != touchedItem) {
326                    mTouchedItem = touchedItem;
327                    invalidate();
328                }
329                if (action == MotionEvent.ACTION_DOWN && touchedItem < 0) {
330                    // Touch something that's not an item, reject event.
331                    return false;
332                }
333                break;
334
335            case MotionEvent.ACTION_UP:
336                final int clickedDay = getDayAtLocation(x, y);
337                onDayClicked(clickedDay);
338                // Fall through.
339            case MotionEvent.ACTION_CANCEL:
340                // Reset touched day on stream end.
341                mTouchedItem = -1;
342                invalidate();
343                break;
344        }
345        return true;
346    }
347
348    @Override
349    protected void onDraw(Canvas canvas) {
350        final int paddingLeft = getPaddingLeft();
351        final int paddingTop = getPaddingTop();
352        canvas.translate(paddingLeft, paddingTop);
353
354        drawMonth(canvas);
355        drawDaysOfWeek(canvas);
356        drawDays(canvas);
357
358        canvas.translate(-paddingLeft, -paddingTop);
359    }
360
361    private void drawMonth(Canvas canvas) {
362        final float x = mPaddedWidth / 2f;
363
364        // Vertically centered within the month header height.
365        final float lineHeight = mMonthPaint.ascent() + mMonthPaint.descent();
366        final float y = (mMonthHeight - lineHeight) / 2f;
367
368        canvas.drawText(getTitle().toString(), x, y, mMonthPaint);
369    }
370
371    private void drawDaysOfWeek(Canvas canvas) {
372        final TextPaint p = mDayOfWeekPaint;
373        final int headerHeight = mMonthHeight;
374        final int rowHeight = mDayOfWeekHeight;
375        final int colWidth = mCellWidth;
376
377        // Text is vertically centered within the day of week height.
378        final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
379        final int rowCenter = headerHeight + rowHeight / 2;
380
381        for (int col = 0; col < DAYS_IN_WEEK; col++) {
382            final int colCenter = colWidth * col + colWidth / 2;
383            final int colCenterRtl;
384            if (isLayoutRtl()) {
385                colCenterRtl = mPaddedWidth - colCenter;
386            } else {
387                colCenterRtl = colCenter;
388            }
389
390            final int dayOfWeek = (col + mWeekStart) % DAYS_IN_WEEK;
391            final String label = getDayOfWeekLabel(dayOfWeek);
392            canvas.drawText(label, colCenterRtl, rowCenter - halfLineHeight, p);
393        }
394    }
395
396    private String getDayOfWeekLabel(int dayOfWeek) {
397        mDayOfWeekLabelCalendar.set(Calendar.DAY_OF_WEEK, dayOfWeek);
398        return mDayOfWeekFormatter.format(mDayOfWeekLabelCalendar.getTime());
399    }
400
401    /**
402     * Draws the month days.
403     */
404    private void drawDays(Canvas canvas) {
405        final TextPaint p = mDayPaint;
406        final int headerHeight = mMonthHeight + mDayOfWeekHeight;
407        final int rowHeight = mDayHeight;
408        final int colWidth = mCellWidth;
409
410        // Text is vertically centered within the row height.
411        final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
412        int rowCenter = headerHeight + rowHeight / 2;
413
414        for (int day = 1, col = findDayOffset(); day <= mDaysInMonth; day++) {
415            final int colCenter = colWidth * col + colWidth / 2;
416            final int colCenterRtl;
417            if (isLayoutRtl()) {
418                colCenterRtl = mPaddedWidth - colCenter;
419            } else {
420                colCenterRtl = colCenter;
421            }
422
423            int stateMask = 0;
424
425            if (day >= mEnabledDayStart && day <= mEnabledDayEnd) {
426                stateMask |= StateSet.VIEW_STATE_ENABLED;
427            }
428
429            final boolean isDayActivated = mActivatedDay == day;
430            if (isDayActivated) {
431                stateMask |= StateSet.VIEW_STATE_ACTIVATED;
432
433                // Adjust the circle to be centered on the row.
434                canvas.drawCircle(colCenterRtl, rowCenter, mDaySelectorRadius, mDaySelectorPaint);
435            } else if (mTouchedItem == day) {
436                stateMask |= StateSet.VIEW_STATE_PRESSED;
437
438                // Adjust the circle to be centered on the row.
439                canvas.drawCircle(colCenterRtl, rowCenter, mDaySelectorRadius, mDayHighlightPaint);
440            }
441
442            final boolean isDayToday = mToday == day;
443            final int dayTextColor;
444            if (isDayToday && !isDayActivated) {
445                dayTextColor = mDaySelectorPaint.getColor();
446            } else {
447                final int[] stateSet = StateSet.get(stateMask);
448                dayTextColor = mDayTextColor.getColorForState(stateSet, 0);
449            }
450            p.setColor(dayTextColor);
451
452            canvas.drawText(Integer.toString(day), colCenterRtl, rowCenter - halfLineHeight, p);
453
454            col++;
455
456            if (col == DAYS_IN_WEEK) {
457                col = 0;
458                rowCenter += rowHeight;
459            }
460        }
461    }
462
463    private static boolean isValidDayOfWeek(int day) {
464        return day >= Calendar.SUNDAY && day <= Calendar.SATURDAY;
465    }
466
467    private static boolean isValidMonth(int month) {
468        return month >= Calendar.JANUARY && month <= Calendar.DECEMBER;
469    }
470
471    /**
472     * Sets the selected day.
473     *
474     * @param dayOfMonth the selected day of the month, or {@code -1} to clear
475     *                   the selection
476     */
477    public void setSelectedDay(int dayOfMonth) {
478        mActivatedDay = dayOfMonth;
479
480        // Invalidate cached accessibility information.
481        mTouchHelper.invalidateRoot();
482        invalidate();
483    }
484
485    /**
486     * Sets the first day of the week.
487     *
488     * @param weekStart which day the week should start on, valid values are
489     *                  {@link Calendar#SUNDAY} through {@link Calendar#SATURDAY}
490     */
491    public void setFirstDayOfWeek(int weekStart) {
492        if (isValidDayOfWeek(weekStart)) {
493            mWeekStart = weekStart;
494        } else {
495            mWeekStart = mCalendar.getFirstDayOfWeek();
496        }
497
498        // Invalidate cached accessibility information.
499        mTouchHelper.invalidateRoot();
500        invalidate();
501    }
502
503    /**
504     * Sets all the parameters for displaying this week.
505     * <p>
506     * Parameters have a default value and will only update if a new value is
507     * included, except for focus month, which will always default to no focus
508     * month if no value is passed in. The only required parameter is the week
509     * start.
510     *
511     * @param selectedDay the selected day of the month, or -1 for no selection
512     * @param month the month
513     * @param year the year
514     * @param weekStart which day the week should start on, valid values are
515     *                  {@link Calendar#SUNDAY} through {@link Calendar#SATURDAY}
516     * @param enabledDayStart the first enabled day
517     * @param enabledDayEnd the last enabled day
518     */
519    void setMonthParams(int selectedDay, int month, int year, int weekStart, int enabledDayStart,
520            int enabledDayEnd) {
521        mActivatedDay = selectedDay;
522
523        if (isValidMonth(month)) {
524            mMonth = month;
525        }
526        mYear = year;
527
528        mCalendar.set(Calendar.MONTH, mMonth);
529        mCalendar.set(Calendar.YEAR, mYear);
530        mCalendar.set(Calendar.DAY_OF_MONTH, 1);
531        mDayOfWeekStart = mCalendar.get(Calendar.DAY_OF_WEEK);
532
533        if (isValidDayOfWeek(weekStart)) {
534            mWeekStart = weekStart;
535        } else {
536            mWeekStart = mCalendar.getFirstDayOfWeek();
537        }
538
539        if (enabledDayStart > 0 && enabledDayEnd < 32) {
540            mEnabledDayStart = enabledDayStart;
541        }
542        if (enabledDayEnd > 0 && enabledDayEnd < 32 && enabledDayEnd >= enabledDayStart) {
543            mEnabledDayEnd = enabledDayEnd;
544        }
545
546        // Figure out what day today is.
547        final Calendar today = Calendar.getInstance();
548        mToday = -1;
549        mDaysInMonth = getDaysInMonth(mMonth, mYear);
550        for (int i = 0; i < mDaysInMonth; i++) {
551            final int day = i + 1;
552            if (sameDay(day, today)) {
553                mToday = day;
554            }
555        }
556
557        // Invalidate the old title.
558        mTitle = null;
559
560        // Invalidate cached accessibility information.
561        mTouchHelper.invalidateRoot();
562    }
563
564    private static int getDaysInMonth(int month, int year) {
565        switch (month) {
566            case Calendar.JANUARY:
567            case Calendar.MARCH:
568            case Calendar.MAY:
569            case Calendar.JULY:
570            case Calendar.AUGUST:
571            case Calendar.OCTOBER:
572            case Calendar.DECEMBER:
573                return 31;
574            case Calendar.APRIL:
575            case Calendar.JUNE:
576            case Calendar.SEPTEMBER:
577            case Calendar.NOVEMBER:
578                return 30;
579            case Calendar.FEBRUARY:
580                return (year % 4 == 0) ? 29 : 28;
581            default:
582                throw new IllegalArgumentException("Invalid Month");
583        }
584    }
585
586    private boolean sameDay(int day, Calendar today) {
587        return mYear == today.get(Calendar.YEAR) && mMonth == today.get(Calendar.MONTH)
588                && day == today.get(Calendar.DAY_OF_MONTH);
589    }
590
591    @Override
592    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
593        final int preferredHeight = mDesiredDayHeight * MAX_WEEKS_IN_MONTH
594                + mDesiredDayOfWeekHeight + mDesiredMonthHeight
595                + getPaddingTop() + getPaddingBottom();
596        final int preferredWidth = mDesiredCellWidth * DAYS_IN_WEEK
597                + getPaddingStart() + getPaddingEnd();
598        final int resolvedWidth = resolveSize(preferredWidth, widthMeasureSpec);
599        final int resolvedHeight = resolveSize(preferredHeight, heightMeasureSpec);
600        setMeasuredDimension(resolvedWidth, resolvedHeight);
601    }
602
603    @Override
604    public void onRtlPropertiesChanged(@ResolvedLayoutDir int layoutDirection) {
605        super.onRtlPropertiesChanged(layoutDirection);
606
607        requestLayout();
608    }
609
610    @Override
611    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
612        if (!changed) {
613            return;
614        }
615
616        // Let's initialize a completely reasonable number of variables.
617        final int w = right - left;
618        final int h = bottom - top;
619        final int paddingLeft = getPaddingLeft();
620        final int paddingTop = getPaddingTop();
621        final int paddingRight = getPaddingRight();
622        final int paddingBottom = getPaddingBottom();
623        final int paddedRight = w - paddingRight;
624        final int paddedBottom = h - paddingBottom;
625        final int paddedWidth = paddedRight - paddingLeft;
626        final int paddedHeight = paddedBottom - paddingTop;
627        if (paddedWidth == mPaddedWidth || paddedHeight == mPaddedHeight) {
628            return;
629        }
630
631        mPaddedWidth = paddedWidth;
632        mPaddedHeight = paddedHeight;
633
634        // We may have been laid out smaller than our preferred size. If so,
635        // scale all dimensions to fit.
636        final int measuredPaddedHeight = getMeasuredHeight() - paddingTop - paddingBottom;
637        final float scaleH = paddedHeight / (float) measuredPaddedHeight;
638        final int monthHeight = (int) (mDesiredMonthHeight * scaleH);
639        final int cellWidth = mPaddedWidth / DAYS_IN_WEEK;
640        mMonthHeight = monthHeight;
641        mDayOfWeekHeight = (int) (mDesiredDayOfWeekHeight * scaleH);
642        mDayHeight = (int) (mDesiredDayHeight * scaleH);
643        mCellWidth = cellWidth;
644
645        // Compute the largest day selector radius that's still within the clip
646        // bounds and desired selector radius.
647        final int maxSelectorWidth = cellWidth / 2 + Math.min(paddingLeft, paddingRight);
648        final int maxSelectorHeight = mDayHeight / 2 + paddingBottom;
649        mDaySelectorRadius = Math.min(mDesiredDaySelectorRadius,
650                Math.min(maxSelectorWidth, maxSelectorHeight));
651
652        // Invalidate cached accessibility information.
653        mTouchHelper.invalidateRoot();
654    }
655
656    private int findDayOffset() {
657        final int offset = mDayOfWeekStart - mWeekStart;
658        if (mDayOfWeekStart < mWeekStart) {
659            return offset + DAYS_IN_WEEK;
660        }
661        return offset;
662    }
663
664    /**
665     * Calculates the day of the month at the specified touch position. Returns
666     * the day of the month or -1 if the position wasn't in a valid day.
667     *
668     * @param x the x position of the touch event
669     * @param y the y position of the touch event
670     * @return the day of the month at (x, y), or -1 if the position wasn't in
671     *         a valid day
672     */
673    private int getDayAtLocation(int x, int y) {
674        final int paddedX = x - getPaddingLeft();
675        if (paddedX < 0 || paddedX >= mPaddedWidth) {
676            return -1;
677        }
678
679        final int headerHeight = mMonthHeight + mDayOfWeekHeight;
680        final int paddedY = y - getPaddingTop();
681        if (paddedY < headerHeight || paddedY >= mPaddedHeight) {
682            return -1;
683        }
684
685        // Adjust for RTL after applying padding.
686        final int paddedXRtl;
687        if (isLayoutRtl()) {
688            paddedXRtl = mPaddedWidth - paddedX;
689        } else {
690            paddedXRtl = paddedX;
691        }
692
693        final int row = (paddedY - headerHeight) / mDayHeight;
694        final int col = (paddedXRtl * DAYS_IN_WEEK) / mPaddedWidth;
695        final int index = col + row * DAYS_IN_WEEK;
696        final int day = index + 1 - findDayOffset();
697        if (day < 1 || day > mDaysInMonth) {
698            return -1;
699        }
700
701        return day;
702    }
703
704    /**
705     * Calculates the bounds of the specified day.
706     *
707     * @param id the day of the month
708     * @param outBounds the rect to populate with bounds
709     */
710    private boolean getBoundsForDay(int id, Rect outBounds) {
711        if (id < 1 || id > mDaysInMonth) {
712            return false;
713        }
714
715        final int index = id - 1 + findDayOffset();
716
717        // Compute left edge, taking into account RTL.
718        final int col = index % DAYS_IN_WEEK;
719        final int colWidth = mCellWidth;
720        final int left;
721        if (isLayoutRtl()) {
722            left = getWidth() - getPaddingRight() - (col + 1) * colWidth;
723        } else {
724            left = getPaddingLeft() + col * colWidth;
725        }
726
727        // Compute top edge.
728        final int row = index / DAYS_IN_WEEK;
729        final int rowHeight = mDayHeight;
730        final int headerHeight = mMonthHeight + mDayOfWeekHeight;
731        final int top = getPaddingTop() + headerHeight + row * rowHeight;
732
733        outBounds.set(left, top, left + colWidth, top + rowHeight);
734
735        return true;
736    }
737
738    /**
739     * Called when the user clicks on a day. Handles callbacks to the
740     * {@link OnDayClickListener} if one is set.
741     *
742     * @param day the day that was clicked
743     */
744    private boolean onDayClicked(int day) {
745        if (day < 0 || day > mDaysInMonth) {
746            return false;
747        }
748
749        if (mOnDayClickListener != null) {
750            final Calendar date = Calendar.getInstance();
751            date.set(mYear, mMonth, day);
752            mOnDayClickListener.onDayClick(this, date);
753        }
754
755        // This is a no-op if accessibility is turned off.
756        mTouchHelper.sendEventForVirtualView(day, AccessibilityEvent.TYPE_VIEW_CLICKED);
757        return true;
758    }
759
760    /**
761     * Provides a virtual view hierarchy for interfacing with an accessibility
762     * service.
763     */
764    private class MonthViewTouchHelper extends ExploreByTouchHelper {
765        private static final String DATE_FORMAT = "dd MMMM yyyy";
766
767        private final Rect mTempRect = new Rect();
768        private final Calendar mTempCalendar = Calendar.getInstance();
769
770        public MonthViewTouchHelper(View host) {
771            super(host);
772        }
773
774        @Override
775        protected int getVirtualViewAt(float x, float y) {
776            final int day = getDayAtLocation((int) (x + 0.5f), (int) (y + 0.5f));
777            if (day >= 0) {
778                return day;
779            }
780            return ExploreByTouchHelper.INVALID_ID;
781        }
782
783        @Override
784        protected void getVisibleVirtualViews(IntArray virtualViewIds) {
785            for (int day = 1; day <= mDaysInMonth; day++) {
786                virtualViewIds.add(day);
787            }
788        }
789
790        @Override
791        protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
792            event.setContentDescription(getDayDescription(virtualViewId));
793        }
794
795        @Override
796        protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node) {
797            final boolean hasBounds = getBoundsForDay(virtualViewId, mTempRect);
798
799            if (!hasBounds) {
800                // The day is invalid, kill the node.
801                mTempRect.setEmpty();
802                node.setContentDescription("");
803                node.setBoundsInParent(mTempRect);
804                node.setVisibleToUser(false);
805                return;
806            }
807
808            node.setText(getDayText(virtualViewId));
809            node.setContentDescription(getDayDescription(virtualViewId));
810            node.setBoundsInParent(mTempRect);
811            node.addAction(AccessibilityAction.ACTION_CLICK);
812
813            if (virtualViewId == mActivatedDay) {
814                // TODO: This should use activated once that's supported.
815                node.setChecked(true);
816            }
817
818        }
819
820        @Override
821        protected boolean onPerformActionForVirtualView(int virtualViewId, int action,
822                Bundle arguments) {
823            switch (action) {
824                case AccessibilityNodeInfo.ACTION_CLICK:
825                    return onDayClicked(virtualViewId);
826            }
827
828            return false;
829        }
830
831        /**
832         * Generates a description for a given virtual view.
833         *
834         * @param id the day to generate a description for
835         * @return a description of the virtual view
836         */
837        private CharSequence getDayDescription(int id) {
838            if (id >= 1 && id <= mDaysInMonth) {
839                mTempCalendar.set(mYear, mMonth, id);
840                return DateFormat.format(DATE_FORMAT, mTempCalendar.getTimeInMillis());
841            }
842
843            return "";
844        }
845
846        /**
847         * Generates displayed text for a given virtual view.
848         *
849         * @param id the day to generate text for
850         * @return the visible text of the virtual view
851         */
852        private CharSequence getDayText(int id) {
853            if (id >= 1 && id <= mDaysInMonth) {
854                return Integer.toString(id);
855            }
856
857            return null;
858        }
859    }
860
861    /**
862     * Handles callbacks when the user clicks on a time object.
863     */
864    public interface OnDayClickListener {
865        void onDayClick(SimpleMonthView view, Calendar day);
866    }
867}
868