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