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