• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2014 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.widget;
18 
19 import android.annotation.Nullable;
20 import android.content.Context;
21 import android.content.res.ColorStateList;
22 import android.content.res.Resources;
23 import android.content.res.TypedArray;
24 import android.graphics.Canvas;
25 import android.graphics.Paint;
26 import android.graphics.Paint.Align;
27 import android.graphics.Paint.Style;
28 import android.graphics.Rect;
29 import android.graphics.Typeface;
30 import android.icu.text.DisplayContext;
31 import android.icu.text.SimpleDateFormat;
32 import android.icu.util.Calendar;
33 import android.os.Bundle;
34 import android.text.TextPaint;
35 import android.text.format.DateFormat;
36 import android.util.AttributeSet;
37 import android.util.IntArray;
38 import android.util.MathUtils;
39 import android.util.StateSet;
40 import android.view.KeyEvent;
41 import android.view.MotionEvent;
42 import android.view.PointerIcon;
43 import android.view.View;
44 import android.view.ViewParent;
45 import android.view.accessibility.AccessibilityEvent;
46 import android.view.accessibility.AccessibilityNodeInfo;
47 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
48 
49 import com.android.internal.R;
50 import com.android.internal.widget.ExploreByTouchHelper;
51 
52 import libcore.icu.LocaleData;
53 
54 import java.text.NumberFormat;
55 import java.util.Locale;
56 
57 /**
58  * A calendar-like view displaying a specified month and the appropriate selectable day numbers
59  * within the specified month.
60  */
61 class SimpleMonthView extends View {
62     private static final int DAYS_IN_WEEK = 7;
63     private static final int MAX_WEEKS_IN_MONTH = 6;
64 
65     private static final int DEFAULT_SELECTED_DAY = -1;
66     private static final int DEFAULT_WEEK_START = Calendar.SUNDAY;
67 
68     private static final String MONTH_YEAR_FORMAT = "MMMMy";
69 
70     private static final int SELECTED_HIGHLIGHT_ALPHA = 0xB0;
71 
72     private final TextPaint mMonthPaint = new TextPaint();
73     private final TextPaint mDayOfWeekPaint = new TextPaint();
74     private final TextPaint mDayPaint = new TextPaint();
75     private final Paint mDaySelectorPaint = new Paint();
76     private final Paint mDayHighlightPaint = new Paint();
77     private final Paint mDayHighlightSelectorPaint = new Paint();
78 
79     /** Array of single-character weekday labels ordered by column index. */
80     private final String[] mDayOfWeekLabels = new String[7];
81 
82     private final Calendar mCalendar;
83     private final Locale mLocale;
84 
85     private final MonthViewTouchHelper mTouchHelper;
86 
87     private final NumberFormat mDayFormatter;
88 
89     // Desired dimensions.
90     private final int mDesiredMonthHeight;
91     private final int mDesiredDayOfWeekHeight;
92     private final int mDesiredDayHeight;
93     private final int mDesiredCellWidth;
94     private final int mDesiredDaySelectorRadius;
95 
96     private String mMonthYearLabel;
97 
98     private int mMonth;
99     private int mYear;
100 
101     // Dimensions as laid out.
102     private int mMonthHeight;
103     private int mDayOfWeekHeight;
104     private int mDayHeight;
105     private int mCellWidth;
106     private int mDaySelectorRadius;
107 
108     private int mPaddedWidth;
109     private int mPaddedHeight;
110 
111     /** The day of month for the selected day, or -1 if no day is selected. */
112     private int mActivatedDay = -1;
113 
114     /**
115      * The day of month for today, or -1 if the today is not in the current
116      * month.
117      */
118     private int mToday = DEFAULT_SELECTED_DAY;
119 
120     /** The first day of the week (ex. Calendar.SUNDAY) indexed from one. */
121     private int mWeekStart = DEFAULT_WEEK_START;
122 
123     /** The number of days (ex. 28) in the current month. */
124     private int mDaysInMonth;
125 
126     /**
127      * The day of week (ex. Calendar.SUNDAY) for the first day of the current
128      * month.
129      */
130     private int mDayOfWeekStart;
131 
132     /** The day of month for the first (inclusive) enabled day. */
133     private int mEnabledDayStart = 1;
134 
135     /** The day of month for the last (inclusive) enabled day. */
136     private int mEnabledDayEnd = 31;
137 
138     /** Optional listener for handling day click actions. */
139     private OnDayClickListener mOnDayClickListener;
140 
141     private ColorStateList mDayTextColor;
142 
143     private int mHighlightedDay = -1;
144     private int mPreviouslyHighlightedDay = -1;
145     private boolean mIsTouchHighlighted = false;
146 
SimpleMonthView(Context context)147     public SimpleMonthView(Context context) {
148         this(context, null);
149     }
150 
SimpleMonthView(Context context, AttributeSet attrs)151     public SimpleMonthView(Context context, AttributeSet attrs) {
152         this(context, attrs, R.attr.datePickerStyle);
153     }
154 
SimpleMonthView(Context context, AttributeSet attrs, int defStyleAttr)155     public SimpleMonthView(Context context, AttributeSet attrs, int defStyleAttr) {
156         this(context, attrs, defStyleAttr, 0);
157     }
158 
SimpleMonthView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)159     public SimpleMonthView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
160         super(context, attrs, defStyleAttr, defStyleRes);
161 
162         final Resources res = context.getResources();
163         mDesiredMonthHeight = res.getDimensionPixelSize(R.dimen.date_picker_month_height);
164         mDesiredDayOfWeekHeight = res.getDimensionPixelSize(R.dimen.date_picker_day_of_week_height);
165         mDesiredDayHeight = res.getDimensionPixelSize(R.dimen.date_picker_day_height);
166         mDesiredCellWidth = res.getDimensionPixelSize(R.dimen.date_picker_day_width);
167         mDesiredDaySelectorRadius = res.getDimensionPixelSize(
168                 R.dimen.date_picker_day_selector_radius);
169 
170         // Set up accessibility components.
171         mTouchHelper = new MonthViewTouchHelper(this);
172         setAccessibilityDelegate(mTouchHelper);
173         setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
174 
175         mLocale = res.getConfiguration().locale;
176         mCalendar = Calendar.getInstance(mLocale);
177 
178         mDayFormatter = NumberFormat.getIntegerInstance(mLocale);
179 
180         updateMonthYearLabel();
181         updateDayOfWeekLabels();
182 
183         initPaints(res);
184     }
185 
updateMonthYearLabel()186     private void updateMonthYearLabel() {
187         final String format = DateFormat.getBestDateTimePattern(mLocale, MONTH_YEAR_FORMAT);
188         final SimpleDateFormat formatter = new SimpleDateFormat(format, mLocale);
189         formatter.setContext(DisplayContext.CAPITALIZATION_FOR_STANDALONE);
190         mMonthYearLabel = formatter.format(mCalendar.getTime());
191     }
192 
updateDayOfWeekLabels()193     private void updateDayOfWeekLabels() {
194         // Use tiny (e.g. single-character) weekday names from ICU. The indices
195         // for this list correspond to Calendar days, e.g. SUNDAY is index 1.
196         final String[] tinyWeekdayNames = LocaleData.get(mLocale).tinyWeekdayNames;
197         for (int i = 0; i < DAYS_IN_WEEK; i++) {
198             mDayOfWeekLabels[i] = tinyWeekdayNames[(mWeekStart + i - 1) % DAYS_IN_WEEK + 1];
199         }
200     }
201 
202     /**
203      * Applies the specified text appearance resource to a paint, returning the
204      * text color if one is set in the text appearance.
205      *
206      * @param p the paint to modify
207      * @param resId the resource ID of the text appearance
208      * @return the text color, if available
209      */
applyTextAppearance(Paint p, int resId)210     private ColorStateList applyTextAppearance(Paint p, int resId) {
211         final TypedArray ta = mContext.obtainStyledAttributes(null,
212                 R.styleable.TextAppearance, 0, resId);
213 
214         final String fontFamily = ta.getString(R.styleable.TextAppearance_fontFamily);
215         if (fontFamily != null) {
216             p.setTypeface(Typeface.create(fontFamily, 0));
217         }
218 
219         p.setTextSize(ta.getDimensionPixelSize(
220                 R.styleable.TextAppearance_textSize, (int) p.getTextSize()));
221 
222         final ColorStateList textColor = ta.getColorStateList(R.styleable.TextAppearance_textColor);
223         if (textColor != null) {
224             final int enabledColor = textColor.getColorForState(ENABLED_STATE_SET, 0);
225             p.setColor(enabledColor);
226         }
227 
228         ta.recycle();
229 
230         return textColor;
231     }
232 
getMonthHeight()233     public int getMonthHeight() {
234         return mMonthHeight;
235     }
236 
getCellWidth()237     public int getCellWidth() {
238         return mCellWidth;
239     }
240 
setMonthTextAppearance(int resId)241     public void setMonthTextAppearance(int resId) {
242         applyTextAppearance(mMonthPaint, resId);
243 
244         invalidate();
245     }
246 
setDayOfWeekTextAppearance(int resId)247     public void setDayOfWeekTextAppearance(int resId) {
248         applyTextAppearance(mDayOfWeekPaint, resId);
249         invalidate();
250     }
251 
setDayTextAppearance(int resId)252     public void setDayTextAppearance(int resId) {
253         final ColorStateList textColor = applyTextAppearance(mDayPaint, resId);
254         if (textColor != null) {
255             mDayTextColor = textColor;
256         }
257 
258         invalidate();
259     }
260 
261     /**
262      * Sets up the text and style properties for painting.
263      */
initPaints(Resources res)264     private void initPaints(Resources res) {
265         final String monthTypeface = res.getString(R.string.date_picker_month_typeface);
266         final String dayOfWeekTypeface = res.getString(R.string.date_picker_day_of_week_typeface);
267         final String dayTypeface = res.getString(R.string.date_picker_day_typeface);
268 
269         final int monthTextSize = res.getDimensionPixelSize(
270                 R.dimen.date_picker_month_text_size);
271         final int dayOfWeekTextSize = res.getDimensionPixelSize(
272                 R.dimen.date_picker_day_of_week_text_size);
273         final int dayTextSize = res.getDimensionPixelSize(
274                 R.dimen.date_picker_day_text_size);
275 
276         mMonthPaint.setAntiAlias(true);
277         mMonthPaint.setTextSize(monthTextSize);
278         mMonthPaint.setTypeface(Typeface.create(monthTypeface, 0));
279         mMonthPaint.setTextAlign(Align.CENTER);
280         mMonthPaint.setStyle(Style.FILL);
281 
282         mDayOfWeekPaint.setAntiAlias(true);
283         mDayOfWeekPaint.setTextSize(dayOfWeekTextSize);
284         mDayOfWeekPaint.setTypeface(Typeface.create(dayOfWeekTypeface, 0));
285         mDayOfWeekPaint.setTextAlign(Align.CENTER);
286         mDayOfWeekPaint.setStyle(Style.FILL);
287 
288         mDaySelectorPaint.setAntiAlias(true);
289         mDaySelectorPaint.setStyle(Style.FILL);
290 
291         mDayHighlightPaint.setAntiAlias(true);
292         mDayHighlightPaint.setStyle(Style.FILL);
293 
294         mDayHighlightSelectorPaint.setAntiAlias(true);
295         mDayHighlightSelectorPaint.setStyle(Style.FILL);
296 
297         mDayPaint.setAntiAlias(true);
298         mDayPaint.setTextSize(dayTextSize);
299         mDayPaint.setTypeface(Typeface.create(dayTypeface, 0));
300         mDayPaint.setTextAlign(Align.CENTER);
301         mDayPaint.setStyle(Style.FILL);
302     }
303 
setMonthTextColor(ColorStateList monthTextColor)304     void setMonthTextColor(ColorStateList monthTextColor) {
305         final int enabledColor = monthTextColor.getColorForState(ENABLED_STATE_SET, 0);
306         mMonthPaint.setColor(enabledColor);
307         invalidate();
308     }
309 
setDayOfWeekTextColor(ColorStateList dayOfWeekTextColor)310     void setDayOfWeekTextColor(ColorStateList dayOfWeekTextColor) {
311         final int enabledColor = dayOfWeekTextColor.getColorForState(ENABLED_STATE_SET, 0);
312         mDayOfWeekPaint.setColor(enabledColor);
313         invalidate();
314     }
315 
setDayTextColor(ColorStateList dayTextColor)316     void setDayTextColor(ColorStateList dayTextColor) {
317         mDayTextColor = dayTextColor;
318         invalidate();
319     }
320 
setDaySelectorColor(ColorStateList dayBackgroundColor)321     void setDaySelectorColor(ColorStateList dayBackgroundColor) {
322         final int activatedColor = dayBackgroundColor.getColorForState(
323                 StateSet.get(StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED), 0);
324         mDaySelectorPaint.setColor(activatedColor);
325         mDayHighlightSelectorPaint.setColor(activatedColor);
326         mDayHighlightSelectorPaint.setAlpha(SELECTED_HIGHLIGHT_ALPHA);
327         invalidate();
328     }
329 
setDayHighlightColor(ColorStateList dayHighlightColor)330     void setDayHighlightColor(ColorStateList dayHighlightColor) {
331         final int pressedColor = dayHighlightColor.getColorForState(
332                 StateSet.get(StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_PRESSED), 0);
333         mDayHighlightPaint.setColor(pressedColor);
334         invalidate();
335     }
336 
setOnDayClickListener(OnDayClickListener listener)337     public void setOnDayClickListener(OnDayClickListener listener) {
338         mOnDayClickListener = listener;
339     }
340 
341     @Override
dispatchHoverEvent(MotionEvent event)342     public boolean dispatchHoverEvent(MotionEvent event) {
343         // First right-of-refusal goes the touch exploration helper.
344         return mTouchHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event);
345     }
346 
347     @Override
onTouchEvent(MotionEvent event)348     public boolean onTouchEvent(MotionEvent event) {
349         final int x = (int) (event.getX() + 0.5f);
350         final int y = (int) (event.getY() + 0.5f);
351 
352         final int action = event.getAction();
353         switch (action) {
354             case MotionEvent.ACTION_DOWN:
355             case MotionEvent.ACTION_MOVE:
356                 final int touchedItem = getDayAtLocation(x, y);
357                 mIsTouchHighlighted = true;
358                 if (mHighlightedDay != touchedItem) {
359                     mHighlightedDay = touchedItem;
360                     mPreviouslyHighlightedDay = touchedItem;
361                     invalidate();
362                 }
363                 if (action == MotionEvent.ACTION_DOWN && touchedItem < 0) {
364                     // Touch something that's not an item, reject event.
365                     return false;
366                 }
367                 break;
368 
369             case MotionEvent.ACTION_UP:
370                 final int clickedDay = getDayAtLocation(x, y);
371                 onDayClicked(clickedDay);
372                 // Fall through.
373             case MotionEvent.ACTION_CANCEL:
374                 // Reset touched day on stream end.
375                 mHighlightedDay = -1;
376                 mIsTouchHighlighted = false;
377                 invalidate();
378                 break;
379         }
380         return true;
381     }
382 
383     @Override
onKeyDown(int keyCode, KeyEvent event)384     public boolean onKeyDown(int keyCode, KeyEvent event) {
385         // We need to handle focus change within the SimpleMonthView because we are simulating
386         // multiple Views. The arrow keys will move between days until there is no space (no
387         // day to the left, top, right, or bottom). Focus forward and back jumps out of the
388         // SimpleMonthView, skipping over other SimpleMonthViews in the parent ViewPager
389         // to the next focusable View in the hierarchy.
390         boolean focusChanged = false;
391         switch (event.getKeyCode()) {
392             case KeyEvent.KEYCODE_DPAD_LEFT:
393                 if (event.hasNoModifiers()) {
394                     focusChanged = moveOneDay(isLayoutRtl());
395                 }
396                 break;
397             case KeyEvent.KEYCODE_DPAD_RIGHT:
398                 if (event.hasNoModifiers()) {
399                     focusChanged = moveOneDay(!isLayoutRtl());
400                 }
401                 break;
402             case KeyEvent.KEYCODE_DPAD_UP:
403                 if (event.hasNoModifiers()) {
404                     ensureFocusedDay();
405                     if (mHighlightedDay > 7) {
406                         mHighlightedDay -= 7;
407                         focusChanged = true;
408                     }
409                 }
410                 break;
411             case KeyEvent.KEYCODE_DPAD_DOWN:
412                 if (event.hasNoModifiers()) {
413                     ensureFocusedDay();
414                     if (mHighlightedDay <= mDaysInMonth - 7) {
415                         mHighlightedDay += 7;
416                         focusChanged = true;
417                     }
418                 }
419                 break;
420             case KeyEvent.KEYCODE_DPAD_CENTER:
421             case KeyEvent.KEYCODE_ENTER:
422                 if (mHighlightedDay != -1) {
423                     onDayClicked(mHighlightedDay);
424                     return true;
425                 }
426                 break;
427             case KeyEvent.KEYCODE_TAB: {
428                 int focusChangeDirection = 0;
429                 if (event.hasNoModifiers()) {
430                     focusChangeDirection = View.FOCUS_FORWARD;
431                 } else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {
432                     focusChangeDirection = View.FOCUS_BACKWARD;
433                 }
434                 if (focusChangeDirection != 0) {
435                     final ViewParent parent = getParent();
436                     // move out of the ViewPager next/previous
437                     View nextFocus = this;
438                     do {
439                         nextFocus = nextFocus.focusSearch(focusChangeDirection);
440                     } while (nextFocus != null && nextFocus != this &&
441                             nextFocus.getParent() == parent);
442                     if (nextFocus != null) {
443                         nextFocus.requestFocus();
444                         return true;
445                     }
446                 }
447                 break;
448             }
449         }
450         if (focusChanged) {
451             invalidate();
452             return true;
453         } else {
454             return super.onKeyDown(keyCode, event);
455         }
456     }
457 
moveOneDay(boolean positive)458     private boolean moveOneDay(boolean positive) {
459         ensureFocusedDay();
460         boolean focusChanged = false;
461         if (positive) {
462             if (!isLastDayOfWeek(mHighlightedDay) && mHighlightedDay < mDaysInMonth) {
463                 mHighlightedDay++;
464                 focusChanged = true;
465             }
466         } else {
467             if (!isFirstDayOfWeek(mHighlightedDay) && mHighlightedDay > 1) {
468                 mHighlightedDay--;
469                 focusChanged = true;
470             }
471         }
472         return focusChanged;
473     }
474 
475     @Override
onFocusChanged(boolean gainFocus, @FocusDirection int direction, @Nullable Rect previouslyFocusedRect)476     protected void onFocusChanged(boolean gainFocus, @FocusDirection int direction,
477             @Nullable Rect previouslyFocusedRect) {
478         if (gainFocus) {
479             // If we've gained focus through arrow keys, we should find the day closest
480             // to the focus rect. If we've gained focus through forward/back, we should
481             // focus on the selected day if there is one.
482             final int offset = findDayOffset();
483             switch(direction) {
484                 case View.FOCUS_RIGHT: {
485                     int row = findClosestRow(previouslyFocusedRect);
486                     mHighlightedDay = row == 0 ? 1 : (row * DAYS_IN_WEEK) - offset + 1;
487                     break;
488                 }
489                 case View.FOCUS_LEFT: {
490                     int row = findClosestRow(previouslyFocusedRect) + 1;
491                     mHighlightedDay = Math.min(mDaysInMonth, (row * DAYS_IN_WEEK) - offset);
492                     break;
493                 }
494                 case View.FOCUS_DOWN: {
495                     final int col = findClosestColumn(previouslyFocusedRect);
496                     final int day = col - offset + 1;
497                     mHighlightedDay = day < 1 ? day + DAYS_IN_WEEK : day;
498                     break;
499                 }
500                 case View.FOCUS_UP: {
501                     final int col = findClosestColumn(previouslyFocusedRect);
502                     final int maxWeeks = (offset + mDaysInMonth) / DAYS_IN_WEEK;
503                     final int day = col - offset + (DAYS_IN_WEEK * maxWeeks) + 1;
504                     mHighlightedDay = day > mDaysInMonth ? day - DAYS_IN_WEEK : day;
505                     break;
506                 }
507             }
508             ensureFocusedDay();
509             invalidate();
510         }
511         super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
512     }
513 
514     /**
515      * Returns the row (0 indexed) closest to previouslyFocusedRect or center if null.
516      */
findClosestRow(@ullable Rect previouslyFocusedRect)517     private int findClosestRow(@Nullable Rect previouslyFocusedRect) {
518         if (previouslyFocusedRect == null) {
519             return 3;
520         } else {
521             int centerY = previouslyFocusedRect.centerY();
522 
523             final TextPaint p = mDayPaint;
524             final int headerHeight = mMonthHeight + mDayOfWeekHeight;
525             final int rowHeight = mDayHeight;
526 
527             // Text is vertically centered within the row height.
528             final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
529             final int rowCenter = headerHeight + rowHeight / 2;
530 
531             centerY -= rowCenter - halfLineHeight;
532             int row = Math.round(centerY / (float) rowHeight);
533             final int maxDay = findDayOffset() + mDaysInMonth;
534             final int maxRows = (maxDay / DAYS_IN_WEEK) - ((maxDay % DAYS_IN_WEEK == 0) ? 1 : 0);
535 
536             row = MathUtils.constrain(row, 0, maxRows);
537             return row;
538         }
539     }
540 
541     /**
542      * Returns the column (0 indexed) closest to the previouslyFocusedRect or center if null.
543      * The 0 index is related to the first day of the week.
544      */
findClosestColumn(@ullable Rect previouslyFocusedRect)545     private int findClosestColumn(@Nullable Rect previouslyFocusedRect) {
546         if (previouslyFocusedRect == null) {
547             return DAYS_IN_WEEK / 2;
548         } else {
549             int centerX = previouslyFocusedRect.centerX() - mPaddingLeft;
550             final int columnFromLeft =
551                     MathUtils.constrain(centerX / mCellWidth, 0, DAYS_IN_WEEK - 1);
552             return isLayoutRtl() ? DAYS_IN_WEEK - columnFromLeft - 1: columnFromLeft;
553         }
554     }
555 
556     @Override
getFocusedRect(Rect r)557     public void getFocusedRect(Rect r) {
558         if (mHighlightedDay > 0) {
559             getBoundsForDay(mHighlightedDay, r);
560         } else {
561             super.getFocusedRect(r);
562         }
563     }
564 
565     @Override
onFocusLost()566     protected void onFocusLost() {
567         if (!mIsTouchHighlighted) {
568             // Unhighlight a day.
569             mPreviouslyHighlightedDay = mHighlightedDay;
570             mHighlightedDay = -1;
571             invalidate();
572         }
573         super.onFocusLost();
574     }
575 
576     /**
577      * Ensure some day is highlighted. If a day isn't highlighted, it chooses the selected day,
578      * if possible, or the first day of the month if not.
579      */
ensureFocusedDay()580     private void ensureFocusedDay() {
581         if (mHighlightedDay != -1) {
582             return;
583         }
584         if (mPreviouslyHighlightedDay != -1) {
585             mHighlightedDay = mPreviouslyHighlightedDay;
586             return;
587         }
588         if (mActivatedDay != -1) {
589             mHighlightedDay = mActivatedDay;
590             return;
591         }
592         mHighlightedDay = 1;
593     }
594 
isFirstDayOfWeek(int day)595     private boolean isFirstDayOfWeek(int day) {
596         final int offset = findDayOffset();
597         return (offset + day - 1) % DAYS_IN_WEEK == 0;
598     }
599 
isLastDayOfWeek(int day)600     private boolean isLastDayOfWeek(int day) {
601         final int offset = findDayOffset();
602         return (offset + day) % DAYS_IN_WEEK == 0;
603     }
604 
605     @Override
onDraw(Canvas canvas)606     protected void onDraw(Canvas canvas) {
607         final int paddingLeft = getPaddingLeft();
608         final int paddingTop = getPaddingTop();
609         canvas.translate(paddingLeft, paddingTop);
610 
611         drawMonth(canvas);
612         drawDaysOfWeek(canvas);
613         drawDays(canvas);
614 
615         canvas.translate(-paddingLeft, -paddingTop);
616     }
617 
drawMonth(Canvas canvas)618     private void drawMonth(Canvas canvas) {
619         final float x = mPaddedWidth / 2f;
620 
621         // Vertically centered within the month header height.
622         final float lineHeight = mMonthPaint.ascent() + mMonthPaint.descent();
623         final float y = (mMonthHeight - lineHeight) / 2f;
624 
625         canvas.drawText(mMonthYearLabel, x, y, mMonthPaint);
626     }
627 
getMonthYearLabel()628     public String getMonthYearLabel() {
629         return mMonthYearLabel;
630     }
631 
drawDaysOfWeek(Canvas canvas)632     private void drawDaysOfWeek(Canvas canvas) {
633         final TextPaint p = mDayOfWeekPaint;
634         final int headerHeight = mMonthHeight;
635         final int rowHeight = mDayOfWeekHeight;
636         final int colWidth = mCellWidth;
637 
638         // Text is vertically centered within the day of week height.
639         final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
640         final int rowCenter = headerHeight + rowHeight / 2;
641 
642         for (int col = 0; col < DAYS_IN_WEEK; col++) {
643             final int colCenter = colWidth * col + colWidth / 2;
644             final int colCenterRtl;
645             if (isLayoutRtl()) {
646                 colCenterRtl = mPaddedWidth - colCenter;
647             } else {
648                 colCenterRtl = colCenter;
649             }
650 
651             final String label = mDayOfWeekLabels[col];
652             canvas.drawText(label, colCenterRtl, rowCenter - halfLineHeight, p);
653         }
654     }
655 
656     /**
657      * Draws the month days.
658      */
drawDays(Canvas canvas)659     private void drawDays(Canvas canvas) {
660         final TextPaint p = mDayPaint;
661         final int headerHeight = mMonthHeight + mDayOfWeekHeight;
662         final int rowHeight = mDayHeight;
663         final int colWidth = mCellWidth;
664 
665         // Text is vertically centered within the row height.
666         final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
667         int rowCenter = headerHeight + rowHeight / 2;
668 
669         for (int day = 1, col = findDayOffset(); day <= mDaysInMonth; day++) {
670             final int colCenter = colWidth * col + colWidth / 2;
671             final int colCenterRtl;
672             if (isLayoutRtl()) {
673                 colCenterRtl = mPaddedWidth - colCenter;
674             } else {
675                 colCenterRtl = colCenter;
676             }
677 
678             int stateMask = 0;
679 
680             final boolean isDayEnabled = isDayEnabled(day);
681             if (isDayEnabled) {
682                 stateMask |= StateSet.VIEW_STATE_ENABLED;
683             }
684 
685             final boolean isDayActivated = mActivatedDay == day;
686             final boolean isDayHighlighted = mHighlightedDay == day;
687             if (isDayActivated) {
688                 stateMask |= StateSet.VIEW_STATE_ACTIVATED;
689 
690                 // Adjust the circle to be centered on the row.
691                 final Paint paint = isDayHighlighted ? mDayHighlightSelectorPaint :
692                         mDaySelectorPaint;
693                 canvas.drawCircle(colCenterRtl, rowCenter, mDaySelectorRadius, paint);
694             } else if (isDayHighlighted) {
695                 stateMask |= StateSet.VIEW_STATE_PRESSED;
696 
697                 if (isDayEnabled) {
698                     // Adjust the circle to be centered on the row.
699                     canvas.drawCircle(colCenterRtl, rowCenter,
700                             mDaySelectorRadius, mDayHighlightPaint);
701                 }
702             }
703 
704             final boolean isDayToday = mToday == day;
705             final int dayTextColor;
706             if (isDayToday && !isDayActivated) {
707                 dayTextColor = mDaySelectorPaint.getColor();
708             } else {
709                 final int[] stateSet = StateSet.get(stateMask);
710                 dayTextColor = mDayTextColor.getColorForState(stateSet, 0);
711             }
712             p.setColor(dayTextColor);
713 
714             canvas.drawText(mDayFormatter.format(day), colCenterRtl, rowCenter - halfLineHeight, p);
715 
716             col++;
717 
718             if (col == DAYS_IN_WEEK) {
719                 col = 0;
720                 rowCenter += rowHeight;
721             }
722         }
723     }
724 
isDayEnabled(int day)725     private boolean isDayEnabled(int day) {
726         return day >= mEnabledDayStart && day <= mEnabledDayEnd;
727     }
728 
isValidDayOfMonth(int day)729     private boolean isValidDayOfMonth(int day) {
730         return day >= 1 && day <= mDaysInMonth;
731     }
732 
isValidDayOfWeek(int day)733     private static boolean isValidDayOfWeek(int day) {
734         return day >= Calendar.SUNDAY && day <= Calendar.SATURDAY;
735     }
736 
isValidMonth(int month)737     private static boolean isValidMonth(int month) {
738         return month >= Calendar.JANUARY && month <= Calendar.DECEMBER;
739     }
740 
741     /**
742      * Sets the selected day.
743      *
744      * @param dayOfMonth the selected day of the month, or {@code -1} to clear
745      *                   the selection
746      */
setSelectedDay(int dayOfMonth)747     public void setSelectedDay(int dayOfMonth) {
748         mActivatedDay = dayOfMonth;
749 
750         // Invalidate cached accessibility information.
751         mTouchHelper.invalidateRoot();
752         invalidate();
753     }
754 
755     /**
756      * Sets the first day of the week.
757      *
758      * @param weekStart which day the week should start on, valid values are
759      *                  {@link Calendar#SUNDAY} through {@link Calendar#SATURDAY}
760      */
setFirstDayOfWeek(int weekStart)761     public void setFirstDayOfWeek(int weekStart) {
762         if (isValidDayOfWeek(weekStart)) {
763             mWeekStart = weekStart;
764         } else {
765             mWeekStart = mCalendar.getFirstDayOfWeek();
766         }
767 
768         updateDayOfWeekLabels();
769 
770         // Invalidate cached accessibility information.
771         mTouchHelper.invalidateRoot();
772         invalidate();
773     }
774 
775     /**
776      * Sets all the parameters for displaying this week.
777      * <p>
778      * Parameters have a default value and will only update if a new value is
779      * included, except for focus month, which will always default to no focus
780      * month if no value is passed in. The only required parameter is the week
781      * start.
782      *
783      * @param selectedDay the selected day of the month, or -1 for no selection
784      * @param month the month
785      * @param year the year
786      * @param weekStart which day the week should start on, valid values are
787      *                  {@link Calendar#SUNDAY} through {@link Calendar#SATURDAY}
788      * @param enabledDayStart the first enabled day
789      * @param enabledDayEnd the last enabled day
790      */
setMonthParams(int selectedDay, int month, int year, int weekStart, int enabledDayStart, int enabledDayEnd)791     void setMonthParams(int selectedDay, int month, int year, int weekStart, int enabledDayStart,
792             int enabledDayEnd) {
793         mActivatedDay = selectedDay;
794 
795         if (isValidMonth(month)) {
796             mMonth = month;
797         }
798         mYear = year;
799 
800         mCalendar.set(Calendar.MONTH, mMonth);
801         mCalendar.set(Calendar.YEAR, mYear);
802         mCalendar.set(Calendar.DAY_OF_MONTH, 1);
803         mDayOfWeekStart = mCalendar.get(Calendar.DAY_OF_WEEK);
804 
805         if (isValidDayOfWeek(weekStart)) {
806             mWeekStart = weekStart;
807         } else {
808             mWeekStart = mCalendar.getFirstDayOfWeek();
809         }
810 
811         // Figure out what day today is.
812         final Calendar today = Calendar.getInstance();
813         mToday = -1;
814         mDaysInMonth = getDaysInMonth(mMonth, mYear);
815         for (int i = 0; i < mDaysInMonth; i++) {
816             final int day = i + 1;
817             if (sameDay(day, today)) {
818                 mToday = day;
819             }
820         }
821 
822         mEnabledDayStart = MathUtils.constrain(enabledDayStart, 1, mDaysInMonth);
823         mEnabledDayEnd = MathUtils.constrain(enabledDayEnd, mEnabledDayStart, mDaysInMonth);
824 
825         updateMonthYearLabel();
826         updateDayOfWeekLabels();
827 
828         // Invalidate cached accessibility information.
829         mTouchHelper.invalidateRoot();
830         invalidate();
831     }
832 
getDaysInMonth(int month, int year)833     private static int getDaysInMonth(int month, int year) {
834         switch (month) {
835             case Calendar.JANUARY:
836             case Calendar.MARCH:
837             case Calendar.MAY:
838             case Calendar.JULY:
839             case Calendar.AUGUST:
840             case Calendar.OCTOBER:
841             case Calendar.DECEMBER:
842                 return 31;
843             case Calendar.APRIL:
844             case Calendar.JUNE:
845             case Calendar.SEPTEMBER:
846             case Calendar.NOVEMBER:
847                 return 30;
848             case Calendar.FEBRUARY:
849                 return (year % 4 == 0) ? 29 : 28;
850             default:
851                 throw new IllegalArgumentException("Invalid Month");
852         }
853     }
854 
sameDay(int day, Calendar today)855     private boolean sameDay(int day, Calendar today) {
856         return mYear == today.get(Calendar.YEAR) && mMonth == today.get(Calendar.MONTH)
857                 && day == today.get(Calendar.DAY_OF_MONTH);
858     }
859 
860     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)861     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
862         final int preferredHeight = mDesiredDayHeight * MAX_WEEKS_IN_MONTH
863                 + mDesiredDayOfWeekHeight + mDesiredMonthHeight
864                 + getPaddingTop() + getPaddingBottom();
865         final int preferredWidth = mDesiredCellWidth * DAYS_IN_WEEK
866                 + getPaddingStart() + getPaddingEnd();
867         final int resolvedWidth = resolveSize(preferredWidth, widthMeasureSpec);
868         final int resolvedHeight = resolveSize(preferredHeight, heightMeasureSpec);
869         setMeasuredDimension(resolvedWidth, resolvedHeight);
870     }
871 
872     @Override
onRtlPropertiesChanged(@esolvedLayoutDir int layoutDirection)873     public void onRtlPropertiesChanged(@ResolvedLayoutDir int layoutDirection) {
874         super.onRtlPropertiesChanged(layoutDirection);
875 
876         requestLayout();
877     }
878 
879     @Override
onLayout(boolean changed, int left, int top, int right, int bottom)880     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
881         if (!changed) {
882             return;
883         }
884 
885         // Let's initialize a completely reasonable number of variables.
886         final int w = right - left;
887         final int h = bottom - top;
888         final int paddingLeft = getPaddingLeft();
889         final int paddingTop = getPaddingTop();
890         final int paddingRight = getPaddingRight();
891         final int paddingBottom = getPaddingBottom();
892         final int paddedRight = w - paddingRight;
893         final int paddedBottom = h - paddingBottom;
894         final int paddedWidth = paddedRight - paddingLeft;
895         final int paddedHeight = paddedBottom - paddingTop;
896         if (paddedWidth == mPaddedWidth || paddedHeight == mPaddedHeight) {
897             return;
898         }
899 
900         mPaddedWidth = paddedWidth;
901         mPaddedHeight = paddedHeight;
902 
903         // We may have been laid out smaller than our preferred size. If so,
904         // scale all dimensions to fit.
905         final int measuredPaddedHeight = getMeasuredHeight() - paddingTop - paddingBottom;
906         final float scaleH = paddedHeight / (float) measuredPaddedHeight;
907         final int monthHeight = (int) (mDesiredMonthHeight * scaleH);
908         final int cellWidth = mPaddedWidth / DAYS_IN_WEEK;
909         mMonthHeight = monthHeight;
910         mDayOfWeekHeight = (int) (mDesiredDayOfWeekHeight * scaleH);
911         mDayHeight = (int) (mDesiredDayHeight * scaleH);
912         mCellWidth = cellWidth;
913 
914         // Compute the largest day selector radius that's still within the clip
915         // bounds and desired selector radius.
916         final int maxSelectorWidth = cellWidth / 2 + Math.min(paddingLeft, paddingRight);
917         final int maxSelectorHeight = mDayHeight / 2 + paddingBottom;
918         mDaySelectorRadius = Math.min(mDesiredDaySelectorRadius,
919                 Math.min(maxSelectorWidth, maxSelectorHeight));
920 
921         // Invalidate cached accessibility information.
922         mTouchHelper.invalidateRoot();
923     }
924 
findDayOffset()925     private int findDayOffset() {
926         final int offset = mDayOfWeekStart - mWeekStart;
927         if (mDayOfWeekStart < mWeekStart) {
928             return offset + DAYS_IN_WEEK;
929         }
930         return offset;
931     }
932 
933     /**
934      * Calculates the day of the month at the specified touch position. Returns
935      * the day of the month or -1 if the position wasn't in a valid day.
936      *
937      * @param x the x position of the touch event
938      * @param y the y position of the touch event
939      * @return the day of the month at (x, y), or -1 if the position wasn't in
940      *         a valid day
941      */
getDayAtLocation(int x, int y)942     private int getDayAtLocation(int x, int y) {
943         final int paddedX = x - getPaddingLeft();
944         if (paddedX < 0 || paddedX >= mPaddedWidth) {
945             return -1;
946         }
947 
948         final int headerHeight = mMonthHeight + mDayOfWeekHeight;
949         final int paddedY = y - getPaddingTop();
950         if (paddedY < headerHeight || paddedY >= mPaddedHeight) {
951             return -1;
952         }
953 
954         // Adjust for RTL after applying padding.
955         final int paddedXRtl;
956         if (isLayoutRtl()) {
957             paddedXRtl = mPaddedWidth - paddedX;
958         } else {
959             paddedXRtl = paddedX;
960         }
961 
962         final int row = (paddedY - headerHeight) / mDayHeight;
963         final int col = (paddedXRtl * DAYS_IN_WEEK) / mPaddedWidth;
964         final int index = col + row * DAYS_IN_WEEK;
965         final int day = index + 1 - findDayOffset();
966         if (!isValidDayOfMonth(day)) {
967             return -1;
968         }
969 
970         return day;
971     }
972 
973     /**
974      * Calculates the bounds of the specified day.
975      *
976      * @param id the day of the month
977      * @param outBounds the rect to populate with bounds
978      */
getBoundsForDay(int id, Rect outBounds)979     public boolean getBoundsForDay(int id, Rect outBounds) {
980         if (!isValidDayOfMonth(id)) {
981             return false;
982         }
983 
984         final int index = id - 1 + findDayOffset();
985 
986         // Compute left edge, taking into account RTL.
987         final int col = index % DAYS_IN_WEEK;
988         final int colWidth = mCellWidth;
989         final int left;
990         if (isLayoutRtl()) {
991             left = getWidth() - getPaddingRight() - (col + 1) * colWidth;
992         } else {
993             left = getPaddingLeft() + col * colWidth;
994         }
995 
996         // Compute top edge.
997         final int row = index / DAYS_IN_WEEK;
998         final int rowHeight = mDayHeight;
999         final int headerHeight = mMonthHeight + mDayOfWeekHeight;
1000         final int top = getPaddingTop() + headerHeight + row * rowHeight;
1001 
1002         outBounds.set(left, top, left + colWidth, top + rowHeight);
1003 
1004         return true;
1005     }
1006 
1007     /**
1008      * Called when the user clicks on a day. Handles callbacks to the
1009      * {@link OnDayClickListener} if one is set.
1010      *
1011      * @param day the day that was clicked
1012      */
onDayClicked(int day)1013     private boolean onDayClicked(int day) {
1014         if (!isValidDayOfMonth(day) || !isDayEnabled(day)) {
1015             return false;
1016         }
1017 
1018         if (mOnDayClickListener != null) {
1019             final Calendar date = Calendar.getInstance();
1020             date.set(mYear, mMonth, day);
1021             mOnDayClickListener.onDayClick(this, date);
1022         }
1023 
1024         // This is a no-op if accessibility is turned off.
1025         mTouchHelper.sendEventForVirtualView(day, AccessibilityEvent.TYPE_VIEW_CLICKED);
1026         return true;
1027     }
1028 
1029     @Override
onResolvePointerIcon(MotionEvent event, int pointerIndex)1030     public PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) {
1031         if (!isEnabled()) {
1032             return null;
1033         }
1034         // Add 0.5f to event coordinates to match the logic in onTouchEvent.
1035         final int x = (int) (event.getX() + 0.5f);
1036         final int y = (int) (event.getY() + 0.5f);
1037         final int dayUnderPointer = getDayAtLocation(x, y);
1038         if (dayUnderPointer >= 0) {
1039             return PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_HAND);
1040         }
1041         return super.onResolvePointerIcon(event, pointerIndex);
1042     }
1043 
1044     /**
1045      * Provides a virtual view hierarchy for interfacing with an accessibility
1046      * service.
1047      */
1048     private class MonthViewTouchHelper extends ExploreByTouchHelper {
1049         private static final String DATE_FORMAT = "dd MMMM yyyy";
1050 
1051         private final Rect mTempRect = new Rect();
1052         private final Calendar mTempCalendar = Calendar.getInstance();
1053 
MonthViewTouchHelper(View host)1054         public MonthViewTouchHelper(View host) {
1055             super(host);
1056         }
1057 
1058         @Override
getVirtualViewAt(float x, float y)1059         protected int getVirtualViewAt(float x, float y) {
1060             final int day = getDayAtLocation((int) (x + 0.5f), (int) (y + 0.5f));
1061             if (day != -1) {
1062                 return day;
1063             }
1064             return ExploreByTouchHelper.INVALID_ID;
1065         }
1066 
1067         @Override
getVisibleVirtualViews(IntArray virtualViewIds)1068         protected void getVisibleVirtualViews(IntArray virtualViewIds) {
1069             for (int day = 1; day <= mDaysInMonth; day++) {
1070                 virtualViewIds.add(day);
1071             }
1072         }
1073 
1074         @Override
onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event)1075         protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
1076             event.setContentDescription(getDayDescription(virtualViewId));
1077         }
1078 
1079         @Override
onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node)1080         protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node) {
1081             final boolean hasBounds = getBoundsForDay(virtualViewId, mTempRect);
1082 
1083             if (!hasBounds) {
1084                 // The day is invalid, kill the node.
1085                 mTempRect.setEmpty();
1086                 node.setContentDescription("");
1087                 node.setBoundsInParent(mTempRect);
1088                 node.setVisibleToUser(false);
1089                 return;
1090             }
1091 
1092             node.setText(getDayText(virtualViewId));
1093             node.setContentDescription(getDayDescription(virtualViewId));
1094             node.setBoundsInParent(mTempRect);
1095 
1096             final boolean isDayEnabled = isDayEnabled(virtualViewId);
1097             if (isDayEnabled) {
1098                 node.addAction(AccessibilityAction.ACTION_CLICK);
1099             }
1100 
1101             node.setEnabled(isDayEnabled);
1102 
1103             if (virtualViewId == mActivatedDay) {
1104                 // TODO: This should use activated once that's supported.
1105                 node.setChecked(true);
1106             }
1107 
1108         }
1109 
1110         @Override
onPerformActionForVirtualView(int virtualViewId, int action, Bundle arguments)1111         protected boolean onPerformActionForVirtualView(int virtualViewId, int action,
1112                 Bundle arguments) {
1113             switch (action) {
1114                 case AccessibilityNodeInfo.ACTION_CLICK:
1115                     return onDayClicked(virtualViewId);
1116             }
1117 
1118             return false;
1119         }
1120 
1121         /**
1122          * Generates a description for a given virtual view.
1123          *
1124          * @param id the day to generate a description for
1125          * @return a description of the virtual view
1126          */
getDayDescription(int id)1127         private CharSequence getDayDescription(int id) {
1128             if (isValidDayOfMonth(id)) {
1129                 mTempCalendar.set(mYear, mMonth, id);
1130                 return DateFormat.format(DATE_FORMAT, mTempCalendar.getTimeInMillis());
1131             }
1132 
1133             return "";
1134         }
1135 
1136         /**
1137          * Generates displayed text for a given virtual view.
1138          *
1139          * @param id the day to generate text for
1140          * @return the visible text of the virtual view
1141          */
getDayText(int id)1142         private CharSequence getDayText(int id) {
1143             if (isValidDayOfMonth(id)) {
1144                 return mDayFormatter.format(id);
1145             }
1146 
1147             return null;
1148         }
1149     }
1150 
1151     /**
1152      * Handles callbacks when the user clicks on a time object.
1153      */
1154     public interface OnDayClickListener {
onDayClick(SimpleMonthView view, Calendar day)1155         void onDayClick(SimpleMonthView view, Calendar day);
1156     }
1157 }
1158