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