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 android.support.v17.leanback.widget.picker;
16 
17 import android.content.Context;
18 import android.content.res.TypedArray;
19 import android.support.v17.leanback.R;
20 import android.text.TextUtils;
21 import android.util.AttributeSet;
22 import android.util.Log;
23 
24 import java.text.DateFormat;
25 import java.text.ParseException;
26 import java.text.SimpleDateFormat;
27 import java.util.ArrayList;
28 import java.util.Calendar;
29 import java.util.Locale;
30 import java.util.TimeZone;
31 
32 /**
33  * {@link DatePicker} is a directly subclass of {@link Picker}.
34  * This class is a widget for selecting a date. The date can be selected by a
35  * year, month, and day Columns. The "minDate" and "maxDate" from which dates to be selected
36  * can be customized.  The columns can be customized by attribute "datePickerFormat" or
37  * {@link #setDatePickerFormat(String)}.
38  *
39  * @attr ref R.styleable#lbDatePicker_android_maxDate
40  * @attr ref R.styleable#lbDatePicker_android_minDate
41  * @attr ref R.styleable#lbDatePicker_datePickerFormat
42  * @hide
43  */
44 public class DatePicker extends Picker {
45 
46     static final String LOG_TAG = "DatePicker";
47 
48     private String mDatePickerFormat;
49     PickerColumn mMonthColumn;
50     PickerColumn mDayColumn;
51     PickerColumn mYearColumn;
52     int mColMonthIndex;
53     int mColDayIndex;
54     int mColYearIndex;
55 
56     final static String DATE_FORMAT = "MM/dd/yyyy";
57     final DateFormat mDateFormat = new SimpleDateFormat(DATE_FORMAT);
58     PickerConstant mConstant;
59 
60     Calendar mMinDate;
61     Calendar mMaxDate;
62     Calendar mCurrentDate;
63     Calendar mTempDate;
64 
DatePicker(Context context, AttributeSet attrs)65     public DatePicker(Context context, AttributeSet attrs) {
66         this(context, attrs, 0);
67     }
68 
DatePicker(Context context, AttributeSet attrs, int defStyleAttr)69     public DatePicker(Context context, AttributeSet attrs, int defStyleAttr) {
70         super(context, attrs, defStyleAttr);
71 
72         updateCurrentLocale();
73         setSeparator(mConstant.dateSeparator);
74 
75         final TypedArray attributesArray = context.obtainStyledAttributes(attrs,
76                 R.styleable.lbDatePicker);
77         String minDate = attributesArray.getString(R.styleable.lbDatePicker_android_minDate);
78         String maxDate = attributesArray.getString(R.styleable.lbDatePicker_android_maxDate);
79         mTempDate.clear();
80         if (!TextUtils.isEmpty(minDate)) {
81             if (!parseDate(minDate, mTempDate)) {
82                 mTempDate.set(1900, 0, 1);
83             }
84         } else {
85             mTempDate.set(1900, 0, 1);
86         }
87         mMinDate.setTimeInMillis(mTempDate.getTimeInMillis());
88 
89         mTempDate.clear();
90         if (!TextUtils.isEmpty(maxDate)) {
91             if (!parseDate(maxDate, mTempDate)) {
92                 mTempDate.set(2100, 0, 1);
93             }
94         } else {
95             mTempDate.set(2100, 0, 1);
96         }
97         mMaxDate.setTimeInMillis(mTempDate.getTimeInMillis());
98 
99         String datePickerFormat = attributesArray
100                 .getString(R.styleable.lbDatePicker_datePickerFormat);
101         if (TextUtils.isEmpty(datePickerFormat)) {
102             datePickerFormat = new String(
103                     android.text.format.DateFormat.getDateFormatOrder(context));
104         }
105         setDatePickerFormat(datePickerFormat);
106     }
107 
parseDate(String date, Calendar outDate)108     private boolean parseDate(String date, Calendar outDate) {
109         try {
110             outDate.setTime(mDateFormat.parse(date));
111             return true;
112         } catch (ParseException e) {
113             Log.w(LOG_TAG, "Date: " + date + " not in format: " + DATE_FORMAT);
114             return false;
115         }
116     }
117 
118     /**
119      * Changes format of showing dates.  For example "YMD".
120      * @param datePickerFormat Format of showing dates.
121      */
setDatePickerFormat(String datePickerFormat)122     public void setDatePickerFormat(String datePickerFormat) {
123         if (TextUtils.isEmpty(datePickerFormat)) {
124             datePickerFormat = new String(
125                     android.text.format.DateFormat.getDateFormatOrder(getContext()));
126         }
127         datePickerFormat = datePickerFormat.toUpperCase();
128         if (TextUtils.equals(mDatePickerFormat, datePickerFormat)) {
129             return;
130         }
131         mDatePickerFormat = datePickerFormat;
132         mYearColumn = mMonthColumn = mDayColumn = null;
133         mColYearIndex = mColDayIndex = mColMonthIndex = -1;
134         ArrayList<PickerColumn> columns = new ArrayList<PickerColumn>(3);
135         for (int i = 0; i < datePickerFormat.length(); i++) {
136             switch (datePickerFormat.charAt(i)) {
137             case 'Y':
138                 if (mYearColumn != null) {
139                     throw new IllegalArgumentException("datePicker format error");
140                 }
141                 columns.add(mYearColumn = new PickerColumn());
142                 mColYearIndex = i;
143                 mYearColumn.setLabelFormat("%d");
144                 break;
145             case 'M':
146                 if (mMonthColumn != null) {
147                     throw new IllegalArgumentException("datePicker format error");
148                 }
149                 columns.add(mMonthColumn = new PickerColumn());
150                 mMonthColumn.setStaticLabels(mConstant.months);
151                 mColMonthIndex = i;
152                 break;
153             case 'D':
154                 if (mDayColumn != null) {
155                     throw new IllegalArgumentException("datePicker format error");
156                 }
157                 columns.add(mDayColumn = new PickerColumn());
158                 mDayColumn.setLabelFormat("%02d");
159                 mColDayIndex = i;
160                 break;
161             default:
162                 throw new IllegalArgumentException("datePicker format error");
163             }
164         }
165         setColumns(columns);
166         updateSpinners(false);
167     }
168 
169     /**
170      * Get format of showing dates.  For example "YMD".  Default value is from
171      * {@link android.text.format.DateFormat#getDateFormatOrder(Context)}.
172      * @return Format of showing dates.
173      */
getDatePickerFormat()174     public String getDatePickerFormat() {
175         return mDatePickerFormat;
176     }
177 
getCalendarForLocale(Calendar oldCalendar, Locale locale)178     private Calendar getCalendarForLocale(Calendar oldCalendar, Locale locale) {
179         if (oldCalendar == null) {
180             return Calendar.getInstance(locale);
181         } else {
182             final long currentTimeMillis = oldCalendar.getTimeInMillis();
183             Calendar newCalendar = Calendar.getInstance(locale);
184             newCalendar.setTimeInMillis(currentTimeMillis);
185             return newCalendar;
186         }
187     }
188 
updateCurrentLocale()189     private void updateCurrentLocale() {
190         mConstant = new PickerConstant(Locale.getDefault(), getContext().getResources());
191         mTempDate = getCalendarForLocale(mTempDate, mConstant.locale);
192         mMinDate = getCalendarForLocale(mMinDate, mConstant.locale);
193         mMaxDate = getCalendarForLocale(mMaxDate, mConstant.locale);
194         mCurrentDate = getCalendarForLocale(mCurrentDate, mConstant.locale);
195 
196         if (mMonthColumn != null) {
197             mMonthColumn.setStaticLabels(mConstant.months);
198             setColumnAt(mColMonthIndex, mMonthColumn);
199         }
200     }
201 
202     @Override
onColumnValueChanged(int column, int newVal)203     public final void onColumnValueChanged(int column, int newVal) {
204         mTempDate.setTimeInMillis(mCurrentDate.getTimeInMillis());
205         // take care of wrapping of days and months to update greater fields
206         int oldVal = getColumnAt(column).getCurrentValue();
207         if (column == mColDayIndex) {
208             mTempDate.add(Calendar.DAY_OF_MONTH, newVal - oldVal);
209         } else if (column == mColMonthIndex) {
210             mTempDate.add(Calendar.MONTH, newVal - oldVal);
211         } else if (column == mColYearIndex) {
212             mTempDate.add(Calendar.YEAR, newVal - oldVal);
213         } else {
214             throw new IllegalArgumentException();
215         }
216         setDate(mTempDate.get(Calendar.YEAR), mTempDate.get(Calendar.MONTH),
217                 mTempDate.get(Calendar.DAY_OF_MONTH));
218         updateSpinners(false);
219     }
220 
221 
222     /**
223      * Sets the minimal date supported by this {@link DatePicker} in
224      * milliseconds since January 1, 1970 00:00:00 in
225      * {@link TimeZone#getDefault()} time zone.
226      *
227      * @param minDate The minimal supported date.
228      */
setMinDate(long minDate)229     public void setMinDate(long minDate) {
230         mTempDate.setTimeInMillis(minDate);
231         if (mTempDate.get(Calendar.YEAR) == mMinDate.get(Calendar.YEAR)
232                 && mTempDate.get(Calendar.DAY_OF_YEAR) != mMinDate.get(Calendar.DAY_OF_YEAR)) {
233             return;
234         }
235         mMinDate.setTimeInMillis(minDate);
236         if (mCurrentDate.before(mMinDate)) {
237             mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis());
238         }
239         updateSpinners(false);
240     }
241 
242 
243     /**
244      * Gets the minimal date supported by this {@link DatePicker} in
245      * milliseconds since January 1, 1970 00:00:00 in
246      * {@link TimeZone#getDefault()} time zone.
247      * <p>
248      * Note: The default minimal date is 01/01/1900.
249      * <p>
250      *
251      * @return The minimal supported date.
252      */
getMinDate()253     public long getMinDate() {
254         return mMinDate.getTimeInMillis();
255     }
256 
257     /**
258      * Sets the maximal date supported by this {@link DatePicker} in
259      * milliseconds since January 1, 1970 00:00:00 in
260      * {@link TimeZone#getDefault()} time zone.
261      *
262      * @param maxDate The maximal supported date.
263      */
setMaxDate(long maxDate)264     public void setMaxDate(long maxDate) {
265         mTempDate.setTimeInMillis(maxDate);
266         if (mTempDate.get(Calendar.YEAR) == mMaxDate.get(Calendar.YEAR)
267                 && mTempDate.get(Calendar.DAY_OF_YEAR) != mMaxDate.get(Calendar.DAY_OF_YEAR)) {
268             return;
269         }
270         mMaxDate.setTimeInMillis(maxDate);
271         if (mCurrentDate.after(mMaxDate)) {
272             mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis());
273         }
274         updateSpinners(false);
275     }
276 
277     /**
278      * Gets the maximal date supported by this {@link DatePicker} in
279      * milliseconds since January 1, 1970 00:00:00 in
280      * {@link TimeZone#getDefault()} time zone.
281      * <p>
282      * Note: The default maximal date is 12/31/2100.
283      * <p>
284      *
285      * @return The maximal supported date.
286      */
getMaxDate()287     public long getMaxDate() {
288         return mMaxDate.getTimeInMillis();
289     }
290 
291     /**
292      * Gets current date value in milliseconds since January 1, 1970 00:00:00 in
293      * {@link TimeZone#getDefault()} time zone.
294      *
295      * @return Current date values.
296      */
getDate()297     public long getDate() {
298         return mCurrentDate.getTimeInMillis();
299     }
300 
setDate(int year, int month, int dayOfMonth)301     private void setDate(int year, int month, int dayOfMonth) {
302         mCurrentDate.set(year, month, dayOfMonth);
303         if (mCurrentDate.before(mMinDate)) {
304             mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis());
305         } else if (mCurrentDate.after(mMaxDate)) {
306             mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis());
307         }
308     }
309 
310     /**
311      * Update the current date.
312      *
313      * @param year The year.
314      * @param month The month which is <strong>starting from zero</strong>.
315      * @param dayOfMonth The day of the month.
316      * @param animation True to run animation to scroll the column.
317      */
updateDate(int year, int month, int dayOfMonth, boolean animation)318     public void updateDate(int year, int month, int dayOfMonth, boolean animation) {
319         if (!isNewDate(year, month, dayOfMonth)) {
320             return;
321         }
322         setDate(year, month, dayOfMonth);
323         updateSpinners(animation);
324     }
325 
isNewDate(int year, int month, int dayOfMonth)326     private boolean isNewDate(int year, int month, int dayOfMonth) {
327         return (mCurrentDate.get(Calendar.YEAR) != year
328                 || mCurrentDate.get(Calendar.MONTH) != dayOfMonth
329                 || mCurrentDate.get(Calendar.DAY_OF_MONTH) != month);
330     }
331 
updateMin(PickerColumn column, int value)332     private static boolean updateMin(PickerColumn column, int value) {
333         if (value != column.getMinValue()) {
334             column.setMinValue(value);
335             return true;
336         }
337         return false;
338     }
339 
updateMax(PickerColumn column, int value)340     private static boolean updateMax(PickerColumn column, int value) {
341         if (value != column.getMaxValue()) {
342             column.setMaxValue(value);
343             return true;
344         }
345         return false;
346     }
347 
348     private static int[] DATE_FIELDS = {Calendar.DAY_OF_MONTH, Calendar.MONTH, Calendar.YEAR};
349 
350     // Following implementation always keeps up-to-date date ranges (min & max values) no matter
351     // what the currently selected date is. This prevents the constant updating of date values while
352     // scrolling vertically and thus fixes the animation jumps that used to happen when we reached
353     // the endpoint date field values since the adapter values do not change while scrolling up
354     // & down across a single field.
updateSpinnersImpl(boolean animation)355     private void updateSpinnersImpl(boolean animation) {
356         // set the spinner ranges respecting the min and max dates
357         int dateFieldIndices[] = {mColDayIndex, mColMonthIndex, mColYearIndex};
358 
359         boolean allLargerDateFieldsHaveBeenEqualToMinDate = true;
360         boolean allLargerDateFieldsHaveBeenEqualToMaxDate = true;
361         for(int i = DATE_FIELDS.length - 1; i >= 0; i--) {
362             boolean dateFieldChanged = false;
363             if (dateFieldIndices[i] < 0)
364                 continue;
365 
366             int currField = DATE_FIELDS[i];
367             PickerColumn currPickerColumn = getColumnAt(dateFieldIndices[i]);
368 
369             if (allLargerDateFieldsHaveBeenEqualToMinDate) {
370                 dateFieldChanged |= updateMin(currPickerColumn,
371                         mMinDate.get(currField));
372             } else {
373                 dateFieldChanged |= updateMin(currPickerColumn,
374                         mCurrentDate.getActualMinimum(currField));
375             }
376 
377             if (allLargerDateFieldsHaveBeenEqualToMaxDate) {
378                 dateFieldChanged |= updateMax(currPickerColumn,
379                         mMaxDate.get(currField));
380             } else {
381                 dateFieldChanged |= updateMax(currPickerColumn,
382                         mCurrentDate.getActualMaximum(currField));
383             }
384 
385             allLargerDateFieldsHaveBeenEqualToMinDate &=
386                     (mCurrentDate.get(currField) == mMinDate.get(currField));
387             allLargerDateFieldsHaveBeenEqualToMaxDate &=
388                     (mCurrentDate.get(currField) == mMaxDate.get(currField));
389 
390             if (dateFieldChanged) {
391                 setColumnAt(dateFieldIndices[i], currPickerColumn);
392             }
393             setColumnValue(dateFieldIndices[i], mCurrentDate.get(currField), animation);
394         }
395     }
396 
updateSpinners(final boolean animation)397     private void updateSpinners(final boolean animation) {
398         // update range in a post call.  The reason is that RV does not allow notifyDataSetChange()
399         // in scroll pass.  UpdateSpinner can be called in a scroll pass, UpdateSpinner() may
400         // notifyDataSetChange to update the range.
401         post(new Runnable() {
402             public void run() {
403                 updateSpinnersImpl(animation);
404             }
405         });
406     }
407 }