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