Home | History | Annotate | Download | only in widget
      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 
     17 package android.widget;
     18 
     19 import android.annotation.IntDef;
     20 import android.annotation.Nullable;
     21 import android.annotation.TestApi;
     22 import android.content.Context;
     23 import android.content.res.ColorStateList;
     24 import android.content.res.Resources;
     25 import android.content.res.TypedArray;
     26 import android.icu.text.DecimalFormatSymbols;
     27 import android.os.Parcelable;
     28 import android.text.SpannableStringBuilder;
     29 import android.text.format.DateFormat;
     30 import android.text.format.DateUtils;
     31 import android.text.style.TtsSpan;
     32 import android.util.AttributeSet;
     33 import android.util.StateSet;
     34 import android.view.HapticFeedbackConstants;
     35 import android.view.LayoutInflater;
     36 import android.view.MotionEvent;
     37 import android.view.View;
     38 import android.view.View.AccessibilityDelegate;
     39 import android.view.View.MeasureSpec;
     40 import android.view.ViewGroup;
     41 import android.view.accessibility.AccessibilityEvent;
     42 import android.view.accessibility.AccessibilityNodeInfo;
     43 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
     44 import android.widget.RadialTimePickerView.OnValueSelectedListener;
     45 import android.widget.TextInputTimePickerView.OnValueTypedListener;
     46 
     47 import com.android.internal.R;
     48 import com.android.internal.widget.NumericTextView;
     49 import com.android.internal.widget.NumericTextView.OnValueChangedListener;
     50 
     51 import java.lang.annotation.Retention;
     52 import java.lang.annotation.RetentionPolicy;
     53 import java.util.Calendar;
     54 
     55 /**
     56  * A delegate implementing the radial clock-based TimePicker.
     57  */
     58 class TimePickerClockDelegate extends TimePicker.AbstractTimePickerDelegate {
     59     /**
     60      * Delay in milliseconds before valid but potentially incomplete, for
     61      * example "1" but not "12", keyboard edits are propagated from the
     62      * hour / minute fields to the radial picker.
     63      */
     64     private static final long DELAY_COMMIT_MILLIS = 2000;
     65 
     66     @IntDef({FROM_EXTERNAL_API, FROM_RADIAL_PICKER, FROM_INPUT_PICKER})
     67     @Retention(RetentionPolicy.SOURCE)
     68     private @interface ChangeSource {}
     69     private static final int FROM_EXTERNAL_API = 0;
     70     private static final int FROM_RADIAL_PICKER = 1;
     71     private static final int FROM_INPUT_PICKER = 2;
     72 
     73     // Index used by RadialPickerLayout
     74     private static final int HOUR_INDEX = RadialTimePickerView.HOURS;
     75     private static final int MINUTE_INDEX = RadialTimePickerView.MINUTES;
     76 
     77     private static final int[] ATTRS_TEXT_COLOR = new int[] {R.attr.textColor};
     78     private static final int[] ATTRS_DISABLED_ALPHA = new int[] {R.attr.disabledAlpha};
     79 
     80     private static final int AM = 0;
     81     private static final int PM = 1;
     82 
     83     private static final int HOURS_IN_HALF_DAY = 12;
     84 
     85     private final NumericTextView mHourView;
     86     private final NumericTextView mMinuteView;
     87     private final View mAmPmLayout;
     88     private final RadioButton mAmLabel;
     89     private final RadioButton mPmLabel;
     90     private final RadialTimePickerView mRadialTimePickerView;
     91     private final TextView mSeparatorView;
     92 
     93     private boolean mRadialPickerModeEnabled = true;
     94     private final ImageButton mRadialTimePickerModeButton;
     95     private final String mRadialTimePickerModeEnabledDescription;
     96     private final String mTextInputPickerModeEnabledDescription;
     97     private final View mRadialTimePickerHeader;
     98     private final View mTextInputPickerHeader;
     99 
    100     private final TextInputTimePickerView mTextInputPickerView;
    101 
    102     private final Calendar mTempCalendar;
    103 
    104     // Accessibility strings.
    105     private final String mSelectHours;
    106     private final String mSelectMinutes;
    107 
    108     private boolean mIsEnabled = true;
    109     private boolean mAllowAutoAdvance;
    110     private int mCurrentHour;
    111     private int mCurrentMinute;
    112     private boolean mIs24Hour;
    113     private boolean mIsAmPmAtStart;
    114 
    115     // Localization data.
    116     private boolean mHourFormatShowLeadingZero;
    117     private boolean mHourFormatStartsAtZero;
    118 
    119     // Most recent time announcement values for accessibility.
    120     private CharSequence mLastAnnouncedText;
    121     private boolean mLastAnnouncedIsHour;
    122 
    123     public TimePickerClockDelegate(TimePicker delegator, Context context, AttributeSet attrs,
    124             int defStyleAttr, int defStyleRes) {
    125         super(delegator, context);
    126 
    127         // process style attributes
    128         final TypedArray a = mContext.obtainStyledAttributes(attrs,
    129                 R.styleable.TimePicker, defStyleAttr, defStyleRes);
    130         final LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(
    131                 Context.LAYOUT_INFLATER_SERVICE);
    132         final Resources res = mContext.getResources();
    133 
    134         mSelectHours = res.getString(R.string.select_hours);
    135         mSelectMinutes = res.getString(R.string.select_minutes);
    136 
    137         final int layoutResourceId = a.getResourceId(R.styleable.TimePicker_internalLayout,
    138                 R.layout.time_picker_material);
    139         final View mainView = inflater.inflate(layoutResourceId, delegator);
    140         mainView.setSaveFromParentEnabled(false);
    141         mRadialTimePickerHeader = mainView.findViewById(R.id.time_header);
    142         mRadialTimePickerHeader.setOnTouchListener(new NearestTouchDelegate());
    143 
    144         // Set up hour/minute labels.
    145         mHourView = (NumericTextView) mainView.findViewById(R.id.hours);
    146         mHourView.setOnClickListener(mClickListener);
    147         mHourView.setOnFocusChangeListener(mFocusListener);
    148         mHourView.setOnDigitEnteredListener(mDigitEnteredListener);
    149         mHourView.setAccessibilityDelegate(
    150                 new ClickActionDelegate(context, R.string.select_hours));
    151         mSeparatorView = (TextView) mainView.findViewById(R.id.separator);
    152         mMinuteView = (NumericTextView) mainView.findViewById(R.id.minutes);
    153         mMinuteView.setOnClickListener(mClickListener);
    154         mMinuteView.setOnFocusChangeListener(mFocusListener);
    155         mMinuteView.setOnDigitEnteredListener(mDigitEnteredListener);
    156         mMinuteView.setAccessibilityDelegate(
    157                 new ClickActionDelegate(context, R.string.select_minutes));
    158         mMinuteView.setRange(0, 59);
    159 
    160         // Set up AM/PM labels.
    161         mAmPmLayout = mainView.findViewById(R.id.ampm_layout);
    162         mAmPmLayout.setOnTouchListener(new NearestTouchDelegate());
    163 
    164         final String[] amPmStrings = TimePicker.getAmPmStrings(context);
    165         mAmLabel = (RadioButton) mAmPmLayout.findViewById(R.id.am_label);
    166         mAmLabel.setText(obtainVerbatim(amPmStrings[0]));
    167         mAmLabel.setOnClickListener(mClickListener);
    168         ensureMinimumTextWidth(mAmLabel);
    169 
    170         mPmLabel = (RadioButton) mAmPmLayout.findViewById(R.id.pm_label);
    171         mPmLabel.setText(obtainVerbatim(amPmStrings[1]));
    172         mPmLabel.setOnClickListener(mClickListener);
    173         ensureMinimumTextWidth(mPmLabel);
    174 
    175         // For the sake of backwards compatibility, attempt to extract the text
    176         // color from the header time text appearance. If it's set, we'll let
    177         // that override the "real" header text color.
    178         ColorStateList headerTextColor = null;
    179 
    180         @SuppressWarnings("deprecation")
    181         final int timeHeaderTextAppearance = a.getResourceId(
    182                 R.styleable.TimePicker_headerTimeTextAppearance, 0);
    183         if (timeHeaderTextAppearance != 0) {
    184             final TypedArray textAppearance = mContext.obtainStyledAttributes(null,
    185                     ATTRS_TEXT_COLOR, 0, timeHeaderTextAppearance);
    186             final ColorStateList legacyHeaderTextColor = textAppearance.getColorStateList(0);
    187             headerTextColor = applyLegacyColorFixes(legacyHeaderTextColor);
    188             textAppearance.recycle();
    189         }
    190 
    191         if (headerTextColor == null) {
    192             headerTextColor = a.getColorStateList(R.styleable.TimePicker_headerTextColor);
    193         }
    194 
    195         mTextInputPickerHeader = mainView.findViewById(R.id.input_header);
    196 
    197         if (headerTextColor != null) {
    198             mHourView.setTextColor(headerTextColor);
    199             mSeparatorView.setTextColor(headerTextColor);
    200             mMinuteView.setTextColor(headerTextColor);
    201             mAmLabel.setTextColor(headerTextColor);
    202             mPmLabel.setTextColor(headerTextColor);
    203         }
    204 
    205         // Set up header background, if available.
    206         if (a.hasValueOrEmpty(R.styleable.TimePicker_headerBackground)) {
    207             mRadialTimePickerHeader.setBackground(a.getDrawable(
    208                     R.styleable.TimePicker_headerBackground));
    209             mTextInputPickerHeader.setBackground(a.getDrawable(
    210                     R.styleable.TimePicker_headerBackground));
    211         }
    212 
    213         a.recycle();
    214 
    215         mRadialTimePickerView = (RadialTimePickerView) mainView.findViewById(R.id.radial_picker);
    216         mRadialTimePickerView.applyAttributes(attrs, defStyleAttr, defStyleRes);
    217         mRadialTimePickerView.setOnValueSelectedListener(mOnValueSelectedListener);
    218 
    219         mTextInputPickerView = (TextInputTimePickerView) mainView.findViewById(R.id.input_mode);
    220         mTextInputPickerView.setListener(mOnValueTypedListener);
    221 
    222         mRadialTimePickerModeButton =
    223                 (ImageButton) mainView.findViewById(R.id.toggle_mode);
    224         mRadialTimePickerModeButton.setOnClickListener(new View.OnClickListener() {
    225             @Override
    226             public void onClick(View v) {
    227                 toggleRadialPickerMode();
    228             }
    229         });
    230         mRadialTimePickerModeEnabledDescription = context.getResources().getString(
    231                 R.string.time_picker_radial_mode_description);
    232         mTextInputPickerModeEnabledDescription = context.getResources().getString(
    233                 R.string.time_picker_text_input_mode_description);
    234 
    235         mAllowAutoAdvance = true;
    236 
    237         updateHourFormat();
    238 
    239         // Initialize with current time.
    240         mTempCalendar = Calendar.getInstance(mLocale);
    241         final int currentHour = mTempCalendar.get(Calendar.HOUR_OF_DAY);
    242         final int currentMinute = mTempCalendar.get(Calendar.MINUTE);
    243         initialize(currentHour, currentMinute, mIs24Hour, HOUR_INDEX);
    244     }
    245 
    246     private void toggleRadialPickerMode() {
    247         if (mRadialPickerModeEnabled) {
    248             mRadialTimePickerView.setVisibility(View.GONE);
    249             mRadialTimePickerHeader.setVisibility(View.GONE);
    250             mTextInputPickerHeader.setVisibility(View.VISIBLE);
    251             mTextInputPickerView.setVisibility(View.VISIBLE);
    252             mRadialTimePickerModeButton.setImageResource(R.drawable.btn_clock_material);
    253             mRadialTimePickerModeButton.setContentDescription(
    254                     mRadialTimePickerModeEnabledDescription);
    255             mRadialPickerModeEnabled = false;
    256         } else {
    257             mRadialTimePickerView.setVisibility(View.VISIBLE);
    258             mRadialTimePickerHeader.setVisibility(View.VISIBLE);
    259             mTextInputPickerHeader.setVisibility(View.GONE);
    260             mTextInputPickerView.setVisibility(View.GONE);
    261             mRadialTimePickerModeButton.setImageResource(R.drawable.btn_keyboard_key_material);
    262             mRadialTimePickerModeButton.setContentDescription(
    263                     mTextInputPickerModeEnabledDescription);
    264             updateTextInputPicker();
    265             mRadialPickerModeEnabled = true;
    266         }
    267     }
    268 
    269     @Override
    270     public boolean validateInput() {
    271         return mTextInputPickerView.validateInput();
    272     }
    273 
    274     /**
    275      * Ensures that a TextView is wide enough to contain its text without
    276      * wrapping or clipping. Measures the specified view and sets the minimum
    277      * width to the view's desired width.
    278      *
    279      * @param v the text view to measure
    280      */
    281     private static void ensureMinimumTextWidth(TextView v) {
    282         v.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
    283 
    284         // Set both the TextView and the View version of minimum
    285         // width because they are subtly different.
    286         final int minWidth = v.getMeasuredWidth();
    287         v.setMinWidth(minWidth);
    288         v.setMinimumWidth(minWidth);
    289     }
    290 
    291     /**
    292      * Updates hour formatting based on the current locale and 24-hour mode.
    293      * <p>
    294      * Determines how the hour should be formatted, sets member variables for
    295      * leading zero and starting hour, and sets the hour view's presentation.
    296      */
    297     private void updateHourFormat() {
    298         final String bestDateTimePattern = DateFormat.getBestDateTimePattern(
    299                 mLocale, mIs24Hour ? "Hm" : "hm");
    300         final int lengthPattern = bestDateTimePattern.length();
    301         boolean showLeadingZero = false;
    302         char hourFormat = '\0';
    303 
    304         for (int i = 0; i < lengthPattern; i++) {
    305             final char c = bestDateTimePattern.charAt(i);
    306             if (c == 'H' || c == 'h' || c == 'K' || c == 'k') {
    307                 hourFormat = c;
    308                 if (i + 1 < lengthPattern && c == bestDateTimePattern.charAt(i + 1)) {
    309                     showLeadingZero = true;
    310                 }
    311                 break;
    312             }
    313         }
    314 
    315         mHourFormatShowLeadingZero = showLeadingZero;
    316         mHourFormatStartsAtZero = hourFormat == 'K' || hourFormat == 'H';
    317 
    318         // Update hour text field.
    319         final int minHour = mHourFormatStartsAtZero ? 0 : 1;
    320         final int maxHour = (mIs24Hour ? 23 : 11) + minHour;
    321         mHourView.setRange(minHour, maxHour);
    322         mHourView.setShowLeadingZeroes(mHourFormatShowLeadingZero);
    323 
    324         final String[] digits = DecimalFormatSymbols.getInstance(mLocale).getDigitStrings();
    325         int maxCharLength = 0;
    326         for (int i = 0; i < 10; i++) {
    327             maxCharLength = Math.max(maxCharLength, digits[i].length());
    328         }
    329         mTextInputPickerView.setHourFormat(maxCharLength * 2);
    330     }
    331 
    332     static final CharSequence obtainVerbatim(String text) {
    333         return new SpannableStringBuilder().append(text,
    334                 new TtsSpan.VerbatimBuilder(text).build(), 0);
    335     }
    336 
    337     /**
    338      * The legacy text color might have been poorly defined. Ensures that it
    339      * has an appropriate activated state, using the selected state if one
    340      * exists or modifying the default text color otherwise.
    341      *
    342      * @param color a legacy text color, or {@code null}
    343      * @return a color state list with an appropriate activated state, or
    344      *         {@code null} if a valid activated state could not be generated
    345      */
    346     @Nullable
    347     private ColorStateList applyLegacyColorFixes(@Nullable ColorStateList color) {
    348         if (color == null || color.hasState(R.attr.state_activated)) {
    349             return color;
    350         }
    351 
    352         final int activatedColor;
    353         final int defaultColor;
    354         if (color.hasState(R.attr.state_selected)) {
    355             activatedColor = color.getColorForState(StateSet.get(
    356                     StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_SELECTED), 0);
    357             defaultColor = color.getColorForState(StateSet.get(
    358                     StateSet.VIEW_STATE_ENABLED), 0);
    359         } else {
    360             activatedColor = color.getDefaultColor();
    361 
    362             // Generate a non-activated color using the disabled alpha.
    363             final TypedArray ta = mContext.obtainStyledAttributes(ATTRS_DISABLED_ALPHA);
    364             final float disabledAlpha = ta.getFloat(0, 0.30f);
    365             defaultColor = multiplyAlphaComponent(activatedColor, disabledAlpha);
    366         }
    367 
    368         if (activatedColor == 0 || defaultColor == 0) {
    369             // We somehow failed to obtain the colors.
    370             return null;
    371         }
    372 
    373         final int[][] stateSet = new int[][] {{ R.attr.state_activated }, {}};
    374         final int[] colors = new int[] { activatedColor, defaultColor };
    375         return new ColorStateList(stateSet, colors);
    376     }
    377 
    378     private int multiplyAlphaComponent(int color, float alphaMod) {
    379         final int srcRgb = color & 0xFFFFFF;
    380         final int srcAlpha = (color >> 24) & 0xFF;
    381         final int dstAlpha = (int) (srcAlpha * alphaMod + 0.5f);
    382         return srcRgb | (dstAlpha << 24);
    383     }
    384 
    385     private static class ClickActionDelegate extends AccessibilityDelegate {
    386         private final AccessibilityAction mClickAction;
    387 
    388         public ClickActionDelegate(Context context, int resId) {
    389             mClickAction = new AccessibilityAction(
    390                     AccessibilityNodeInfo.ACTION_CLICK, context.getString(resId));
    391         }
    392 
    393         @Override
    394         public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
    395             super.onInitializeAccessibilityNodeInfo(host, info);
    396 
    397             info.addAction(mClickAction);
    398         }
    399     }
    400 
    401     private void initialize(int hourOfDay, int minute, boolean is24HourView, int index) {
    402         mCurrentHour = hourOfDay;
    403         mCurrentMinute = minute;
    404         mIs24Hour = is24HourView;
    405         updateUI(index);
    406     }
    407 
    408     private void updateUI(int index) {
    409         updateHeaderAmPm();
    410         updateHeaderHour(mCurrentHour, false);
    411         updateHeaderSeparator();
    412         updateHeaderMinute(mCurrentMinute, false);
    413         updateRadialPicker(index);
    414         updateTextInputPicker();
    415 
    416         mDelegator.invalidate();
    417     }
    418 
    419     private void updateTextInputPicker() {
    420         mTextInputPickerView.updateTextInputValues(getLocalizedHour(mCurrentHour), mCurrentMinute,
    421                 mCurrentHour < 12 ? AM : PM, mIs24Hour, mHourFormatStartsAtZero);
    422     }
    423 
    424     private void updateRadialPicker(int index) {
    425         mRadialTimePickerView.initialize(mCurrentHour, mCurrentMinute, mIs24Hour);
    426         setCurrentItemShowing(index, false, true);
    427     }
    428 
    429     private void updateHeaderAmPm() {
    430         if (mIs24Hour) {
    431             mAmPmLayout.setVisibility(View.GONE);
    432         } else {
    433             // Ensure that AM/PM layout is in the correct position.
    434             final String dateTimePattern = DateFormat.getBestDateTimePattern(mLocale, "hm");
    435             final boolean isAmPmAtStart = dateTimePattern.startsWith("a");
    436             setAmPmAtStart(isAmPmAtStart);
    437 
    438             updateAmPmLabelStates(mCurrentHour < 12 ? AM : PM);
    439         }
    440     }
    441 
    442     private void setAmPmAtStart(boolean isAmPmAtStart) {
    443         if (mIsAmPmAtStart != isAmPmAtStart) {
    444             mIsAmPmAtStart = isAmPmAtStart;
    445 
    446             final RelativeLayout.LayoutParams params =
    447                     (RelativeLayout.LayoutParams) mAmPmLayout.getLayoutParams();
    448             if (params.getRule(RelativeLayout.RIGHT_OF) != 0 ||
    449                     params.getRule(RelativeLayout.LEFT_OF) != 0) {
    450                 if (isAmPmAtStart) {
    451                     params.removeRule(RelativeLayout.RIGHT_OF);
    452                     params.addRule(RelativeLayout.LEFT_OF, mHourView.getId());
    453                 } else {
    454                     params.removeRule(RelativeLayout.LEFT_OF);
    455                     params.addRule(RelativeLayout.RIGHT_OF, mMinuteView.getId());
    456                 }
    457             }
    458 
    459             mAmPmLayout.setLayoutParams(params);
    460         }
    461     }
    462 
    463     /**
    464      * Set the current hour.
    465      */
    466     @Override
    467     public void setHour(int hour) {
    468         setHourInternal(hour, FROM_EXTERNAL_API, true);
    469     }
    470 
    471     private void setHourInternal(int hour, @ChangeSource int source, boolean announce) {
    472         if (mCurrentHour == hour) {
    473             return;
    474         }
    475 
    476         mCurrentHour = hour;
    477         updateHeaderHour(hour, announce);
    478         updateHeaderAmPm();
    479 
    480         if (source != FROM_RADIAL_PICKER) {
    481             mRadialTimePickerView.setCurrentHour(hour);
    482             mRadialTimePickerView.setAmOrPm(hour < 12 ? AM : PM);
    483         }
    484         if (source != FROM_INPUT_PICKER) {
    485             updateTextInputPicker();
    486         }
    487 
    488         mDelegator.invalidate();
    489         onTimeChanged();
    490     }
    491 
    492     /**
    493      * @return the current hour in the range (0-23)
    494      */
    495     @Override
    496     public int getHour() {
    497         final int currentHour = mRadialTimePickerView.getCurrentHour();
    498         if (mIs24Hour) {
    499             return currentHour;
    500         }
    501 
    502         if (mRadialTimePickerView.getAmOrPm() == PM) {
    503             return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY;
    504         } else {
    505             return currentHour % HOURS_IN_HALF_DAY;
    506         }
    507     }
    508 
    509     /**
    510      * Set the current minute (0-59).
    511      */
    512     @Override
    513     public void setMinute(int minute) {
    514         setMinuteInternal(minute, FROM_EXTERNAL_API);
    515     }
    516 
    517     private void setMinuteInternal(int minute, @ChangeSource int source) {
    518         if (mCurrentMinute == minute) {
    519             return;
    520         }
    521 
    522         mCurrentMinute = minute;
    523         updateHeaderMinute(minute, true);
    524 
    525         if (source != FROM_RADIAL_PICKER) {
    526             mRadialTimePickerView.setCurrentMinute(minute);
    527         }
    528         if (source != FROM_INPUT_PICKER) {
    529             updateTextInputPicker();
    530         }
    531 
    532         mDelegator.invalidate();
    533         onTimeChanged();
    534     }
    535 
    536     /**
    537      * @return The current minute.
    538      */
    539     @Override
    540     public int getMinute() {
    541         return mRadialTimePickerView.getCurrentMinute();
    542     }
    543 
    544     /**
    545      * Sets whether time is displayed in 24-hour mode or 12-hour mode with
    546      * AM/PM indicators.
    547      *
    548      * @param is24Hour {@code true} to display time in 24-hour mode or
    549      *        {@code false} for 12-hour mode with AM/PM
    550      */
    551     public void setIs24Hour(boolean is24Hour) {
    552         if (mIs24Hour != is24Hour) {
    553             mIs24Hour = is24Hour;
    554             mCurrentHour = getHour();
    555 
    556             updateHourFormat();
    557             updateUI(mRadialTimePickerView.getCurrentItemShowing());
    558         }
    559     }
    560 
    561     /**
    562      * @return {@code true} if time is displayed in 24-hour mode, or
    563      *         {@code false} if time is displayed in 12-hour mode with AM/PM
    564      *         indicators
    565      */
    566     @Override
    567     public boolean is24Hour() {
    568         return mIs24Hour;
    569     }
    570 
    571     @Override
    572     public void setEnabled(boolean enabled) {
    573         mHourView.setEnabled(enabled);
    574         mMinuteView.setEnabled(enabled);
    575         mAmLabel.setEnabled(enabled);
    576         mPmLabel.setEnabled(enabled);
    577         mRadialTimePickerView.setEnabled(enabled);
    578         mIsEnabled = enabled;
    579     }
    580 
    581     @Override
    582     public boolean isEnabled() {
    583         return mIsEnabled;
    584     }
    585 
    586     @Override
    587     public int getBaseline() {
    588         // does not support baseline alignment
    589         return -1;
    590     }
    591 
    592     @Override
    593     public Parcelable onSaveInstanceState(Parcelable superState) {
    594         return new SavedState(superState, getHour(), getMinute(),
    595                 is24Hour(), getCurrentItemShowing());
    596     }
    597 
    598     @Override
    599     public void onRestoreInstanceState(Parcelable state) {
    600         if (state instanceof SavedState) {
    601             final SavedState ss = (SavedState) state;
    602             initialize(ss.getHour(), ss.getMinute(), ss.is24HourMode(), ss.getCurrentItemShowing());
    603             mRadialTimePickerView.invalidate();
    604         }
    605     }
    606 
    607     @Override
    608     public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
    609         onPopulateAccessibilityEvent(event);
    610         return true;
    611     }
    612 
    613     @Override
    614     public void onPopulateAccessibilityEvent(AccessibilityEvent event) {
    615         int flags = DateUtils.FORMAT_SHOW_TIME;
    616         if (mIs24Hour) {
    617             flags |= DateUtils.FORMAT_24HOUR;
    618         } else {
    619             flags |= DateUtils.FORMAT_12HOUR;
    620         }
    621 
    622         mTempCalendar.set(Calendar.HOUR_OF_DAY, getHour());
    623         mTempCalendar.set(Calendar.MINUTE, getMinute());
    624 
    625         final String selectedTime = DateUtils.formatDateTime(mContext,
    626                 mTempCalendar.getTimeInMillis(), flags);
    627         final String selectionMode = mRadialTimePickerView.getCurrentItemShowing() == HOUR_INDEX ?
    628                 mSelectHours : mSelectMinutes;
    629         event.getText().add(selectedTime + " " + selectionMode);
    630     }
    631 
    632     /** @hide */
    633     @Override
    634     @TestApi
    635     public View getHourView() {
    636         return mHourView;
    637     }
    638 
    639     /** @hide */
    640     @Override
    641     @TestApi
    642     public View getMinuteView() {
    643         return mMinuteView;
    644     }
    645 
    646     /** @hide */
    647     @Override
    648     @TestApi
    649     public View getAmView() {
    650         return mAmLabel;
    651     }
    652 
    653     /** @hide */
    654     @Override
    655     @TestApi
    656     public View getPmView() {
    657         return mPmLabel;
    658     }
    659 
    660     /**
    661      * @return the index of the current item showing
    662      */
    663     private int getCurrentItemShowing() {
    664         return mRadialTimePickerView.getCurrentItemShowing();
    665     }
    666 
    667     /**
    668      * Propagate the time change
    669      */
    670     private void onTimeChanged() {
    671         mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
    672         if (mOnTimeChangedListener != null) {
    673             mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute());
    674         }
    675         if (mAutoFillChangeListener != null) {
    676             mAutoFillChangeListener.onTimeChanged(mDelegator, getHour(), getMinute());
    677         }
    678     }
    679 
    680     private void tryVibrate() {
    681         mDelegator.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
    682     }
    683 
    684     private void updateAmPmLabelStates(int amOrPm) {
    685         final boolean isAm = amOrPm == AM;
    686         mAmLabel.setActivated(isAm);
    687         mAmLabel.setChecked(isAm);
    688 
    689         final boolean isPm = amOrPm == PM;
    690         mPmLabel.setActivated(isPm);
    691         mPmLabel.setChecked(isPm);
    692     }
    693 
    694     /**
    695      * Converts hour-of-day (0-23) time into a localized hour number.
    696      * <p>
    697      * The localized value may be in the range (0-23), (1-24), (0-11), or
    698      * (1-12) depending on the locale. This method does not handle leading
    699      * zeroes.
    700      *
    701      * @param hourOfDay the hour-of-day (0-23)
    702      * @return a localized hour number
    703      */
    704     private int getLocalizedHour(int hourOfDay) {
    705         if (!mIs24Hour) {
    706             // Convert to hour-of-am-pm.
    707             hourOfDay %= 12;
    708         }
    709 
    710         if (!mHourFormatStartsAtZero && hourOfDay == 0) {
    711             // Convert to clock-hour (either of-day or of-am-pm).
    712             hourOfDay = mIs24Hour ? 24 : 12;
    713         }
    714 
    715         return hourOfDay;
    716     }
    717 
    718     private void updateHeaderHour(int hourOfDay, boolean announce) {
    719         final int localizedHour = getLocalizedHour(hourOfDay);
    720         mHourView.setValue(localizedHour);
    721 
    722         if (announce) {
    723             tryAnnounceForAccessibility(mHourView.getText(), true);
    724         }
    725     }
    726 
    727     private void updateHeaderMinute(int minuteOfHour, boolean announce) {
    728         mMinuteView.setValue(minuteOfHour);
    729 
    730         if (announce) {
    731             tryAnnounceForAccessibility(mMinuteView.getText(), false);
    732         }
    733     }
    734 
    735     /**
    736      * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":".
    737      *
    738      * See http://unicode.org/cldr/trac/browser/trunk/common/main
    739      *
    740      * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the
    741      * separator as the character which is just after the hour marker in the returned pattern.
    742      */
    743     private void updateHeaderSeparator() {
    744         final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale,
    745                 (mIs24Hour) ? "Hm" : "hm");
    746         final String separatorText;
    747         // See http://www.unicode.org/reports/tr35/tr35-dates.html for hour formats
    748         final char[] hourFormats = {'H', 'h', 'K', 'k'};
    749         int hIndex = lastIndexOfAny(bestDateTimePattern, hourFormats);
    750         if (hIndex == -1) {
    751             // Default case
    752             separatorText = ":";
    753         } else {
    754             separatorText = Character.toString(bestDateTimePattern.charAt(hIndex + 1));
    755         }
    756         mSeparatorView.setText(separatorText);
    757         mTextInputPickerView.updateSeparator(separatorText);
    758     }
    759 
    760     static private int lastIndexOfAny(String str, char[] any) {
    761         final int lengthAny = any.length;
    762         if (lengthAny > 0) {
    763             for (int i = str.length() - 1; i >= 0; i--) {
    764                 char c = str.charAt(i);
    765                 for (int j = 0; j < lengthAny; j++) {
    766                     if (c == any[j]) {
    767                         return i;
    768                     }
    769                 }
    770             }
    771         }
    772         return -1;
    773     }
    774 
    775     private void tryAnnounceForAccessibility(CharSequence text, boolean isHour) {
    776         if (mLastAnnouncedIsHour != isHour || !text.equals(mLastAnnouncedText)) {
    777             // TODO: Find a better solution, potentially live regions?
    778             mDelegator.announceForAccessibility(text);
    779             mLastAnnouncedText = text;
    780             mLastAnnouncedIsHour = isHour;
    781         }
    782     }
    783 
    784     /**
    785      * Show either Hours or Minutes.
    786      */
    787     private void setCurrentItemShowing(int index, boolean animateCircle, boolean announce) {
    788         mRadialTimePickerView.setCurrentItemShowing(index, animateCircle);
    789 
    790         if (index == HOUR_INDEX) {
    791             if (announce) {
    792                 mDelegator.announceForAccessibility(mSelectHours);
    793             }
    794         } else {
    795             if (announce) {
    796                 mDelegator.announceForAccessibility(mSelectMinutes);
    797             }
    798         }
    799 
    800         mHourView.setActivated(index == HOUR_INDEX);
    801         mMinuteView.setActivated(index == MINUTE_INDEX);
    802     }
    803 
    804     private void setAmOrPm(int amOrPm) {
    805         updateAmPmLabelStates(amOrPm);
    806 
    807         if (mRadialTimePickerView.setAmOrPm(amOrPm)) {
    808             mCurrentHour = getHour();
    809             updateTextInputPicker();
    810             if (mOnTimeChangedListener != null) {
    811                 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute());
    812             }
    813         }
    814     }
    815 
    816     /** Listener for RadialTimePickerView interaction. */
    817     private final OnValueSelectedListener mOnValueSelectedListener = new OnValueSelectedListener() {
    818         @Override
    819         public void onValueSelected(int pickerType, int newValue, boolean autoAdvance) {
    820             boolean valueChanged = false;
    821             switch (pickerType) {
    822                 case RadialTimePickerView.HOURS:
    823                     if (getHour() != newValue) {
    824                         valueChanged = true;
    825                     }
    826                     final boolean isTransition = mAllowAutoAdvance && autoAdvance;
    827                     setHourInternal(newValue, FROM_RADIAL_PICKER, !isTransition);
    828                     if (isTransition) {
    829                         setCurrentItemShowing(MINUTE_INDEX, true, false);
    830 
    831                         final int localizedHour = getLocalizedHour(newValue);
    832                         mDelegator.announceForAccessibility(localizedHour + ". " + mSelectMinutes);
    833                     }
    834                     break;
    835                 case RadialTimePickerView.MINUTES:
    836                     if (getMinute() != newValue) {
    837                         valueChanged = true;
    838                     }
    839                     setMinuteInternal(newValue, FROM_RADIAL_PICKER);
    840                     break;
    841             }
    842 
    843             if (mOnTimeChangedListener != null && valueChanged) {
    844                 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute());
    845             }
    846         }
    847     };
    848 
    849     private final OnValueTypedListener mOnValueTypedListener = new OnValueTypedListener() {
    850         @Override
    851         public void onValueChanged(int pickerType, int newValue) {
    852             switch (pickerType) {
    853                 case TextInputTimePickerView.HOURS:
    854                     setHourInternal(newValue, FROM_INPUT_PICKER, false);
    855                     break;
    856                 case TextInputTimePickerView.MINUTES:
    857                     setMinuteInternal(newValue, FROM_INPUT_PICKER);
    858                     break;
    859                 case TextInputTimePickerView.AMPM:
    860                     setAmOrPm(newValue);
    861                     break;
    862             }
    863         }
    864     };
    865 
    866     /** Listener for keyboard interaction. */
    867     private final OnValueChangedListener mDigitEnteredListener = new OnValueChangedListener() {
    868         @Override
    869         public void onValueChanged(NumericTextView view, int value,
    870                 boolean isValid, boolean isFinished) {
    871             final Runnable commitCallback;
    872             final View nextFocusTarget;
    873             if (view == mHourView) {
    874                 commitCallback = mCommitHour;
    875                 nextFocusTarget = view.isFocused() ? mMinuteView : null;
    876             } else if (view == mMinuteView) {
    877                 commitCallback = mCommitMinute;
    878                 nextFocusTarget = null;
    879             } else {
    880                 return;
    881             }
    882 
    883             view.removeCallbacks(commitCallback);
    884 
    885             if (isValid) {
    886                 if (isFinished) {
    887                     // Done with hours entry, make visual updates
    888                     // immediately and move to next focus if needed.
    889                     commitCallback.run();
    890 
    891                     if (nextFocusTarget != null) {
    892                         nextFocusTarget.requestFocus();
    893                     }
    894                 } else {
    895                     // May still be making changes. Postpone visual
    896                     // updates to prevent distracting the user.
    897                     view.postDelayed(commitCallback, DELAY_COMMIT_MILLIS);
    898                 }
    899             }
    900         }
    901     };
    902 
    903     private final Runnable mCommitHour = new Runnable() {
    904         @Override
    905         public void run() {
    906             setHour(mHourView.getValue());
    907         }
    908     };
    909 
    910     private final Runnable mCommitMinute = new Runnable() {
    911         @Override
    912         public void run() {
    913             setMinute(mMinuteView.getValue());
    914         }
    915     };
    916 
    917     private final View.OnFocusChangeListener mFocusListener = new View.OnFocusChangeListener() {
    918         @Override
    919         public void onFocusChange(View v, boolean focused) {
    920             if (focused) {
    921                 switch (v.getId()) {
    922                     case R.id.am_label:
    923                         setAmOrPm(AM);
    924                         break;
    925                     case R.id.pm_label:
    926                         setAmOrPm(PM);
    927                         break;
    928                     case R.id.hours:
    929                         setCurrentItemShowing(HOUR_INDEX, true, true);
    930                         break;
    931                     case R.id.minutes:
    932                         setCurrentItemShowing(MINUTE_INDEX, true, true);
    933                         break;
    934                     default:
    935                         // Failed to handle this click, don't vibrate.
    936                         return;
    937                 }
    938 
    939                 tryVibrate();
    940             }
    941         }
    942     };
    943 
    944     private final View.OnClickListener mClickListener = new View.OnClickListener() {
    945         @Override
    946         public void onClick(View v) {
    947 
    948             final int amOrPm;
    949             switch (v.getId()) {
    950                 case R.id.am_label:
    951                     setAmOrPm(AM);
    952                     break;
    953                 case R.id.pm_label:
    954                     setAmOrPm(PM);
    955                     break;
    956                 case R.id.hours:
    957                     setCurrentItemShowing(HOUR_INDEX, true, true);
    958                     break;
    959                 case R.id.minutes:
    960                     setCurrentItemShowing(MINUTE_INDEX, true, true);
    961                     break;
    962                 default:
    963                     // Failed to handle this click, don't vibrate.
    964                     return;
    965             }
    966 
    967             tryVibrate();
    968         }
    969     };
    970 
    971     /**
    972      * Delegates unhandled touches in a view group to the nearest child view.
    973      */
    974     private static class NearestTouchDelegate implements View.OnTouchListener {
    975             private View mInitialTouchTarget;
    976 
    977             @Override
    978             public boolean onTouch(View view, MotionEvent motionEvent) {
    979                 final int actionMasked = motionEvent.getActionMasked();
    980                 if (actionMasked == MotionEvent.ACTION_DOWN) {
    981                     if (view instanceof ViewGroup) {
    982                         mInitialTouchTarget = findNearestChild((ViewGroup) view,
    983                                 (int) motionEvent.getX(), (int) motionEvent.getY());
    984                     } else {
    985                         mInitialTouchTarget = null;
    986                     }
    987                 }
    988 
    989                 final View child = mInitialTouchTarget;
    990                 if (child == null) {
    991                     return false;
    992                 }
    993 
    994                 final float offsetX = view.getScrollX() - child.getLeft();
    995                 final float offsetY = view.getScrollY() - child.getTop();
    996                 motionEvent.offsetLocation(offsetX, offsetY);
    997                 final boolean handled = child.dispatchTouchEvent(motionEvent);
    998                 motionEvent.offsetLocation(-offsetX, -offsetY);
    999 
   1000                 if (actionMasked == MotionEvent.ACTION_UP
   1001                         || actionMasked == MotionEvent.ACTION_CANCEL) {
   1002                     mInitialTouchTarget = null;
   1003                 }
   1004 
   1005                 return handled;
   1006             }
   1007 
   1008         private View findNearestChild(ViewGroup v, int x, int y) {
   1009             View bestChild = null;
   1010             int bestDist = Integer.MAX_VALUE;
   1011 
   1012             for (int i = 0, count = v.getChildCount(); i < count; i++) {
   1013                 final View child = v.getChildAt(i);
   1014                 final int dX = x - (child.getLeft() + child.getWidth() / 2);
   1015                 final int dY = y - (child.getTop() + child.getHeight() / 2);
   1016                 final int dist = dX * dX + dY * dY;
   1017                 if (bestDist > dist) {
   1018                     bestChild = child;
   1019                     bestDist = dist;
   1020                 }
   1021             }
   1022 
   1023             return bestChild;
   1024         }
   1025     }
   1026 }
   1027