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