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