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