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