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