Home | History | Annotate | Download | only in picker
      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 
     71     public DatePicker(Context context, AttributeSet attrs) {
     72         this(context, attrs, 0);
     73     }
     74 
     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 
    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      */
    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      */
    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 
    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      */
    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      */
    277     public String getDatePickerFormat() {
    278         return mDatePickerFormat;
    279     }
    280 
    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
    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      */
    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      */
    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      */
    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      */
    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      */
    390     public long getDate() {
    391         return mCurrentDate.getTimeInMillis();
    392     }
    393 
    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      */
    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 
    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 
    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 
    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.
    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 
    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 }