1 /* 2 * Copyright (C) 2015 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 * in compliance with the License. You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software distributed under the License 10 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 * or implied. See the License for the specific language governing permissions and limitations under 12 * the License. 13 */ 14 15 package androidx.leanback.widget.picker; 16 17 import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; 18 19 import android.content.Context; 20 import android.content.res.TypedArray; 21 import android.text.TextUtils; 22 import android.util.AttributeSet; 23 import android.util.Log; 24 25 import androidx.annotation.RestrictTo; 26 import androidx.leanback.R; 27 28 import java.text.DateFormat; 29 import java.text.ParseException; 30 import java.text.SimpleDateFormat; 31 import java.util.ArrayList; 32 import java.util.Calendar; 33 import java.util.List; 34 import java.util.Locale; 35 import java.util.TimeZone; 36 37 /** 38 * {@link DatePicker} is a directly subclass of {@link Picker}. 39 * This class is a widget for selecting a date. The date can be selected by a 40 * year, month, and day Columns. The "minDate" and "maxDate" from which dates to be selected 41 * can be customized. The columns can be customized by attribute "datePickerFormat" or 42 * {@link #setDatePickerFormat(String)}. 43 * 44 * @attr ref R.styleable#lbDatePicker_android_maxDate 45 * @attr ref R.styleable#lbDatePicker_android_minDate 46 * @attr ref R.styleable#lbDatePicker_datePickerFormat 47 * @hide 48 */ 49 @RestrictTo(LIBRARY_GROUP) 50 public class DatePicker extends Picker { 51 52 static final String LOG_TAG = "DatePicker"; 53 54 private String mDatePickerFormat; 55 PickerColumn mMonthColumn; 56 PickerColumn mDayColumn; 57 PickerColumn mYearColumn; 58 int mColMonthIndex; 59 int mColDayIndex; 60 int mColYearIndex; 61 62 final static String DATE_FORMAT = "MM/dd/yyyy"; 63 final DateFormat mDateFormat = new SimpleDateFormat(DATE_FORMAT); 64 PickerUtility.DateConstant mConstant; 65 66 Calendar mMinDate; 67 Calendar mMaxDate; 68 Calendar mCurrentDate; 69 Calendar mTempDate; 70 DatePicker(Context context, AttributeSet attrs)71 public DatePicker(Context context, AttributeSet attrs) { 72 this(context, attrs, 0); 73 } 74 DatePicker(Context context, AttributeSet attrs, int defStyleAttr)75 public DatePicker(Context context, AttributeSet attrs, int defStyleAttr) { 76 super(context, attrs, defStyleAttr); 77 78 updateCurrentLocale(); 79 80 final TypedArray attributesArray = context.obtainStyledAttributes(attrs, 81 R.styleable.lbDatePicker); 82 String minDate = attributesArray.getString(R.styleable.lbDatePicker_android_minDate); 83 String maxDate = attributesArray.getString(R.styleable.lbDatePicker_android_maxDate); 84 mTempDate.clear(); 85 if (!TextUtils.isEmpty(minDate)) { 86 if (!parseDate(minDate, mTempDate)) { 87 mTempDate.set(1900, 0, 1); 88 } 89 } else { 90 mTempDate.set(1900, 0, 1); 91 } 92 mMinDate.setTimeInMillis(mTempDate.getTimeInMillis()); 93 94 mTempDate.clear(); 95 if (!TextUtils.isEmpty(maxDate)) { 96 if (!parseDate(maxDate, mTempDate)) { 97 mTempDate.set(2100, 0, 1); 98 } 99 } else { 100 mTempDate.set(2100, 0, 1); 101 } 102 mMaxDate.setTimeInMillis(mTempDate.getTimeInMillis()); 103 104 String datePickerFormat = attributesArray 105 .getString(R.styleable.lbDatePicker_datePickerFormat); 106 if (TextUtils.isEmpty(datePickerFormat)) { 107 datePickerFormat = new String( 108 android.text.format.DateFormat.getDateFormatOrder(context)); 109 } 110 setDatePickerFormat(datePickerFormat); 111 } 112 parseDate(String date, Calendar outDate)113 private boolean parseDate(String date, Calendar outDate) { 114 try { 115 outDate.setTime(mDateFormat.parse(date)); 116 return true; 117 } catch (ParseException e) { 118 Log.w(LOG_TAG, "Date: " + date + " not in format: " + DATE_FORMAT); 119 return false; 120 } 121 } 122 123 /** 124 * Returns the best localized representation of the date for the given date format and the 125 * current locale. 126 * 127 * @param datePickerFormat The date format skeleton (e.g. "dMy") used to gather the 128 * appropriate representation of the date in the current locale. 129 * 130 * @return The best localized representation of the date for the given date format 131 */ getBestYearMonthDayPattern(String datePickerFormat)132 String getBestYearMonthDayPattern(String datePickerFormat) { 133 final String yearPattern; 134 if (PickerUtility.SUPPORTS_BEST_DATE_TIME_PATTERN) { 135 yearPattern = android.text.format.DateFormat.getBestDateTimePattern(mConstant.locale, 136 datePickerFormat); 137 } else { 138 final java.text.DateFormat dateFormat = android.text.format.DateFormat.getDateFormat( 139 getContext()); 140 if (dateFormat instanceof SimpleDateFormat) { 141 yearPattern = ((SimpleDateFormat) dateFormat).toLocalizedPattern(); 142 } else { 143 yearPattern = DATE_FORMAT; 144 } 145 } 146 return TextUtils.isEmpty(yearPattern) ? DATE_FORMAT : yearPattern; 147 } 148 149 /** 150 * Extracts the separators used to separate date fields (including before the first and after 151 * the last date field). The separators can vary based on the individual locale date format, 152 * defined in the Unicode CLDR and cannot be supposed to be "/". 153 * 154 * See http://unicode.org/cldr/trac/browser/trunk/common/main 155 * 156 * For example, for Croatian in dMy format, the best localized representation is "d. M. y". This 157 * method returns {"", ".", ".", "."}, where the first separator indicates nothing needs to be 158 * displayed to the left of the day field, "." needs to be displayed tos the right of the day 159 * field, and so forth. 160 * 161 * @return The ArrayList of separators to populate between the actual date fields in the 162 * DatePicker. 163 */ extractSeparators()164 List<CharSequence> extractSeparators() { 165 // Obtain the time format string per the current locale (e.g. h:mm a) 166 String hmaPattern = getBestYearMonthDayPattern(mDatePickerFormat); 167 168 List<CharSequence> separators = new ArrayList<>(); 169 StringBuilder sb = new StringBuilder(); 170 char lastChar = '\0'; 171 // See http://www.unicode.org/reports/tr35/tr35-dates.html for date formats 172 final char[] dateFormats = {'Y', 'y', 'M', 'm', 'D', 'd'}; 173 boolean processingQuote = false; 174 for (int i = 0; i < hmaPattern.length(); i++) { 175 char c = hmaPattern.charAt(i); 176 if (c == ' ') { 177 continue; 178 } 179 if (c == '\'') { 180 if (!processingQuote) { 181 sb.setLength(0); 182 processingQuote = true; 183 } else { 184 processingQuote = false; 185 } 186 continue; 187 } 188 if (processingQuote) { 189 sb.append(c); 190 } else { 191 if (isAnyOf(c, dateFormats)) { 192 if (c != lastChar) { 193 separators.add(sb.toString()); 194 sb.setLength(0); 195 } 196 } else { 197 sb.append(c); 198 } 199 } 200 lastChar = c; 201 } 202 separators.add(sb.toString()); 203 return separators; 204 } 205 isAnyOf(char c, char[] any)206 private static boolean isAnyOf(char c, char[] any) { 207 for (int i = 0; i < any.length; i++) { 208 if (c == any[i]) { 209 return true; 210 } 211 } 212 return false; 213 } 214 215 /** 216 * Changes format of showing dates. For example "YMD". 217 * @param datePickerFormat Format of showing dates. 218 */ setDatePickerFormat(String datePickerFormat)219 public void setDatePickerFormat(String datePickerFormat) { 220 if (TextUtils.isEmpty(datePickerFormat)) { 221 datePickerFormat = new String( 222 android.text.format.DateFormat.getDateFormatOrder(getContext())); 223 } 224 if (TextUtils.equals(mDatePickerFormat, datePickerFormat)) { 225 return; 226 } 227 mDatePickerFormat = datePickerFormat; 228 List<CharSequence> separators = extractSeparators(); 229 if (separators.size() != (datePickerFormat.length() + 1)) { 230 throw new IllegalStateException("Separators size: " + separators.size() + " must equal" 231 + " the size of datePickerFormat: " + datePickerFormat.length() + " + 1"); 232 } 233 setSeparators(separators); 234 mYearColumn = mMonthColumn = mDayColumn = null; 235 mColYearIndex = mColDayIndex = mColMonthIndex = -1; 236 String dateFieldsPattern = datePickerFormat.toUpperCase(); 237 ArrayList<PickerColumn> columns = new ArrayList<PickerColumn>(3); 238 for (int i = 0; i < dateFieldsPattern.length(); i++) { 239 switch (dateFieldsPattern.charAt(i)) { 240 case 'Y': 241 if (mYearColumn != null) { 242 throw new IllegalArgumentException("datePicker format error"); 243 } 244 columns.add(mYearColumn = new PickerColumn()); 245 mColYearIndex = i; 246 mYearColumn.setLabelFormat("%d"); 247 break; 248 case 'M': 249 if (mMonthColumn != null) { 250 throw new IllegalArgumentException("datePicker format error"); 251 } 252 columns.add(mMonthColumn = new PickerColumn()); 253 mMonthColumn.setStaticLabels(mConstant.months); 254 mColMonthIndex = i; 255 break; 256 case 'D': 257 if (mDayColumn != null) { 258 throw new IllegalArgumentException("datePicker format error"); 259 } 260 columns.add(mDayColumn = new PickerColumn()); 261 mDayColumn.setLabelFormat("%02d"); 262 mColDayIndex = i; 263 break; 264 default: 265 throw new IllegalArgumentException("datePicker format error"); 266 } 267 } 268 setColumns(columns); 269 updateSpinners(false); 270 } 271 272 /** 273 * Get format of showing dates. For example "YMD". Default value is from 274 * {@link android.text.format.DateFormat#getDateFormatOrder(Context)}. 275 * @return Format of showing dates. 276 */ getDatePickerFormat()277 public String getDatePickerFormat() { 278 return mDatePickerFormat; 279 } 280 updateCurrentLocale()281 private void updateCurrentLocale() { 282 mConstant = PickerUtility.getDateConstantInstance(Locale.getDefault(), 283 getContext().getResources()); 284 mTempDate = PickerUtility.getCalendarForLocale(mTempDate, mConstant.locale); 285 mMinDate = PickerUtility.getCalendarForLocale(mMinDate, mConstant.locale); 286 mMaxDate = PickerUtility.getCalendarForLocale(mMaxDate, mConstant.locale); 287 mCurrentDate = PickerUtility.getCalendarForLocale(mCurrentDate, mConstant.locale); 288 289 if (mMonthColumn != null) { 290 mMonthColumn.setStaticLabels(mConstant.months); 291 setColumnAt(mColMonthIndex, mMonthColumn); 292 } 293 } 294 295 @Override onColumnValueChanged(int column, int newVal)296 public final void onColumnValueChanged(int column, int newVal) { 297 mTempDate.setTimeInMillis(mCurrentDate.getTimeInMillis()); 298 // take care of wrapping of days and months to update greater fields 299 int oldVal = getColumnAt(column).getCurrentValue(); 300 if (column == mColDayIndex) { 301 mTempDate.add(Calendar.DAY_OF_MONTH, newVal - oldVal); 302 } else if (column == mColMonthIndex) { 303 mTempDate.add(Calendar.MONTH, newVal - oldVal); 304 } else if (column == mColYearIndex) { 305 mTempDate.add(Calendar.YEAR, newVal - oldVal); 306 } else { 307 throw new IllegalArgumentException(); 308 } 309 setDate(mTempDate.get(Calendar.YEAR), mTempDate.get(Calendar.MONTH), 310 mTempDate.get(Calendar.DAY_OF_MONTH)); 311 updateSpinners(false); 312 } 313 314 315 /** 316 * Sets the minimal date supported by this {@link DatePicker} in 317 * milliseconds since January 1, 1970 00:00:00 in 318 * {@link TimeZone#getDefault()} time zone. 319 * 320 * @param minDate The minimal supported date. 321 */ setMinDate(long minDate)322 public void setMinDate(long minDate) { 323 mTempDate.setTimeInMillis(minDate); 324 if (mTempDate.get(Calendar.YEAR) == mMinDate.get(Calendar.YEAR) 325 && mTempDate.get(Calendar.DAY_OF_YEAR) != mMinDate.get(Calendar.DAY_OF_YEAR)) { 326 return; 327 } 328 mMinDate.setTimeInMillis(minDate); 329 if (mCurrentDate.before(mMinDate)) { 330 mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis()); 331 } 332 updateSpinners(false); 333 } 334 335 336 /** 337 * Gets the minimal date supported by this {@link DatePicker} in 338 * milliseconds since January 1, 1970 00:00:00 in 339 * {@link TimeZone#getDefault()} time zone. 340 * <p> 341 * Note: The default minimal date is 01/01/1900. 342 * <p> 343 * 344 * @return The minimal supported date. 345 */ getMinDate()346 public long getMinDate() { 347 return mMinDate.getTimeInMillis(); 348 } 349 350 /** 351 * Sets the maximal date supported by this {@link DatePicker} in 352 * milliseconds since January 1, 1970 00:00:00 in 353 * {@link TimeZone#getDefault()} time zone. 354 * 355 * @param maxDate The maximal supported date. 356 */ setMaxDate(long maxDate)357 public void setMaxDate(long maxDate) { 358 mTempDate.setTimeInMillis(maxDate); 359 if (mTempDate.get(Calendar.YEAR) == mMaxDate.get(Calendar.YEAR) 360 && mTempDate.get(Calendar.DAY_OF_YEAR) != mMaxDate.get(Calendar.DAY_OF_YEAR)) { 361 return; 362 } 363 mMaxDate.setTimeInMillis(maxDate); 364 if (mCurrentDate.after(mMaxDate)) { 365 mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis()); 366 } 367 updateSpinners(false); 368 } 369 370 /** 371 * Gets the maximal date supported by this {@link DatePicker} in 372 * milliseconds since January 1, 1970 00:00:00 in 373 * {@link TimeZone#getDefault()} time zone. 374 * <p> 375 * Note: The default maximal date is 12/31/2100. 376 * <p> 377 * 378 * @return The maximal supported date. 379 */ getMaxDate()380 public long getMaxDate() { 381 return mMaxDate.getTimeInMillis(); 382 } 383 384 /** 385 * Gets current date value in milliseconds since January 1, 1970 00:00:00 in 386 * {@link TimeZone#getDefault()} time zone. 387 * 388 * @return Current date values. 389 */ getDate()390 public long getDate() { 391 return mCurrentDate.getTimeInMillis(); 392 } 393 setDate(int year, int month, int dayOfMonth)394 private void setDate(int year, int month, int dayOfMonth) { 395 mCurrentDate.set(year, month, dayOfMonth); 396 if (mCurrentDate.before(mMinDate)) { 397 mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis()); 398 } else if (mCurrentDate.after(mMaxDate)) { 399 mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis()); 400 } 401 } 402 403 /** 404 * Update the current date. 405 * 406 * @param year The year. 407 * @param month The month which is <strong>starting from zero</strong>. 408 * @param dayOfMonth The day of the month. 409 * @param animation True to run animation to scroll the column. 410 */ updateDate(int year, int month, int dayOfMonth, boolean animation)411 public void updateDate(int year, int month, int dayOfMonth, boolean animation) { 412 if (!isNewDate(year, month, dayOfMonth)) { 413 return; 414 } 415 setDate(year, month, dayOfMonth); 416 updateSpinners(animation); 417 } 418 isNewDate(int year, int month, int dayOfMonth)419 private boolean isNewDate(int year, int month, int dayOfMonth) { 420 return (mCurrentDate.get(Calendar.YEAR) != year 421 || mCurrentDate.get(Calendar.MONTH) != dayOfMonth 422 || mCurrentDate.get(Calendar.DAY_OF_MONTH) != month); 423 } 424 updateMin(PickerColumn column, int value)425 private static boolean updateMin(PickerColumn column, int value) { 426 if (value != column.getMinValue()) { 427 column.setMinValue(value); 428 return true; 429 } 430 return false; 431 } 432 updateMax(PickerColumn column, int value)433 private static boolean updateMax(PickerColumn column, int value) { 434 if (value != column.getMaxValue()) { 435 column.setMaxValue(value); 436 return true; 437 } 438 return false; 439 } 440 441 private static final int[] DATE_FIELDS = {Calendar.DAY_OF_MONTH, Calendar.MONTH, Calendar.YEAR}; 442 443 // Following implementation always keeps up-to-date date ranges (min & max values) no matter 444 // what the currently selected date is. This prevents the constant updating of date values while 445 // scrolling vertically and thus fixes the animation jumps that used to happen when we reached 446 // the endpoint date field values since the adapter values do not change while scrolling up 447 // & down across a single field. updateSpinnersImpl(boolean animation)448 void updateSpinnersImpl(boolean animation) { 449 // set the spinner ranges respecting the min and max dates 450 int dateFieldIndices[] = {mColDayIndex, mColMonthIndex, mColYearIndex}; 451 452 boolean allLargerDateFieldsHaveBeenEqualToMinDate = true; 453 boolean allLargerDateFieldsHaveBeenEqualToMaxDate = true; 454 for(int i = DATE_FIELDS.length - 1; i >= 0; i--) { 455 boolean dateFieldChanged = false; 456 if (dateFieldIndices[i] < 0) 457 continue; 458 459 int currField = DATE_FIELDS[i]; 460 PickerColumn currPickerColumn = getColumnAt(dateFieldIndices[i]); 461 462 if (allLargerDateFieldsHaveBeenEqualToMinDate) { 463 dateFieldChanged |= updateMin(currPickerColumn, 464 mMinDate.get(currField)); 465 } else { 466 dateFieldChanged |= updateMin(currPickerColumn, 467 mCurrentDate.getActualMinimum(currField)); 468 } 469 470 if (allLargerDateFieldsHaveBeenEqualToMaxDate) { 471 dateFieldChanged |= updateMax(currPickerColumn, 472 mMaxDate.get(currField)); 473 } else { 474 dateFieldChanged |= updateMax(currPickerColumn, 475 mCurrentDate.getActualMaximum(currField)); 476 } 477 478 allLargerDateFieldsHaveBeenEqualToMinDate &= 479 (mCurrentDate.get(currField) == mMinDate.get(currField)); 480 allLargerDateFieldsHaveBeenEqualToMaxDate &= 481 (mCurrentDate.get(currField) == mMaxDate.get(currField)); 482 483 if (dateFieldChanged) { 484 setColumnAt(dateFieldIndices[i], currPickerColumn); 485 } 486 setColumnValue(dateFieldIndices[i], mCurrentDate.get(currField), animation); 487 } 488 } 489 updateSpinners(final boolean animation)490 private void updateSpinners(final boolean animation) { 491 // update range in a post call. The reason is that RV does not allow notifyDataSetChange() 492 // in scroll pass. UpdateSpinner can be called in a scroll pass, UpdateSpinner() may 493 // notifyDataSetChange to update the range. 494 post(new Runnable() { 495 @Override 496 public void run() { 497 updateSpinnersImpl(animation); 498 } 499 }); 500 } 501 }