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