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