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