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