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