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