MonthView.java revision 7c597aee285f2fe4a13a24334493c6eadddd7939
1/*
2 * Copyright (C) 2013 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.datetimepicker.date;
18
19import android.content.Context;
20import android.content.res.Resources;
21import android.graphics.Canvas;
22import android.graphics.Paint;
23import android.graphics.Paint.Align;
24import android.graphics.Paint.Style;
25import android.graphics.Rect;
26import android.graphics.Typeface;
27import android.os.Bundle;
28import android.support.v4.view.ViewCompat;
29import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
30import android.support.v4.widget.ExploreByTouchHelper;
31import android.text.format.DateFormat;
32import android.text.format.DateUtils;
33import android.text.format.Time;
34import android.view.MotionEvent;
35import android.view.View;
36import android.view.accessibility.AccessibilityEvent;
37import android.view.accessibility.AccessibilityNodeInfo;
38
39import com.android.datetimepicker.R;
40import com.android.datetimepicker.Utils;
41import com.android.datetimepicker.date.MonthAdapter.CalendarDay;
42
43import java.security.InvalidParameterException;
44import java.util.Calendar;
45import java.util.Formatter;
46import java.util.HashMap;
47import java.util.List;
48import java.util.Locale;
49
50/**
51 * A calendar-like view displaying a specified month and the appropriate selectable day numbers
52 * within the specified month.
53 */
54public abstract class MonthView extends View {
55    private static final String TAG = "MonthView";
56
57    /**
58     * These params can be passed into the view to control how it appears.
59     * {@link #VIEW_PARAMS_WEEK} is the only required field, though the default
60     * values are unlikely to fit most layouts correctly.
61     */
62    /**
63     * This sets the height of this week in pixels
64     */
65    public static final String VIEW_PARAMS_HEIGHT = "height";
66    /**
67     * This specifies the position (or weeks since the epoch) of this week,
68     * calculated using {@link Utils#getWeeksSinceEpochFromJulianDay}
69     */
70    public static final String VIEW_PARAMS_MONTH = "month";
71    /**
72     * This specifies the position (or weeks since the epoch) of this week,
73     * calculated using {@link Utils#getWeeksSinceEpochFromJulianDay}
74     */
75    public static final String VIEW_PARAMS_YEAR = "year";
76    /**
77     * This sets one of the days in this view as selected {@link Time#SUNDAY}
78     * through {@link Time#SATURDAY}.
79     */
80    public static final String VIEW_PARAMS_SELECTED_DAY = "selected_day";
81    /**
82     * Which day the week should start on. {@link Time#SUNDAY} through
83     * {@link Time#SATURDAY}.
84     */
85    public static final String VIEW_PARAMS_WEEK_START = "week_start";
86    /**
87     * How many days to display at a time. Days will be displayed starting with
88     * {@link #mWeekStart}.
89     */
90    public static final String VIEW_PARAMS_NUM_DAYS = "num_days";
91    /**
92     * Which month is currently in focus, as defined by {@link Time#month}
93     * [0-11].
94     */
95    public static final String VIEW_PARAMS_FOCUS_MONTH = "focus_month";
96    /**
97     * If this month should display week numbers. false if 0, true otherwise.
98     */
99    public static final String VIEW_PARAMS_SHOW_WK_NUM = "show_wk_num";
100
101    protected static int DEFAULT_HEIGHT = 32;
102    protected static int MIN_HEIGHT = 10;
103    protected static final int DEFAULT_SELECTED_DAY = -1;
104    protected static final int DEFAULT_WEEK_START = Calendar.SUNDAY;
105    protected static final int DEFAULT_NUM_DAYS = 7;
106    protected static final int DEFAULT_SHOW_WK_NUM = 0;
107    protected static final int DEFAULT_FOCUS_MONTH = -1;
108    protected static final int DEFAULT_NUM_ROWS = 6;
109    protected static final int MAX_NUM_ROWS = 6;
110
111    private static final int SELECTED_CIRCLE_ALPHA = 60;
112
113    protected static int DAY_SEPARATOR_WIDTH = 1;
114    protected static int MINI_DAY_NUMBER_TEXT_SIZE;
115    protected static int MONTH_LABEL_TEXT_SIZE;
116    protected static int MONTH_DAY_LABEL_TEXT_SIZE;
117    protected static int MONTH_HEADER_SIZE;
118    protected static int DAY_SELECTED_CIRCLE_SIZE;
119
120    // used for scaling to the device density
121    protected static float mScale = 0;
122
123    protected final DatePickerController mController;
124
125    // affects the padding on the sides of this view
126    protected int mPadding = 0;
127
128    private String mDayOfWeekTypeface;
129    private String mMonthTitleTypeface;
130
131    protected Paint mMonthNumPaint;
132    protected Paint mMonthTitlePaint;
133    protected Paint mMonthTitleBGPaint;
134    protected Paint mSelectedCirclePaint;
135    protected Paint mMonthDayLabelPaint;
136
137    private final Formatter mFormatter;
138    private final StringBuilder mStringBuilder;
139
140    // The Julian day of the first day displayed by this item
141    protected int mFirstJulianDay = -1;
142    // The month of the first day in this week
143    protected int mFirstMonth = -1;
144    // The month of the last day in this week
145    protected int mLastMonth = -1;
146
147    protected int mMonth;
148
149    protected int mYear;
150    // Quick reference to the width of this view, matches parent
151    protected int mWidth;
152    // The height this view should draw at in pixels, set by height param
153    protected int mRowHeight = DEFAULT_HEIGHT;
154    // If this view contains the today
155    protected boolean mHasToday = false;
156    // Which day is selected [0-6] or -1 if no day is selected
157    protected int mSelectedDay = -1;
158    // Which day is today [0-6] or -1 if no day is today
159    protected int mToday = DEFAULT_SELECTED_DAY;
160    // Which day of the week to start on [0-6]
161    protected int mWeekStart = DEFAULT_WEEK_START;
162    // How many days to display
163    protected int mNumDays = DEFAULT_NUM_DAYS;
164    // The number of days + a spot for week number if it is displayed
165    protected int mNumCells = mNumDays;
166    // The left edge of the selected day
167    protected int mSelectedLeft = -1;
168    // The right edge of the selected day
169    protected int mSelectedRight = -1;
170
171    private final Calendar mCalendar;
172    protected final Calendar mDayLabelCalendar;
173    private final MonthViewTouchHelper mTouchHelper;
174
175    private int mNumRows = DEFAULT_NUM_ROWS;
176
177    // Optional listener for handling day click actions
178    private OnDayClickListener mOnDayClickListener;
179    // Whether to prevent setting the accessibility delegate
180    private boolean mLockAccessibilityDelegate;
181
182    protected int mDayTextColor;
183    protected int mTodayNumberColor;
184    protected int mDisabledDayTextColor;
185    protected int mMonthTitleColor;
186    protected int mMonthTitleBGColor;
187
188    public MonthView(Context context) {
189        this(context, null);
190    }
191
192    public MonthView(Context context, DatePickerController controller) {
193        super(context);
194
195        mController = controller;
196
197        Resources res = context.getResources();
198
199        mDayLabelCalendar = Calendar.getInstance();
200        mCalendar = Calendar.getInstance();
201
202        mDayOfWeekTypeface = res.getString(R.string.day_of_week_label_typeface);
203        mMonthTitleTypeface = res.getString(R.string.sans_serif);
204
205        mDayTextColor = res.getColor(R.color.date_picker_text_normal);
206        mTodayNumberColor = res.getColor(R.color.blue);
207        mDisabledDayTextColor = res.getColor(R.color.date_picker_text_disabled);
208        mMonthTitleColor = res.getColor(R.color.white);
209        mMonthTitleBGColor = res.getColor(R.color.circle_background);
210
211        mStringBuilder = new StringBuilder(50);
212        mFormatter = new Formatter(mStringBuilder, Locale.getDefault());
213
214        MINI_DAY_NUMBER_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.day_number_size);
215        MONTH_LABEL_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.month_label_size);
216        MONTH_DAY_LABEL_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.month_day_label_text_size);
217        MONTH_HEADER_SIZE = res.getDimensionPixelOffset(R.dimen.month_list_item_header_height);
218        DAY_SELECTED_CIRCLE_SIZE = res
219                .getDimensionPixelSize(R.dimen.day_number_select_circle_radius);
220
221        mRowHeight = (res.getDimensionPixelOffset(R.dimen.date_picker_view_animator_height)
222                - MONTH_HEADER_SIZE) / MAX_NUM_ROWS;
223
224        // Set up accessibility components.
225        mTouchHelper = getMonthViewTouchHelper();
226        ViewCompat.setAccessibilityDelegate(this, mTouchHelper);
227        ViewCompat.setImportantForAccessibility(this, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
228        mLockAccessibilityDelegate = true;
229
230        // Sets up any standard paints that will be used
231        initView();
232    }
233
234    protected MonthViewTouchHelper getMonthViewTouchHelper() {
235        return new MonthViewTouchHelper(this);
236    }
237
238    @Override
239    public void setAccessibilityDelegate(AccessibilityDelegate delegate) {
240        // Workaround for a JB MR1 issue where accessibility delegates on
241        // top-level ListView items are overwritten.
242        if (!mLockAccessibilityDelegate) {
243            super.setAccessibilityDelegate(delegate);
244        }
245    }
246
247    public void setOnDayClickListener(OnDayClickListener listener) {
248        mOnDayClickListener = listener;
249    }
250
251    @Override
252    public boolean dispatchHoverEvent(MotionEvent event) {
253        // First right-of-refusal goes the touch exploration helper.
254        if (mTouchHelper.dispatchHoverEvent(event)) {
255            return true;
256        }
257        return super.dispatchHoverEvent(event);
258    }
259
260    @Override
261    public boolean onTouchEvent(MotionEvent event) {
262        switch (event.getAction()) {
263            case MotionEvent.ACTION_UP:
264                final int day = getDayFromLocation(event.getX(), event.getY());
265                if (day >= 0) {
266                    onDayClick(day);
267                }
268                break;
269        }
270        return true;
271    }
272
273    /**
274     * Sets up the text and style properties for painting. Override this if you
275     * want to use a different paint.
276     */
277    protected void initView() {
278        mMonthTitlePaint = new Paint();
279        mMonthTitlePaint.setFakeBoldText(true);
280        mMonthTitlePaint.setAntiAlias(true);
281        mMonthTitlePaint.setTextSize(MONTH_LABEL_TEXT_SIZE);
282        mMonthTitlePaint.setTypeface(Typeface.create(mMonthTitleTypeface, Typeface.BOLD));
283        mMonthTitlePaint.setColor(mDayTextColor);
284        mMonthTitlePaint.setTextAlign(Align.CENTER);
285        mMonthTitlePaint.setStyle(Style.FILL);
286
287        mMonthTitleBGPaint = new Paint();
288        mMonthTitleBGPaint.setFakeBoldText(true);
289        mMonthTitleBGPaint.setAntiAlias(true);
290        mMonthTitleBGPaint.setColor(mMonthTitleBGColor);
291        mMonthTitleBGPaint.setTextAlign(Align.CENTER);
292        mMonthTitleBGPaint.setStyle(Style.FILL);
293
294        mSelectedCirclePaint = new Paint();
295        mSelectedCirclePaint.setFakeBoldText(true);
296        mSelectedCirclePaint.setAntiAlias(true);
297        mSelectedCirclePaint.setColor(mTodayNumberColor);
298        mSelectedCirclePaint.setTextAlign(Align.CENTER);
299        mSelectedCirclePaint.setStyle(Style.FILL);
300        mSelectedCirclePaint.setAlpha(SELECTED_CIRCLE_ALPHA);
301
302        mMonthDayLabelPaint = new Paint();
303        mMonthDayLabelPaint.setAntiAlias(true);
304        mMonthDayLabelPaint.setTextSize(MONTH_DAY_LABEL_TEXT_SIZE);
305        mMonthDayLabelPaint.setColor(mDayTextColor);
306        mMonthDayLabelPaint.setTypeface(Typeface.create(mDayOfWeekTypeface, Typeface.NORMAL));
307        mMonthDayLabelPaint.setStyle(Style.FILL);
308        mMonthDayLabelPaint.setTextAlign(Align.CENTER);
309        mMonthDayLabelPaint.setFakeBoldText(true);
310
311        mMonthNumPaint = new Paint();
312        mMonthNumPaint.setAntiAlias(true);
313        mMonthNumPaint.setTextSize(MINI_DAY_NUMBER_TEXT_SIZE);
314        mMonthNumPaint.setStyle(Style.FILL);
315        mMonthNumPaint.setTextAlign(Align.CENTER);
316        mMonthNumPaint.setFakeBoldText(false);
317    }
318
319    @Override
320    protected void onDraw(Canvas canvas) {
321        drawMonthTitle(canvas);
322        drawMonthDayLabels(canvas);
323        drawMonthNums(canvas);
324    }
325
326    private int mDayOfWeekStart = 0;
327
328    /**
329     * Sets all the parameters for displaying this week. The only required
330     * parameter is the week number. Other parameters have a default value and
331     * will only update if a new value is included, except for focus month,
332     * which will always default to no focus month if no value is passed in. See
333     * {@link #VIEW_PARAMS_HEIGHT} for more info on parameters.
334     *
335     * @param params A map of the new parameters, see
336     *            {@link #VIEW_PARAMS_HEIGHT}
337     */
338    public void setMonthParams(HashMap<String, Integer> params) {
339        if (!params.containsKey(VIEW_PARAMS_MONTH) && !params.containsKey(VIEW_PARAMS_YEAR)) {
340            throw new InvalidParameterException("You must specify month and year for this view");
341        }
342        setTag(params);
343        // We keep the current value for any params not present
344        if (params.containsKey(VIEW_PARAMS_HEIGHT)) {
345            mRowHeight = params.get(VIEW_PARAMS_HEIGHT);
346            if (mRowHeight < MIN_HEIGHT) {
347                mRowHeight = MIN_HEIGHT;
348            }
349        }
350        if (params.containsKey(VIEW_PARAMS_SELECTED_DAY)) {
351            mSelectedDay = params.get(VIEW_PARAMS_SELECTED_DAY);
352        }
353
354        // Allocate space for caching the day numbers and focus values
355        mMonth = params.get(VIEW_PARAMS_MONTH);
356        mYear = params.get(VIEW_PARAMS_YEAR);
357
358        // Figure out what day today is
359        final Time today = new Time(Time.getCurrentTimezone());
360        today.setToNow();
361        mHasToday = false;
362        mToday = -1;
363
364        mCalendar.set(Calendar.MONTH, mMonth);
365        mCalendar.set(Calendar.YEAR, mYear);
366        mCalendar.set(Calendar.DAY_OF_MONTH, 1);
367        mDayOfWeekStart = mCalendar.get(Calendar.DAY_OF_WEEK);
368
369        if (params.containsKey(VIEW_PARAMS_WEEK_START)) {
370            mWeekStart = params.get(VIEW_PARAMS_WEEK_START);
371        } else {
372            mWeekStart = mCalendar.getFirstDayOfWeek();
373        }
374
375        mNumCells = Utils.getDaysInMonth(mMonth, mYear);
376        for (int i = 0; i < mNumCells; i++) {
377            final int day = i + 1;
378            if (sameDay(day, today)) {
379                mHasToday = true;
380                mToday = day;
381            }
382        }
383        mNumRows = calculateNumRows();
384
385        // Invalidate cached accessibility information.
386        mTouchHelper.invalidateRoot();
387    }
388
389    public void setSelectedDay(int day) {
390        mSelectedDay = day;
391    }
392
393    public void reuse() {
394        mNumRows = DEFAULT_NUM_ROWS;
395        requestLayout();
396    }
397
398    private int calculateNumRows() {
399        int offset = findDayOffset();
400        int dividend = (offset + mNumCells) / mNumDays;
401        int remainder = (offset + mNumCells) % mNumDays;
402        return (dividend + (remainder > 0 ? 1 : 0));
403    }
404
405    private boolean sameDay(int day, Time today) {
406        return mYear == today.year &&
407                mMonth == today.month &&
408                day == today.monthDay;
409    }
410
411    @Override
412    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
413        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mRowHeight * mNumRows
414                + MONTH_HEADER_SIZE);
415    }
416
417    @Override
418    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
419        mWidth = w;
420
421        // Invalidate cached accessibility information.
422        mTouchHelper.invalidateRoot();
423    }
424
425    public int getMonth() {
426        return mMonth;
427    }
428
429    public int getYear() {
430        return mYear;
431    }
432
433    private String getMonthAndYearString() {
434        int flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR
435                | DateUtils.FORMAT_NO_MONTH_DAY;
436        mStringBuilder.setLength(0);
437        long millis = mCalendar.getTimeInMillis();
438        return DateUtils.formatDateRange(getContext(), mFormatter, millis, millis, flags,
439                Time.getCurrentTimezone()).toString();
440    }
441
442    protected void drawMonthTitle(Canvas canvas) {
443        int x = (mWidth + 2 * mPadding) / 2;
444        int y = (MONTH_HEADER_SIZE - MONTH_DAY_LABEL_TEXT_SIZE) / 2 + (MONTH_LABEL_TEXT_SIZE / 3);
445        canvas.drawText(getMonthAndYearString(), x, y, mMonthTitlePaint);
446    }
447
448    protected void drawMonthDayLabels(Canvas canvas) {
449        int y = MONTH_HEADER_SIZE - (MONTH_DAY_LABEL_TEXT_SIZE / 2);
450        int dayWidthHalf = (mWidth - mPadding * 2) / (mNumDays * 2);
451
452        for (int i = 0; i < mNumDays; i++) {
453            int calendarDay = (i + mWeekStart) % mNumDays;
454            int x = (2 * i + 1) * dayWidthHalf + mPadding;
455            mDayLabelCalendar.set(Calendar.DAY_OF_WEEK, calendarDay);
456            canvas.drawText(mDayLabelCalendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT,
457                    Locale.getDefault()).toUpperCase(Locale.getDefault()), x, y,
458                    mMonthDayLabelPaint);
459        }
460    }
461
462    /**
463     * Draws the week and month day numbers for this week. Override this method
464     * if you need different placement.
465     *
466     * @param canvas The canvas to draw on
467     */
468    protected void drawMonthNums(Canvas canvas) {
469        int y = (((mRowHeight + MINI_DAY_NUMBER_TEXT_SIZE) / 2) - DAY_SEPARATOR_WIDTH)
470                + MONTH_HEADER_SIZE;
471        int dayWidthHalf = (mWidth - mPadding * 2) / (mNumDays * 2);
472        int j = findDayOffset();
473        for (int dayNumber = 1; dayNumber <= mNumCells; dayNumber++) {
474            int x = (2 * j + 1) * dayWidthHalf + mPadding;
475
476            int yRelativeToDay = (mRowHeight + MINI_DAY_NUMBER_TEXT_SIZE) / 2 - DAY_SEPARATOR_WIDTH;
477
478            int startX = x - dayWidthHalf;
479            int stopX = x + dayWidthHalf;
480            int startY = y - yRelativeToDay;
481            int stopY = startY + mRowHeight;
482
483            drawMonthDay(canvas, mYear, mMonth, dayNumber, x, y, startX, stopX, startY, stopY);
484
485            j++;
486            if (j == mNumDays) {
487                j = 0;
488                y += mRowHeight;
489            }
490        }
491    }
492
493    /**
494     * This method should draw the month day.  Implemented by sub-classes to allow customization.
495     *
496     * @param canvas  The canvas to draw on
497     * @param year  The year of this month day
498     * @param month  The month of this month day
499     * @param day  The day number of this month day
500     * @param x  The default x position to draw the day number
501     * @param y  The default y position to draw the day number
502     * @param startX  The left boundary of the day number rect
503     * @param stopX  The right boundary of the day number rect
504     * @param startY  The top boundary of the day number rect
505     * @param stopY  The bottom boundary of the day number rect
506     */
507    public abstract void drawMonthDay(Canvas canvas, int year, int month, int day,
508            int x, int y, int startX, int stopX, int startY, int stopY);
509
510    protected int findDayOffset() {
511        return (mDayOfWeekStart < mWeekStart ? (mDayOfWeekStart + mNumDays) : mDayOfWeekStart)
512                - mWeekStart;
513    }
514
515
516    /**
517     * Calculates the day that the given x position is in, accounting for week
518     * number. Returns the day or -1 if the position wasn't in a day.
519     *
520     * @param x The x position of the touch event
521     * @return The day number, or -1 if the position wasn't in a day
522     */
523    public int getDayFromLocation(float x, float y) {
524        int dayStart = mPadding;
525        if (x < dayStart || x > mWidth - mPadding) {
526            return -1;
527        }
528        // Selection is (x - start) / (pixels/day) == (x -s) * day / pixels
529        int row = (int) (y - MONTH_HEADER_SIZE) / mRowHeight;
530        int column = (int) ((x - dayStart) * mNumDays / (mWidth - dayStart - mPadding));
531
532        int day = column - findDayOffset() + 1;
533        day += row * mNumDays;
534        if (day < 1 || day > mNumCells) {
535            return -1;
536        }
537        return day;
538    }
539
540    /**
541     * Called when the user clicks on a day. Handles callbacks to the
542     * {@link OnDayClickListener} if one is set.
543     * <p/>
544     * If the day is out of the range set by minDate and/or maxDate, this is a no-op.
545     *
546     * @param day The day that was clicked
547     */
548    private void onDayClick(int day) {
549        // If the min / max date are set, only process the click if it's a valid selection.
550        if (isOutOfRange(mYear, mMonth, day)) {
551            return;
552        }
553
554
555        if (mOnDayClickListener != null) {
556            mOnDayClickListener.onDayClick(this, new CalendarDay(mYear, mMonth, day));
557        }
558
559        // This is a no-op if accessibility is turned off.
560        mTouchHelper.sendEventForVirtualView(day, AccessibilityEvent.TYPE_VIEW_CLICKED);
561    }
562
563    /**
564     * @return true if the specified year/month/day are within the range set by minDate and maxDate.
565     * If one or either have not been set, they are considered as Integer.MIN_VALUE and
566     * Integer.MAX_VALUE.
567     */
568    protected boolean isOutOfRange(int year, int month, int day) {
569        if (isBeforeMin(year, month, day)) {
570            return true;
571        } else if (isAfterMax(year, month, day)) {
572            return true;
573        }
574
575        return false;
576    }
577
578    private boolean isBeforeMin(int year, int month, int day) {
579        Calendar minDate = mController.getMinDate();
580        if (minDate == null) {
581            return false;
582        }
583
584        if (year < minDate.get(Calendar.YEAR)) {
585            return true;
586        } else if (year > minDate.get(Calendar.YEAR)) {
587            return false;
588        }
589
590        if (month < minDate.get(Calendar.MONTH)) {
591            return true;
592        } else if (month > minDate.get(Calendar.MONTH)) {
593            return false;
594        }
595
596        if (day < minDate.get(Calendar.DAY_OF_MONTH)) {
597            return true;
598        } else {
599            return false;
600        }
601    }
602
603    private boolean isAfterMax(int year, int month, int day) {
604        Calendar maxDate = mController.getMaxDate();
605        if (maxDate == null) {
606            return false;
607        }
608
609        if (year > maxDate.get(Calendar.YEAR)) {
610            return true;
611        } else if (year < maxDate.get(Calendar.YEAR)) {
612            return false;
613        }
614
615        if (month > maxDate.get(Calendar.MONTH)) {
616            return true;
617        } else if (month < maxDate.get(Calendar.MONTH)) {
618            return false;
619        }
620
621        if (day > maxDate.get(Calendar.DAY_OF_MONTH)) {
622            return true;
623        } else {
624            return false;
625        }
626    }
627
628    /**
629     * @return The date that has accessibility focus, or {@code null} if no date
630     *         has focus
631     */
632    public CalendarDay getAccessibilityFocus() {
633        final int day = mTouchHelper.getFocusedVirtualView();
634        if (day >= 0) {
635            return new CalendarDay(mYear, mMonth, day);
636        }
637        return null;
638    }
639
640    /**
641     * Clears accessibility focus within the view. No-op if the view does not
642     * contain accessibility focus.
643     */
644    public void clearAccessibilityFocus() {
645        mTouchHelper.clearFocusedVirtualView();
646    }
647
648    /**
649     * Attempts to restore accessibility focus to the specified date.
650     *
651     * @param day The date which should receive focus
652     * @return {@code false} if the date is not valid for this month view, or
653     *         {@code true} if the date received focus
654     */
655    public boolean restoreAccessibilityFocus(CalendarDay day) {
656        if ((day.year != mYear) || (day.month != mMonth) || (day.day > mNumCells)) {
657            return false;
658        }
659        mTouchHelper.setFocusedVirtualView(day.day);
660        return true;
661    }
662
663    /**
664     * Provides a virtual view hierarchy for interfacing with an accessibility
665     * service.
666     */
667    protected class MonthViewTouchHelper extends ExploreByTouchHelper {
668        private static final String DATE_FORMAT = "dd MMMM yyyy";
669
670        private final Rect mTempRect = new Rect();
671        private final Calendar mTempCalendar = Calendar.getInstance();
672
673        public MonthViewTouchHelper(View host) {
674            super(host);
675        }
676
677        public void setFocusedVirtualView(int virtualViewId) {
678            getAccessibilityNodeProvider(MonthView.this).performAction(
679                    virtualViewId, AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS, null);
680        }
681
682        public void clearFocusedVirtualView() {
683            final int focusedVirtualView = getFocusedVirtualView();
684            if (focusedVirtualView != ExploreByTouchHelper.INVALID_ID) {
685                getAccessibilityNodeProvider(MonthView.this).performAction(
686                        focusedVirtualView,
687                        AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS,
688                        null);
689            }
690        }
691
692        @Override
693        protected int getVirtualViewAt(float x, float y) {
694            final int day = getDayFromLocation(x, y);
695            if (day >= 0) {
696                return day;
697            }
698            return ExploreByTouchHelper.INVALID_ID;
699        }
700
701        @Override
702        protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
703            for (int day = 1; day <= mNumCells; day++) {
704                virtualViewIds.add(day);
705            }
706        }
707
708        @Override
709        protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
710            event.setContentDescription(getItemDescription(virtualViewId));
711        }
712
713        @Override
714        protected void onPopulateNodeForVirtualView(int virtualViewId,
715                AccessibilityNodeInfoCompat node) {
716            getItemBounds(virtualViewId, mTempRect);
717
718            node.setContentDescription(getItemDescription(virtualViewId));
719            node.setBoundsInParent(mTempRect);
720            node.addAction(AccessibilityNodeInfo.ACTION_CLICK);
721
722            if (virtualViewId == mSelectedDay) {
723                node.setSelected(true);
724            }
725
726        }
727
728        @Override
729        protected boolean onPerformActionForVirtualView(int virtualViewId, int action,
730                Bundle arguments) {
731            switch (action) {
732                case AccessibilityNodeInfo.ACTION_CLICK:
733                    onDayClick(virtualViewId);
734                    return true;
735            }
736
737            return false;
738        }
739
740        /**
741         * Calculates the bounding rectangle of a given time object.
742         *
743         * @param day The day to calculate bounds for
744         * @param rect The rectangle in which to store the bounds
745         */
746        protected void getItemBounds(int day, Rect rect) {
747            final int offsetX = mPadding;
748            final int offsetY = MONTH_HEADER_SIZE;
749            final int cellHeight = mRowHeight;
750            final int cellWidth = ((mWidth - (2 * mPadding)) / mNumDays);
751            final int index = ((day - 1) + findDayOffset());
752            final int row = (index / mNumDays);
753            final int column = (index % mNumDays);
754            final int x = (offsetX + (column * cellWidth));
755            final int y = (offsetY + (row * cellHeight));
756
757            rect.set(x, y, (x + cellWidth), (y + cellHeight));
758        }
759
760        /**
761         * Generates a description for a given time object. Since this
762         * description will be spoken, the components are ordered by descending
763         * specificity as DAY MONTH YEAR.
764         *
765         * @param day The day to generate a description for
766         * @return A description of the time object
767         */
768        protected CharSequence getItemDescription(int day) {
769            mTempCalendar.set(mYear, mMonth, day);
770            final CharSequence date = DateFormat.format(DATE_FORMAT,
771                    mTempCalendar.getTimeInMillis());
772
773            if (day == mSelectedDay) {
774                return getContext().getString(R.string.item_is_selected, date);
775            }
776
777            return date;
778        }
779    }
780
781    /**
782     * Handles callbacks when the user clicks on a time object.
783     */
784    public interface OnDayClickListener {
785        public void onDayClick(MonthView view, CalendarDay day);
786    }
787}
788