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.Configuration; 23 import android.content.res.Resources; 24 import android.content.res.TypedArray; 25 import android.icu.text.DateFormat; 26 import android.icu.text.DisplayContext; 27 import android.icu.util.Calendar; 28 import android.os.Parcelable; 29 import android.util.AttributeSet; 30 import android.util.StateSet; 31 import android.view.HapticFeedbackConstants; 32 import android.view.LayoutInflater; 33 import android.view.View; 34 import android.view.View.OnClickListener; 35 import android.view.ViewGroup; 36 import android.view.accessibility.AccessibilityEvent; 37 import android.widget.DayPickerView.OnDaySelectedListener; 38 import android.widget.YearPickerView.OnYearSelectedListener; 39 40 import com.android.internal.R; 41 42 import java.util.Locale; 43 44 /** 45 * A delegate for picking up a date (day / month / year). 46 */ 47 class DatePickerCalendarDelegate extends DatePicker.AbstractDatePickerDelegate { 48 private static final int USE_LOCALE = 0; 49 50 private static final int UNINITIALIZED = -1; 51 private static final int VIEW_MONTH_DAY = 0; 52 private static final int VIEW_YEAR = 1; 53 54 private static final int DEFAULT_START_YEAR = 1900; 55 private static final int DEFAULT_END_YEAR = 2100; 56 57 private static final int ANIMATION_DURATION = 300; 58 59 private static final int[] ATTRS_TEXT_COLOR = new int[] { 60 com.android.internal.R.attr.textColor}; 61 private static final int[] ATTRS_DISABLED_ALPHA = new int[] { 62 com.android.internal.R.attr.disabledAlpha}; 63 64 private DateFormat mYearFormat; 65 private DateFormat mMonthDayFormat; 66 67 // Top-level container. 68 private ViewGroup mContainer; 69 70 // Header views. 71 private TextView mHeaderYear; 72 private TextView mHeaderMonthDay; 73 74 // Picker views. 75 private ViewAnimator mAnimator; 76 private DayPickerView mDayPickerView; 77 private YearPickerView mYearPickerView; 78 79 // Accessibility strings. 80 private String mSelectDay; 81 private String mSelectYear; 82 83 private int mCurrentView = UNINITIALIZED; 84 85 private final Calendar mTempDate; 86 private final Calendar mMinDate; 87 private final Calendar mMaxDate; 88 89 private int mFirstDayOfWeek = USE_LOCALE; 90 DatePickerCalendarDelegate(DatePicker delegator, Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)91 public DatePickerCalendarDelegate(DatePicker delegator, Context context, AttributeSet attrs, 92 int defStyleAttr, int defStyleRes) { 93 super(delegator, context); 94 95 final Locale locale = mCurrentLocale; 96 mCurrentDate = Calendar.getInstance(locale); 97 mTempDate = Calendar.getInstance(locale); 98 mMinDate = Calendar.getInstance(locale); 99 mMaxDate = Calendar.getInstance(locale); 100 101 mMinDate.set(DEFAULT_START_YEAR, Calendar.JANUARY, 1); 102 mMaxDate.set(DEFAULT_END_YEAR, Calendar.DECEMBER, 31); 103 104 final Resources res = mDelegator.getResources(); 105 final TypedArray a = mContext.obtainStyledAttributes(attrs, 106 R.styleable.DatePicker, defStyleAttr, defStyleRes); 107 final LayoutInflater inflater = (LayoutInflater) mContext.getSystemService( 108 Context.LAYOUT_INFLATER_SERVICE); 109 final int layoutResourceId = a.getResourceId( 110 R.styleable.DatePicker_internalLayout, R.layout.date_picker_material); 111 112 // Set up and attach container. 113 mContainer = (ViewGroup) inflater.inflate(layoutResourceId, mDelegator, false); 114 mContainer.setSaveFromParentEnabled(false); 115 mDelegator.addView(mContainer); 116 117 // Set up header views. 118 final ViewGroup header = mContainer.findViewById(R.id.date_picker_header); 119 mHeaderYear = header.findViewById(R.id.date_picker_header_year); 120 mHeaderYear.setOnClickListener(mOnHeaderClickListener); 121 mHeaderMonthDay = header.findViewById(R.id.date_picker_header_date); 122 mHeaderMonthDay.setOnClickListener(mOnHeaderClickListener); 123 124 // For the sake of backwards compatibility, attempt to extract the text 125 // color from the header month text appearance. If it's set, we'll let 126 // that override the "real" header text color. 127 ColorStateList headerTextColor = null; 128 129 @SuppressWarnings("deprecation") 130 final int monthHeaderTextAppearance = a.getResourceId( 131 R.styleable.DatePicker_headerMonthTextAppearance, 0); 132 if (monthHeaderTextAppearance != 0) { 133 final TypedArray textAppearance = mContext.obtainStyledAttributes(null, 134 ATTRS_TEXT_COLOR, 0, monthHeaderTextAppearance); 135 final ColorStateList legacyHeaderTextColor = textAppearance.getColorStateList(0); 136 headerTextColor = applyLegacyColorFixes(legacyHeaderTextColor); 137 textAppearance.recycle(); 138 } 139 140 if (headerTextColor == null) { 141 headerTextColor = a.getColorStateList(R.styleable.DatePicker_headerTextColor); 142 } 143 144 if (headerTextColor != null) { 145 mHeaderYear.setTextColor(headerTextColor); 146 mHeaderMonthDay.setTextColor(headerTextColor); 147 } 148 149 // Set up header background, if available. 150 if (a.hasValueOrEmpty(R.styleable.DatePicker_headerBackground)) { 151 header.setBackground(a.getDrawable(R.styleable.DatePicker_headerBackground)); 152 } 153 154 a.recycle(); 155 156 // Set up picker container. 157 mAnimator = mContainer.findViewById(R.id.animator); 158 159 // Set up day picker view. 160 mDayPickerView = mAnimator.findViewById(R.id.date_picker_day_picker); 161 mDayPickerView.setFirstDayOfWeek(mFirstDayOfWeek); 162 mDayPickerView.setMinDate(mMinDate.getTimeInMillis()); 163 mDayPickerView.setMaxDate(mMaxDate.getTimeInMillis()); 164 mDayPickerView.setDate(mCurrentDate.getTimeInMillis()); 165 mDayPickerView.setOnDaySelectedListener(mOnDaySelectedListener); 166 167 // Set up year picker view. 168 mYearPickerView = mAnimator.findViewById(R.id.date_picker_year_picker); 169 mYearPickerView.setRange(mMinDate, mMaxDate); 170 mYearPickerView.setYear(mCurrentDate.get(Calendar.YEAR)); 171 mYearPickerView.setOnYearSelectedListener(mOnYearSelectedListener); 172 173 // Set up content descriptions. 174 mSelectDay = res.getString(R.string.select_day); 175 mSelectYear = res.getString(R.string.select_year); 176 177 // Initialize for current locale. This also initializes the date, so no 178 // need to call onDateChanged. 179 onLocaleChanged(mCurrentLocale); 180 181 setCurrentView(VIEW_MONTH_DAY); 182 } 183 184 /** 185 * The legacy text color might have been poorly defined. Ensures that it 186 * has an appropriate activated state, using the selected state if one 187 * exists or modifying the default text color otherwise. 188 * 189 * @param color a legacy text color, or {@code null} 190 * @return a color state list with an appropriate activated state, or 191 * {@code null} if a valid activated state could not be generated 192 */ 193 @Nullable applyLegacyColorFixes(@ullable ColorStateList color)194 private ColorStateList applyLegacyColorFixes(@Nullable ColorStateList color) { 195 if (color == null || color.hasState(R.attr.state_activated)) { 196 return color; 197 } 198 199 final int activatedColor; 200 final int defaultColor; 201 if (color.hasState(R.attr.state_selected)) { 202 activatedColor = color.getColorForState(StateSet.get( 203 StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_SELECTED), 0); 204 defaultColor = color.getColorForState(StateSet.get( 205 StateSet.VIEW_STATE_ENABLED), 0); 206 } else { 207 activatedColor = color.getDefaultColor(); 208 209 // Generate a non-activated color using the disabled alpha. 210 final TypedArray ta = mContext.obtainStyledAttributes(ATTRS_DISABLED_ALPHA); 211 final float disabledAlpha = ta.getFloat(0, 0.30f); 212 defaultColor = multiplyAlphaComponent(activatedColor, disabledAlpha); 213 } 214 215 if (activatedColor == 0 || defaultColor == 0) { 216 // We somehow failed to obtain the colors. 217 return null; 218 } 219 220 final int[][] stateSet = new int[][] {{ R.attr.state_activated }, {}}; 221 final int[] colors = new int[] { activatedColor, defaultColor }; 222 return new ColorStateList(stateSet, colors); 223 } 224 multiplyAlphaComponent(int color, float alphaMod)225 private int multiplyAlphaComponent(int color, float alphaMod) { 226 final int srcRgb = color & 0xFFFFFF; 227 final int srcAlpha = (color >> 24) & 0xFF; 228 final int dstAlpha = (int) (srcAlpha * alphaMod + 0.5f); 229 return srcRgb | (dstAlpha << 24); 230 } 231 232 /** 233 * Listener called when the user selects a day in the day picker view. 234 */ 235 private final OnDaySelectedListener mOnDaySelectedListener = new OnDaySelectedListener() { 236 @Override 237 public void onDaySelected(DayPickerView view, Calendar day) { 238 mCurrentDate.setTimeInMillis(day.getTimeInMillis()); 239 onDateChanged(true, true); 240 } 241 }; 242 243 /** 244 * Listener called when the user selects a year in the year picker view. 245 */ 246 private final OnYearSelectedListener mOnYearSelectedListener = new OnYearSelectedListener() { 247 @Override 248 public void onYearChanged(YearPickerView view, int year) { 249 // If the newly selected month / year does not contain the 250 // currently selected day number, change the selected day number 251 // to the last day of the selected month or year. 252 // e.g. Switching from Mar to Apr when Mar 31 is selected -> Apr 30 253 // e.g. Switching from 2012 to 2013 when Feb 29, 2012 is selected -> Feb 28, 2013 254 final int day = mCurrentDate.get(Calendar.DAY_OF_MONTH); 255 final int month = mCurrentDate.get(Calendar.MONTH); 256 final int daysInMonth = getDaysInMonth(month, year); 257 if (day > daysInMonth) { 258 mCurrentDate.set(Calendar.DAY_OF_MONTH, daysInMonth); 259 } 260 261 mCurrentDate.set(Calendar.YEAR, year); 262 if (mCurrentDate.compareTo(mMinDate) < 0) { 263 mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis()); 264 } else if (mCurrentDate.compareTo(mMaxDate) > 0) { 265 mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis()); 266 } 267 onDateChanged(true, true); 268 269 // Automatically switch to day picker. 270 setCurrentView(VIEW_MONTH_DAY); 271 272 // Switch focus back to the year text. 273 mHeaderYear.requestFocus(); 274 } 275 }; 276 277 /** 278 * Listener called when the user clicks on a header item. 279 */ 280 private final OnClickListener mOnHeaderClickListener = v -> { 281 tryVibrate(); 282 283 switch (v.getId()) { 284 case R.id.date_picker_header_year: 285 setCurrentView(VIEW_YEAR); 286 break; 287 case R.id.date_picker_header_date: 288 setCurrentView(VIEW_MONTH_DAY); 289 break; 290 } 291 }; 292 293 @Override onLocaleChanged(Locale locale)294 protected void onLocaleChanged(Locale locale) { 295 final TextView headerYear = mHeaderYear; 296 if (headerYear == null) { 297 // Abort, we haven't initialized yet. This method will get called 298 // again later after everything has been set up. 299 return; 300 } 301 302 // Update the date formatter. 303 mMonthDayFormat = DateFormat.getInstanceForSkeleton("EMMMd", locale); 304 mMonthDayFormat.setContext(DisplayContext.CAPITALIZATION_FOR_STANDALONE); 305 mYearFormat = DateFormat.getInstanceForSkeleton("y", locale); 306 307 // Update the header text. 308 onCurrentDateChanged(false); 309 } 310 onCurrentDateChanged(boolean announce)311 private void onCurrentDateChanged(boolean announce) { 312 if (mHeaderYear == null) { 313 // Abort, we haven't initialized yet. This method will get called 314 // again later after everything has been set up. 315 return; 316 } 317 318 final String year = mYearFormat.format(mCurrentDate.getTime()); 319 mHeaderYear.setText(year); 320 321 final String monthDay = mMonthDayFormat.format(mCurrentDate.getTime()); 322 mHeaderMonthDay.setText(monthDay); 323 324 // TODO: This should use live regions. 325 if (announce) { 326 mAnimator.announceForAccessibility(getFormattedCurrentDate()); 327 } 328 } 329 setCurrentView(final int viewIndex)330 private void setCurrentView(final int viewIndex) { 331 switch (viewIndex) { 332 case VIEW_MONTH_DAY: 333 mDayPickerView.setDate(mCurrentDate.getTimeInMillis()); 334 335 if (mCurrentView != viewIndex) { 336 mHeaderMonthDay.setActivated(true); 337 mHeaderYear.setActivated(false); 338 mAnimator.setDisplayedChild(VIEW_MONTH_DAY); 339 mCurrentView = viewIndex; 340 } 341 342 mAnimator.announceForAccessibility(mSelectDay); 343 break; 344 case VIEW_YEAR: 345 final int year = mCurrentDate.get(Calendar.YEAR); 346 mYearPickerView.setYear(year); 347 mYearPickerView.post(() -> { 348 mYearPickerView.requestFocus(); 349 final View selected = mYearPickerView.getSelectedView(); 350 if (selected != null) { 351 selected.requestFocus(); 352 } 353 }); 354 355 if (mCurrentView != viewIndex) { 356 mHeaderMonthDay.setActivated(false); 357 mHeaderYear.setActivated(true); 358 mAnimator.setDisplayedChild(VIEW_YEAR); 359 mCurrentView = viewIndex; 360 } 361 362 mAnimator.announceForAccessibility(mSelectYear); 363 break; 364 } 365 } 366 367 @Override init(int year, int month, int dayOfMonth, DatePicker.OnDateChangedListener callBack)368 public void init(int year, int month, int dayOfMonth, 369 DatePicker.OnDateChangedListener callBack) { 370 setDate(year, month, dayOfMonth); 371 onDateChanged(false, false); 372 373 mOnDateChangedListener = callBack; 374 } 375 376 @Override updateDate(int year, int month, int dayOfMonth)377 public void updateDate(int year, int month, int dayOfMonth) { 378 setDate(year, month, dayOfMonth); 379 onDateChanged(false, true); 380 } 381 setDate(int year, int month, int dayOfMonth)382 private void setDate(int year, int month, int dayOfMonth) { 383 mCurrentDate.set(Calendar.YEAR, year); 384 mCurrentDate.set(Calendar.MONTH, month); 385 mCurrentDate.set(Calendar.DAY_OF_MONTH, dayOfMonth); 386 resetAutofilledValue(); 387 } 388 onDateChanged(boolean fromUser, boolean callbackToClient)389 private void onDateChanged(boolean fromUser, boolean callbackToClient) { 390 final int year = mCurrentDate.get(Calendar.YEAR); 391 392 if (callbackToClient 393 && (mOnDateChangedListener != null || mAutoFillChangeListener != null)) { 394 final int monthOfYear = mCurrentDate.get(Calendar.MONTH); 395 final int dayOfMonth = mCurrentDate.get(Calendar.DAY_OF_MONTH); 396 if (mOnDateChangedListener != null) { 397 mOnDateChangedListener.onDateChanged(mDelegator, year, monthOfYear, dayOfMonth); 398 } 399 if (mAutoFillChangeListener != null) { 400 mAutoFillChangeListener.onDateChanged(mDelegator, year, monthOfYear, dayOfMonth); 401 } 402 } 403 404 mDayPickerView.setDate(mCurrentDate.getTimeInMillis()); 405 mYearPickerView.setYear(year); 406 407 onCurrentDateChanged(fromUser); 408 409 if (fromUser) { 410 tryVibrate(); 411 } 412 } 413 414 @Override getYear()415 public int getYear() { 416 return mCurrentDate.get(Calendar.YEAR); 417 } 418 419 @Override getMonth()420 public int getMonth() { 421 return mCurrentDate.get(Calendar.MONTH); 422 } 423 424 @Override getDayOfMonth()425 public int getDayOfMonth() { 426 return mCurrentDate.get(Calendar.DAY_OF_MONTH); 427 } 428 429 @Override setMinDate(long minDate)430 public void setMinDate(long minDate) { 431 mTempDate.setTimeInMillis(minDate); 432 if (mTempDate.get(Calendar.YEAR) == mMinDate.get(Calendar.YEAR) 433 && mTempDate.get(Calendar.DAY_OF_YEAR) == mMinDate.get(Calendar.DAY_OF_YEAR)) { 434 // Same day, no-op. 435 return; 436 } 437 if (mCurrentDate.before(mTempDate)) { 438 mCurrentDate.setTimeInMillis(minDate); 439 onDateChanged(false, true); 440 } 441 mMinDate.setTimeInMillis(minDate); 442 mDayPickerView.setMinDate(minDate); 443 mYearPickerView.setRange(mMinDate, mMaxDate); 444 } 445 446 @Override getMinDate()447 public Calendar getMinDate() { 448 return mMinDate; 449 } 450 451 @Override setMaxDate(long maxDate)452 public void setMaxDate(long maxDate) { 453 mTempDate.setTimeInMillis(maxDate); 454 if (mTempDate.get(Calendar.YEAR) == mMaxDate.get(Calendar.YEAR) 455 && mTempDate.get(Calendar.DAY_OF_YEAR) == mMaxDate.get(Calendar.DAY_OF_YEAR)) { 456 // Same day, no-op. 457 return; 458 } 459 if (mCurrentDate.after(mTempDate)) { 460 mCurrentDate.setTimeInMillis(maxDate); 461 onDateChanged(false, true); 462 } 463 mMaxDate.setTimeInMillis(maxDate); 464 mDayPickerView.setMaxDate(maxDate); 465 mYearPickerView.setRange(mMinDate, mMaxDate); 466 } 467 468 @Override getMaxDate()469 public Calendar getMaxDate() { 470 return mMaxDate; 471 } 472 473 @Override setFirstDayOfWeek(int firstDayOfWeek)474 public void setFirstDayOfWeek(int firstDayOfWeek) { 475 mFirstDayOfWeek = firstDayOfWeek; 476 477 mDayPickerView.setFirstDayOfWeek(firstDayOfWeek); 478 } 479 480 @Override getFirstDayOfWeek()481 public int getFirstDayOfWeek() { 482 if (mFirstDayOfWeek != USE_LOCALE) { 483 return mFirstDayOfWeek; 484 } 485 return mCurrentDate.getFirstDayOfWeek(); 486 } 487 488 @Override setEnabled(boolean enabled)489 public void setEnabled(boolean enabled) { 490 mContainer.setEnabled(enabled); 491 mDayPickerView.setEnabled(enabled); 492 mYearPickerView.setEnabled(enabled); 493 mHeaderYear.setEnabled(enabled); 494 mHeaderMonthDay.setEnabled(enabled); 495 } 496 497 @Override isEnabled()498 public boolean isEnabled() { 499 return mContainer.isEnabled(); 500 } 501 502 @Override getCalendarView()503 public CalendarView getCalendarView() { 504 throw new UnsupportedOperationException("Not supported by calendar-mode DatePicker"); 505 } 506 507 @Override setCalendarViewShown(boolean shown)508 public void setCalendarViewShown(boolean shown) { 509 // No-op for compatibility with the old DatePicker. 510 } 511 512 @Override getCalendarViewShown()513 public boolean getCalendarViewShown() { 514 return false; 515 } 516 517 @Override setSpinnersShown(boolean shown)518 public void setSpinnersShown(boolean shown) { 519 // No-op for compatibility with the old DatePicker. 520 } 521 522 @Override getSpinnersShown()523 public boolean getSpinnersShown() { 524 return false; 525 } 526 527 @Override onConfigurationChanged(Configuration newConfig)528 public void onConfigurationChanged(Configuration newConfig) { 529 setCurrentLocale(newConfig.locale); 530 } 531 532 @Override onSaveInstanceState(Parcelable superState)533 public Parcelable onSaveInstanceState(Parcelable superState) { 534 final int year = mCurrentDate.get(Calendar.YEAR); 535 final int month = mCurrentDate.get(Calendar.MONTH); 536 final int day = mCurrentDate.get(Calendar.DAY_OF_MONTH); 537 538 int listPosition = -1; 539 int listPositionOffset = -1; 540 541 if (mCurrentView == VIEW_MONTH_DAY) { 542 listPosition = mDayPickerView.getMostVisiblePosition(); 543 } else if (mCurrentView == VIEW_YEAR) { 544 listPosition = mYearPickerView.getFirstVisiblePosition(); 545 listPositionOffset = mYearPickerView.getFirstPositionOffset(); 546 } 547 548 return new SavedState(superState, year, month, day, mMinDate.getTimeInMillis(), 549 mMaxDate.getTimeInMillis(), mCurrentView, listPosition, listPositionOffset); 550 } 551 552 @Override onRestoreInstanceState(Parcelable state)553 public void onRestoreInstanceState(Parcelable state) { 554 if (state instanceof SavedState) { 555 final SavedState ss = (SavedState) state; 556 557 // TODO: Move instance state into DayPickerView, YearPickerView. 558 mCurrentDate.set(ss.getSelectedYear(), ss.getSelectedMonth(), ss.getSelectedDay()); 559 mMinDate.setTimeInMillis(ss.getMinDate()); 560 mMaxDate.setTimeInMillis(ss.getMaxDate()); 561 562 onCurrentDateChanged(false); 563 564 final int currentView = ss.getCurrentView(); 565 setCurrentView(currentView); 566 567 final int listPosition = ss.getListPosition(); 568 if (listPosition != -1) { 569 if (currentView == VIEW_MONTH_DAY) { 570 mDayPickerView.setPosition(listPosition); 571 } else if (currentView == VIEW_YEAR) { 572 final int listPositionOffset = ss.getListPositionOffset(); 573 mYearPickerView.setSelectionFromTop(listPosition, listPositionOffset); 574 } 575 } 576 } 577 } 578 579 @Override dispatchPopulateAccessibilityEvent(AccessibilityEvent event)580 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 581 onPopulateAccessibilityEvent(event); 582 return true; 583 } 584 getAccessibilityClassName()585 public CharSequence getAccessibilityClassName() { 586 return DatePicker.class.getName(); 587 } 588 getDaysInMonth(int month, int year)589 private static int getDaysInMonth(int month, int year) { 590 switch (month) { 591 case Calendar.JANUARY: 592 case Calendar.MARCH: 593 case Calendar.MAY: 594 case Calendar.JULY: 595 case Calendar.AUGUST: 596 case Calendar.OCTOBER: 597 case Calendar.DECEMBER: 598 return 31; 599 case Calendar.APRIL: 600 case Calendar.JUNE: 601 case Calendar.SEPTEMBER: 602 case Calendar.NOVEMBER: 603 return 30; 604 case Calendar.FEBRUARY: 605 return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) ? 29 : 28; 606 default: 607 throw new IllegalArgumentException("Invalid Month"); 608 } 609 } 610 tryVibrate()611 private void tryVibrate() { 612 mDelegator.performHapticFeedback(HapticFeedbackConstants.CALENDAR_DATE); 613 } 614 } 615