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