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