TimePickerClockDelegate.java revision 67945c11a5e9547f71be91ceb99e7b9ff15a6292
1/*
2 * Copyright (C) 2013 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.content.Context;
20import android.content.res.Configuration;
21import android.content.res.TypedArray;
22import android.os.Parcel;
23import android.os.Parcelable;
24import android.text.format.DateFormat;
25import android.text.format.DateUtils;
26import android.util.AttributeSet;
27import android.view.LayoutInflater;
28import android.view.View;
29import android.view.ViewGroup;
30import android.view.accessibility.AccessibilityEvent;
31import android.view.accessibility.AccessibilityNodeInfo;
32import android.view.inputmethod.EditorInfo;
33import android.view.inputmethod.InputMethodManager;
34import com.android.internal.R;
35
36import java.text.DateFormatSymbols;
37import java.util.Calendar;
38import java.util.Locale;
39
40import libcore.icu.LocaleData;
41
42import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_AUTO;
43import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_YES;
44
45/**
46 * A delegate implementing the basic spinner-based TimePicker.
47 */
48class TimePickerClockDelegate extends TimePicker.AbstractTimePickerDelegate {
49    private static final boolean DEFAULT_ENABLED_STATE = true;
50    private static final int HOURS_IN_HALF_DAY = 12;
51
52    // state
53    private boolean mIs24HourView;
54    private boolean mIsAm;
55
56    // ui components
57    private final NumberPicker mHourSpinner;
58    private final NumberPicker mMinuteSpinner;
59    private final NumberPicker mAmPmSpinner;
60    private final EditText mHourSpinnerInput;
61    private final EditText mMinuteSpinnerInput;
62    private final EditText mAmPmSpinnerInput;
63    private final TextView mDivider;
64
65    // Note that the legacy implementation of the TimePicker is
66    // using a button for toggling between AM/PM while the new
67    // version uses a NumberPicker spinner. Therefore the code
68    // accommodates these two cases to be backwards compatible.
69    private final Button mAmPmButton;
70
71    private final String[] mAmPmStrings;
72
73    private boolean mIsEnabled = DEFAULT_ENABLED_STATE;
74    private Calendar mTempCalendar;
75    private boolean mHourWithTwoDigit;
76    private char mHourFormat;
77
78    public TimePickerClockDelegate(TimePicker delegator, Context context, AttributeSet attrs,
79            int defStyleAttr, int defStyleRes) {
80        super(delegator, context);
81
82        // process style attributes
83        final TypedArray a = mContext.obtainStyledAttributes(
84                attrs, R.styleable.TimePicker, defStyleAttr, defStyleRes);
85        final int layoutResourceId = a.getResourceId(
86                R.styleable.TimePicker_legacyLayout, R.layout.time_picker_legacy);
87        a.recycle();
88
89        final LayoutInflater inflater = LayoutInflater.from(mContext);
90        inflater.inflate(layoutResourceId, mDelegator, true);
91
92        // hour
93        mHourSpinner = (NumberPicker) delegator.findViewById(R.id.hour);
94        mHourSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() {
95            public void onValueChange(NumberPicker spinner, int oldVal, int newVal) {
96                updateInputState();
97                if (!is24HourView()) {
98                    if ((oldVal == HOURS_IN_HALF_DAY - 1 && newVal == HOURS_IN_HALF_DAY) ||
99                            (oldVal == HOURS_IN_HALF_DAY && newVal == HOURS_IN_HALF_DAY - 1)) {
100                        mIsAm = !mIsAm;
101                        updateAmPmControl();
102                    }
103                }
104                onTimeChanged();
105            }
106        });
107        mHourSpinnerInput = (EditText) mHourSpinner.findViewById(R.id.numberpicker_input);
108        mHourSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT);
109
110        // divider (only for the new widget style)
111        mDivider = (TextView) mDelegator.findViewById(R.id.divider);
112        if (mDivider != null) {
113            setDividerText();
114        }
115
116        // minute
117        mMinuteSpinner = (NumberPicker) mDelegator.findViewById(R.id.minute);
118        mMinuteSpinner.setMinValue(0);
119        mMinuteSpinner.setMaxValue(59);
120        mMinuteSpinner.setOnLongPressUpdateInterval(100);
121        mMinuteSpinner.setFormatter(NumberPicker.getTwoDigitFormatter());
122        mMinuteSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() {
123            public void onValueChange(NumberPicker spinner, int oldVal, int newVal) {
124                updateInputState();
125                int minValue = mMinuteSpinner.getMinValue();
126                int maxValue = mMinuteSpinner.getMaxValue();
127                if (oldVal == maxValue && newVal == minValue) {
128                    int newHour = mHourSpinner.getValue() + 1;
129                    if (!is24HourView() && newHour == HOURS_IN_HALF_DAY) {
130                        mIsAm = !mIsAm;
131                        updateAmPmControl();
132                    }
133                    mHourSpinner.setValue(newHour);
134                } else if (oldVal == minValue && newVal == maxValue) {
135                    int newHour = mHourSpinner.getValue() - 1;
136                    if (!is24HourView() && newHour == HOURS_IN_HALF_DAY - 1) {
137                        mIsAm = !mIsAm;
138                        updateAmPmControl();
139                    }
140                    mHourSpinner.setValue(newHour);
141                }
142                onTimeChanged();
143            }
144        });
145        mMinuteSpinnerInput = (EditText) mMinuteSpinner.findViewById(R.id.numberpicker_input);
146        mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT);
147
148        // Get the localized am/pm strings and use them in the spinner.
149        mAmPmStrings = getAmPmStrings(context);
150
151        // am/pm
152        final View amPmView = mDelegator.findViewById(R.id.amPm);
153        if (amPmView instanceof Button) {
154            mAmPmSpinner = null;
155            mAmPmSpinnerInput = null;
156            mAmPmButton = (Button) amPmView;
157            mAmPmButton.setOnClickListener(new View.OnClickListener() {
158                public void onClick(View button) {
159                    button.requestFocus();
160                    mIsAm = !mIsAm;
161                    updateAmPmControl();
162                    onTimeChanged();
163                }
164            });
165        } else {
166            mAmPmButton = null;
167            mAmPmSpinner = (NumberPicker) amPmView;
168            mAmPmSpinner.setMinValue(0);
169            mAmPmSpinner.setMaxValue(1);
170            mAmPmSpinner.setDisplayedValues(mAmPmStrings);
171            mAmPmSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() {
172                public void onValueChange(NumberPicker picker, int oldVal, int newVal) {
173                    updateInputState();
174                    picker.requestFocus();
175                    mIsAm = !mIsAm;
176                    updateAmPmControl();
177                    onTimeChanged();
178                }
179            });
180            mAmPmSpinnerInput = (EditText) mAmPmSpinner.findViewById(R.id.numberpicker_input);
181            mAmPmSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_DONE);
182        }
183
184        if (isAmPmAtStart()) {
185            // Move the am/pm view to the beginning
186            ViewGroup amPmParent = (ViewGroup) delegator.findViewById(R.id.timePickerLayout);
187            amPmParent.removeView(amPmView);
188            amPmParent.addView(amPmView, 0);
189            // Swap layout margins if needed. They may be not symmetrical (Old Standard Theme
190            // for example and not for Holo Theme)
191            ViewGroup.MarginLayoutParams lp =
192                    (ViewGroup.MarginLayoutParams) amPmView.getLayoutParams();
193            final int startMargin = lp.getMarginStart();
194            final int endMargin = lp.getMarginEnd();
195            if (startMargin != endMargin) {
196                lp.setMarginStart(endMargin);
197                lp.setMarginEnd(startMargin);
198            }
199        }
200
201        getHourFormatData();
202
203        // update controls to initial state
204        updateHourControl();
205        updateMinuteControl();
206        updateAmPmControl();
207
208        // set to current time
209        setCurrentHour(mTempCalendar.get(Calendar.HOUR_OF_DAY));
210        setCurrentMinute(mTempCalendar.get(Calendar.MINUTE));
211
212        if (!isEnabled()) {
213            setEnabled(false);
214        }
215
216        // set the content descriptions
217        setContentDescriptions();
218
219        // If not explicitly specified this view is important for accessibility.
220        if (mDelegator.getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
221            mDelegator.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
222        }
223    }
224
225    private void getHourFormatData() {
226        final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale,
227                (mIs24HourView) ? "Hm" : "hm");
228        final int lengthPattern = bestDateTimePattern.length();
229        mHourWithTwoDigit = false;
230        char hourFormat = '\0';
231        // Check if the returned pattern is single or double 'H', 'h', 'K', 'k'. We also save
232        // the hour format that we found.
233        for (int i = 0; i < lengthPattern; i++) {
234            final char c = bestDateTimePattern.charAt(i);
235            if (c == 'H' || c == 'h' || c == 'K' || c == 'k') {
236                mHourFormat = c;
237                if (i + 1 < lengthPattern && c == bestDateTimePattern.charAt(i + 1)) {
238                    mHourWithTwoDigit = true;
239                }
240                break;
241            }
242        }
243    }
244
245    private boolean isAmPmAtStart() {
246        final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale,
247                "hm" /* skeleton */);
248
249        return bestDateTimePattern.startsWith("a");
250    }
251
252    /**
253     * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":".
254     *
255     * See http://unicode.org/cldr/trac/browser/trunk/common/main
256     *
257     * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the
258     * separator as the character which is just after the hour marker in the returned pattern.
259     */
260    private void setDividerText() {
261        final String skeleton = (mIs24HourView) ? "Hm" : "hm";
262        final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale,
263                skeleton);
264        final String separatorText;
265        int hourIndex = bestDateTimePattern.lastIndexOf('H');
266        if (hourIndex == -1) {
267            hourIndex = bestDateTimePattern.lastIndexOf('h');
268        }
269        if (hourIndex == -1) {
270            // Default case
271            separatorText = ":";
272        } else {
273            int minuteIndex = bestDateTimePattern.indexOf('m', hourIndex + 1);
274            if  (minuteIndex == -1) {
275                separatorText = Character.toString(bestDateTimePattern.charAt(hourIndex + 1));
276            } else {
277                separatorText = bestDateTimePattern.substring(hourIndex + 1, minuteIndex);
278            }
279        }
280        mDivider.setText(separatorText);
281    }
282
283    @Override
284    public void setCurrentHour(Integer currentHour) {
285        setCurrentHour(currentHour, true);
286    }
287
288    private void setCurrentHour(Integer currentHour, boolean notifyTimeChanged) {
289        // why was Integer used in the first place?
290        if (currentHour == null || currentHour == getCurrentHour()) {
291            return;
292        }
293        if (!is24HourView()) {
294            // convert [0,23] ordinal to wall clock display
295            if (currentHour >= HOURS_IN_HALF_DAY) {
296                mIsAm = false;
297                if (currentHour > HOURS_IN_HALF_DAY) {
298                    currentHour = currentHour - HOURS_IN_HALF_DAY;
299                }
300            } else {
301                mIsAm = true;
302                if (currentHour == 0) {
303                    currentHour = HOURS_IN_HALF_DAY;
304                }
305            }
306            updateAmPmControl();
307        }
308        mHourSpinner.setValue(currentHour);
309        if (notifyTimeChanged) {
310            onTimeChanged();
311        }
312    }
313
314    @Override
315    public Integer getCurrentHour() {
316        int currentHour = mHourSpinner.getValue();
317        if (is24HourView()) {
318            return currentHour;
319        } else if (mIsAm) {
320            return currentHour % HOURS_IN_HALF_DAY;
321        } else {
322            return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY;
323        }
324    }
325
326    @Override
327    public void setCurrentMinute(Integer currentMinute) {
328        if (currentMinute == getCurrentMinute()) {
329            return;
330        }
331        mMinuteSpinner.setValue(currentMinute);
332        onTimeChanged();
333    }
334
335    @Override
336    public Integer getCurrentMinute() {
337        return mMinuteSpinner.getValue();
338    }
339
340    @Override
341    public void setIs24HourView(Boolean is24HourView) {
342        if (mIs24HourView == is24HourView) {
343            return;
344        }
345        // cache the current hour since spinner range changes and BEFORE changing mIs24HourView!!
346        int currentHour = getCurrentHour();
347        // Order is important here.
348        mIs24HourView = is24HourView;
349        getHourFormatData();
350        updateHourControl();
351        // set value after spinner range is updated
352        setCurrentHour(currentHour, false);
353        updateMinuteControl();
354        updateAmPmControl();
355    }
356
357    @Override
358    public boolean is24HourView() {
359        return mIs24HourView;
360    }
361
362    @Override
363    public void setOnTimeChangedListener(TimePicker.OnTimeChangedListener onTimeChangedListener) {
364        mOnTimeChangedListener = onTimeChangedListener;
365    }
366
367    @Override
368    public void setEnabled(boolean enabled) {
369        mMinuteSpinner.setEnabled(enabled);
370        if (mDivider != null) {
371            mDivider.setEnabled(enabled);
372        }
373        mHourSpinner.setEnabled(enabled);
374        if (mAmPmSpinner != null) {
375            mAmPmSpinner.setEnabled(enabled);
376        } else {
377            mAmPmButton.setEnabled(enabled);
378        }
379        mIsEnabled = enabled;
380    }
381
382    @Override
383    public boolean isEnabled() {
384        return mIsEnabled;
385    }
386
387    @Override
388    public int getBaseline() {
389        return mHourSpinner.getBaseline();
390    }
391
392    @Override
393    public void onConfigurationChanged(Configuration newConfig) {
394        setCurrentLocale(newConfig.locale);
395    }
396
397    @Override
398    public Parcelable onSaveInstanceState(Parcelable superState) {
399        return new SavedState(superState, getCurrentHour(), getCurrentMinute());
400    }
401
402    @Override
403    public void onRestoreInstanceState(Parcelable state) {
404        SavedState ss = (SavedState) state;
405        setCurrentHour(ss.getHour());
406        setCurrentMinute(ss.getMinute());
407    }
408
409    @Override
410    public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
411        onPopulateAccessibilityEvent(event);
412        return true;
413    }
414
415    @Override
416    public void onPopulateAccessibilityEvent(AccessibilityEvent event) {
417        int flags = DateUtils.FORMAT_SHOW_TIME;
418        if (mIs24HourView) {
419            flags |= DateUtils.FORMAT_24HOUR;
420        } else {
421            flags |= DateUtils.FORMAT_12HOUR;
422        }
423        mTempCalendar.set(Calendar.HOUR_OF_DAY, getCurrentHour());
424        mTempCalendar.set(Calendar.MINUTE, getCurrentMinute());
425        String selectedDateUtterance = DateUtils.formatDateTime(mContext,
426                mTempCalendar.getTimeInMillis(), flags);
427        event.getText().add(selectedDateUtterance);
428    }
429
430    @Override
431    public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
432        event.setClassName(TimePicker.class.getName());
433    }
434
435    @Override
436    public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
437        info.setClassName(TimePicker.class.getName());
438    }
439
440    private void updateInputState() {
441        // Make sure that if the user changes the value and the IME is active
442        // for one of the inputs if this widget, the IME is closed. If the user
443        // changed the value via the IME and there is a next input the IME will
444        // be shown, otherwise the user chose another means of changing the
445        // value and having the IME up makes no sense.
446        InputMethodManager inputMethodManager = InputMethodManager.peekInstance();
447        if (inputMethodManager != null) {
448            if (inputMethodManager.isActive(mHourSpinnerInput)) {
449                mHourSpinnerInput.clearFocus();
450                inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0);
451            } else if (inputMethodManager.isActive(mMinuteSpinnerInput)) {
452                mMinuteSpinnerInput.clearFocus();
453                inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0);
454            } else if (inputMethodManager.isActive(mAmPmSpinnerInput)) {
455                mAmPmSpinnerInput.clearFocus();
456                inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0);
457            }
458        }
459    }
460
461    private void updateAmPmControl() {
462        if (is24HourView()) {
463            if (mAmPmSpinner != null) {
464                mAmPmSpinner.setVisibility(View.GONE);
465            } else {
466                mAmPmButton.setVisibility(View.GONE);
467            }
468        } else {
469            int index = mIsAm ? Calendar.AM : Calendar.PM;
470            if (mAmPmSpinner != null) {
471                mAmPmSpinner.setValue(index);
472                mAmPmSpinner.setVisibility(View.VISIBLE);
473            } else {
474                mAmPmButton.setText(mAmPmStrings[index]);
475                mAmPmButton.setVisibility(View.VISIBLE);
476            }
477        }
478        mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
479    }
480
481    /**
482     * Sets the current locale.
483     *
484     * @param locale The current locale.
485     */
486    @Override
487    public void setCurrentLocale(Locale locale) {
488        super.setCurrentLocale(locale);
489        mTempCalendar = Calendar.getInstance(locale);
490    }
491
492    private void onTimeChanged() {
493        mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
494        if (mOnTimeChangedListener != null) {
495            mOnTimeChangedListener.onTimeChanged(mDelegator, getCurrentHour(),
496                    getCurrentMinute());
497        }
498    }
499
500    private void updateHourControl() {
501        if (is24HourView()) {
502            // 'k' means 1-24 hour
503            if (mHourFormat == 'k') {
504                mHourSpinner.setMinValue(1);
505                mHourSpinner.setMaxValue(24);
506            } else {
507                mHourSpinner.setMinValue(0);
508                mHourSpinner.setMaxValue(23);
509            }
510        } else {
511            // 'K' means 0-11 hour
512            if (mHourFormat == 'K') {
513                mHourSpinner.setMinValue(0);
514                mHourSpinner.setMaxValue(11);
515            } else {
516                mHourSpinner.setMinValue(1);
517                mHourSpinner.setMaxValue(12);
518            }
519        }
520        mHourSpinner.setFormatter(mHourWithTwoDigit ? NumberPicker.getTwoDigitFormatter() : null);
521    }
522
523    private void updateMinuteControl() {
524        if (is24HourView()) {
525            mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_DONE);
526        } else {
527            mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT);
528        }
529    }
530
531    private void setContentDescriptions() {
532        // Minute
533        trySetContentDescription(mMinuteSpinner, R.id.increment,
534                R.string.time_picker_increment_minute_button);
535        trySetContentDescription(mMinuteSpinner, R.id.decrement,
536                R.string.time_picker_decrement_minute_button);
537        // Hour
538        trySetContentDescription(mHourSpinner, R.id.increment,
539                R.string.time_picker_increment_hour_button);
540        trySetContentDescription(mHourSpinner, R.id.decrement,
541                R.string.time_picker_decrement_hour_button);
542        // AM/PM
543        if (mAmPmSpinner != null) {
544            trySetContentDescription(mAmPmSpinner, R.id.increment,
545                    R.string.time_picker_increment_set_pm_button);
546            trySetContentDescription(mAmPmSpinner, R.id.decrement,
547                    R.string.time_picker_decrement_set_am_button);
548        }
549    }
550
551    private void trySetContentDescription(View root, int viewId, int contDescResId) {
552        View target = root.findViewById(viewId);
553        if (target != null) {
554            target.setContentDescription(mContext.getString(contDescResId));
555        }
556    }
557
558    /**
559     * Used to save / restore state of time picker
560     */
561    private static class SavedState extends View.BaseSavedState {
562        private final int mHour;
563        private final int mMinute;
564
565        private SavedState(Parcelable superState, int hour, int minute) {
566            super(superState);
567            mHour = hour;
568            mMinute = minute;
569        }
570
571        private SavedState(Parcel in) {
572            super(in);
573            mHour = in.readInt();
574            mMinute = in.readInt();
575        }
576
577        public int getHour() {
578            return mHour;
579        }
580
581        public int getMinute() {
582            return mMinute;
583        }
584
585        @Override
586        public void writeToParcel(Parcel dest, int flags) {
587            super.writeToParcel(dest, flags);
588            dest.writeInt(mHour);
589            dest.writeInt(mMinute);
590        }
591
592        @SuppressWarnings({"unused", "hiding"})
593        public static final Parcelable.Creator<SavedState> CREATOR = new Creator<SavedState>() {
594            public SavedState createFromParcel(Parcel in) {
595                return new SavedState(in);
596            }
597
598            public SavedState[] newArray(int size) {
599                return new SavedState[size];
600            }
601        };
602    }
603
604    public static String[] getAmPmStrings(Context context) {
605        String[] result = new String[2];
606        LocaleData d = LocaleData.get(context.getResources().getConfiguration().locale);
607        result[0] = d.amPm[0].length() > 2 ? d.narrowAm : d.amPm[0];
608        result[1] = d.amPm[1].length() > 2 ? d.narrowPm : d.amPm[1];
609        return result;
610    }
611}
612