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