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