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