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