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