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.animation.ObjectAnimator;
     20 import android.annotation.IntDef;
     21 import android.content.Context;
     22 import android.content.res.ColorStateList;
     23 import android.content.res.Resources;
     24 import android.content.res.TypedArray;
     25 import android.graphics.Canvas;
     26 import android.graphics.Color;
     27 import android.graphics.Paint;
     28 import android.graphics.Path;
     29 import android.graphics.Rect;
     30 import android.graphics.Region;
     31 import android.graphics.Typeface;
     32 import android.os.Bundle;
     33 import android.util.AttributeSet;
     34 import android.util.FloatProperty;
     35 import android.util.IntArray;
     36 import android.util.Log;
     37 import android.util.MathUtils;
     38 import android.util.StateSet;
     39 import android.util.TypedValue;
     40 import android.view.HapticFeedbackConstants;
     41 import android.view.MotionEvent;
     42 import android.view.PointerIcon;
     43 import android.view.View;
     44 import android.view.accessibility.AccessibilityEvent;
     45 import android.view.accessibility.AccessibilityNodeInfo;
     46 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
     47 
     48 import com.android.internal.R;
     49 import com.android.internal.widget.ExploreByTouchHelper;
     50 
     51 import java.lang.annotation.Retention;
     52 import java.lang.annotation.RetentionPolicy;
     53 import java.util.Calendar;
     54 import java.util.Locale;
     55 
     56 /**
     57  * View to show a clock circle picker (with one or two picking circles)
     58  *
     59  * @hide
     60  */
     61 public class RadialTimePickerView extends View {
     62     private static final String TAG = "RadialTimePickerView";
     63 
     64     public static final int HOURS = 0;
     65     public static final int MINUTES = 1;
     66 
     67     /** @hide */
     68     @IntDef({HOURS, MINUTES})
     69     @Retention(RetentionPolicy.SOURCE)
     70     @interface PickerType {}
     71 
     72     private static final int HOURS_INNER = 2;
     73 
     74     private static final int SELECTOR_CIRCLE = 0;
     75     private static final int SELECTOR_DOT = 1;
     76     private static final int SELECTOR_LINE = 2;
     77 
     78     private static final int AM = 0;
     79     private static final int PM = 1;
     80 
     81     private static final int HOURS_IN_CIRCLE = 12;
     82     private static final int MINUTES_IN_CIRCLE = 60;
     83     private static final int DEGREES_FOR_ONE_HOUR = 360 / HOURS_IN_CIRCLE;
     84     private static final int DEGREES_FOR_ONE_MINUTE = 360 / MINUTES_IN_CIRCLE;
     85 
     86     private static final int[] HOURS_NUMBERS = {12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
     87     private static final int[] HOURS_NUMBERS_24 = {0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23};
     88     private static final int[] MINUTES_NUMBERS = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55};
     89 
     90     private static final int ANIM_DURATION_NORMAL = 500;
     91     private static final int ANIM_DURATION_TOUCH = 60;
     92 
     93     private static final int[] SNAP_PREFER_30S_MAP = new int[361];
     94 
     95     private static final int NUM_POSITIONS = 12;
     96     private static final float[] COS_30 = new float[NUM_POSITIONS];
     97     private static final float[] SIN_30 = new float[NUM_POSITIONS];
     98 
     99     /** "Something is wrong" color used when a color attribute is missing. */
    100     private static final int MISSING_COLOR = Color.MAGENTA;
    101 
    102     static {
    103         // Prepare mapping to snap touchable degrees to selectable degrees.
    104         preparePrefer30sMap();
    105 
    106         final double increment = 2.0 * Math.PI / NUM_POSITIONS;
    107         double angle = Math.PI / 2.0;
    108         for (int i = 0; i < NUM_POSITIONS; i++) {
    109             COS_30[i] = (float) Math.cos(angle);
    110             SIN_30[i] = (float) Math.sin(angle);
    111             angle += increment;
    112         }
    113     }
    114 
    115     private final FloatProperty<RadialTimePickerView> HOURS_TO_MINUTES =
    116             new FloatProperty<RadialTimePickerView>("hoursToMinutes") {
    117                 @Override
    118                 public Float get(RadialTimePickerView radialTimePickerView) {
    119                     return radialTimePickerView.mHoursToMinutes;
    120                 }
    121 
    122                 @Override
    123                 public void setValue(RadialTimePickerView object, float value) {
    124                     object.mHoursToMinutes = value;
    125                     object.invalidate();
    126                 }
    127             };
    128 
    129     private final String[] mHours12Texts = new String[12];
    130     private final String[] mOuterHours24Texts = new String[12];
    131     private final String[] mInnerHours24Texts = new String[12];
    132     private final String[] mMinutesTexts = new String[12];
    133 
    134     private final Paint[] mPaint = new Paint[2];
    135     private final Paint mPaintCenter = new Paint();
    136     private final Paint[] mPaintSelector = new Paint[3];
    137     private final Paint mPaintBackground = new Paint();
    138 
    139     private final Typeface mTypeface;
    140 
    141     private final ColorStateList[] mTextColor = new ColorStateList[3];
    142     private final int[] mTextSize = new int[3];
    143     private final int[] mTextInset = new int[3];
    144 
    145     private final float[][] mOuterTextX = new float[2][12];
    146     private final float[][] mOuterTextY = new float[2][12];
    147 
    148     private final float[] mInnerTextX = new float[12];
    149     private final float[] mInnerTextY = new float[12];
    150 
    151     private final int[] mSelectionDegrees = new int[2];
    152 
    153     private final RadialPickerTouchHelper mTouchHelper;
    154 
    155     private final Path mSelectorPath = new Path();
    156 
    157     private boolean mIs24HourMode;
    158     private boolean mShowHours;
    159 
    160     private ObjectAnimator mHoursToMinutesAnimator;
    161     private float mHoursToMinutes;
    162 
    163     /**
    164      * When in 24-hour mode, indicates that the current hour is between
    165      * 1 and 12 (inclusive).
    166      */
    167     private boolean mIsOnInnerCircle;
    168 
    169     private int mSelectorRadius;
    170     private int mSelectorStroke;
    171     private int mSelectorDotRadius;
    172     private int mCenterDotRadius;
    173 
    174     private int mSelectorColor;
    175     private int mSelectorDotColor;
    176 
    177     private int mXCenter;
    178     private int mYCenter;
    179     private int mCircleRadius;
    180 
    181     private int mMinDistForInnerNumber;
    182     private int mMaxDistForOuterNumber;
    183     private int mHalfwayDist;
    184 
    185     private String[] mOuterTextHours;
    186     private String[] mInnerTextHours;
    187     private String[] mMinutesText;
    188 
    189     private int mAmOrPm;
    190 
    191     private float mDisabledAlpha;
    192 
    193     private OnValueSelectedListener mListener;
    194 
    195     private boolean mInputEnabled = true;
    196 
    197     interface OnValueSelectedListener {
    198         /**
    199          * Called when the selected value at a given picker index has changed.
    200          *
    201          * @param pickerType the type of value that has changed, one of:
    202          *                   <ul>
    203          *                       <li>{@link #MINUTES}
    204          *                       <li>{@link #HOURS}
    205          *                   </ul>
    206          * @param newValue the new value as minute in hour (0-59) or hour in
    207          *                 day (0-23)
    208          * @param autoAdvance when the picker type is {@link #HOURS},
    209          *                    {@code true} to switch to the {@link #MINUTES}
    210          *                    picker or {@code false} to stay on the current
    211          *                    picker. No effect when picker type is
    212          *                    {@link #MINUTES}.
    213          */
    214         void onValueSelected(@PickerType int pickerType, int newValue, boolean autoAdvance);
    215     }
    216 
    217     /**
    218      * Split up the 360 degrees of the circle among the 60 selectable values. Assigns a larger
    219      * selectable area to each of the 12 visible values, such that the ratio of space apportioned
    220      * to a visible value : space apportioned to a non-visible value will be 14 : 4.
    221      * E.g. the output of 30 degrees should have a higher range of input associated with it than
    222      * the output of 24 degrees, because 30 degrees corresponds to a visible number on the clock
    223      * circle (5 on the minutes, 1 or 13 on the hours).
    224      */
    225     private static void preparePrefer30sMap() {
    226         // We'll split up the visible output and the non-visible output such that each visible
    227         // output will correspond to a range of 14 associated input degrees, and each non-visible
    228         // output will correspond to a range of 4 associate input degrees, so visible numbers
    229         // are more than 3 times easier to get than non-visible numbers:
    230         // {354-359,0-7}:0, {8-11}:6, {12-15}:12, {16-19}:18, {20-23}:24, {24-37}:30, etc.
    231         //
    232         // If an output of 30 degrees should correspond to a range of 14 associated degrees, then
    233         // we'll need any input between 24 - 37 to snap to 30. Working out from there, 20-23 should
    234         // snap to 24, while 38-41 should snap to 36. This is somewhat counter-intuitive, that you
    235         // can be touching 36 degrees but have the selection snapped to 30 degrees; however, this
    236         // inconsistency isn't noticeable at such fine-grained degrees, and it affords us the
    237         // ability to aggressively prefer the visible values by a factor of more than 3:1, which
    238         // greatly contributes to the selectability of these values.
    239 
    240         // The first output is 0, and each following output will increment by 6 {0, 6, 12, ...}.
    241         int snappedOutputDegrees = 0;
    242         // Count of how many inputs we've designated to the specified output.
    243         int count = 1;
    244         // How many input we expect for a specified output. This will be 14 for output divisible
    245         // by 30, and 4 for the remaining output. We'll special case the outputs of 0 and 360, so
    246         // the caller can decide which they need.
    247         int expectedCount = 8;
    248         // Iterate through the input.
    249         for (int degrees = 0; degrees < 361; degrees++) {
    250             // Save the input-output mapping.
    251             SNAP_PREFER_30S_MAP[degrees] = snappedOutputDegrees;
    252             // If this is the last input for the specified output, calculate the next output and
    253             // the next expected count.
    254             if (count == expectedCount) {
    255                 snappedOutputDegrees += 6;
    256                 if (snappedOutputDegrees == 360) {
    257                     expectedCount = 7;
    258                 } else if (snappedOutputDegrees % 30 == 0) {
    259                     expectedCount = 14;
    260                 } else {
    261                     expectedCount = 4;
    262                 }
    263                 count = 1;
    264             } else {
    265                 count++;
    266             }
    267         }
    268     }
    269 
    270     /**
    271      * Returns mapping of any input degrees (0 to 360) to one of 60 selectable output degrees,
    272      * where the degrees corresponding to visible numbers (i.e. those divisible by 30) will be
    273      * weighted heavier than the degrees corresponding to non-visible numbers.
    274      * See {@link #preparePrefer30sMap()} documentation for the rationale and generation of the
    275      * mapping.
    276      */
    277     private static int snapPrefer30s(int degrees) {
    278         if (SNAP_PREFER_30S_MAP == null) {
    279             return -1;
    280         }
    281         return SNAP_PREFER_30S_MAP[degrees];
    282     }
    283 
    284     /**
    285      * Returns mapping of any input degrees (0 to 360) to one of 12 visible output degrees (all
    286      * multiples of 30), where the input will be "snapped" to the closest visible degrees.
    287      * @param degrees The input degrees
    288      * @param forceHigherOrLower The output may be forced to either the higher or lower step, or may
    289      * be allowed to snap to whichever is closer. Use 1 to force strictly higher, -1 to force
    290      * strictly lower, and 0 to snap to the closer one.
    291      * @return output degrees, will be a multiple of 30
    292      */
    293     private static int snapOnly30s(int degrees, int forceHigherOrLower) {
    294         final int stepSize = DEGREES_FOR_ONE_HOUR;
    295         int floor = (degrees / stepSize) * stepSize;
    296         final int ceiling = floor + stepSize;
    297         if (forceHigherOrLower == 1) {
    298             degrees = ceiling;
    299         } else if (forceHigherOrLower == -1) {
    300             if (degrees == floor) {
    301                 floor -= stepSize;
    302             }
    303             degrees = floor;
    304         } else {
    305             if ((degrees - floor) < (ceiling - degrees)) {
    306                 degrees = floor;
    307             } else {
    308                 degrees = ceiling;
    309             }
    310         }
    311         return degrees;
    312     }
    313 
    314     @SuppressWarnings("unused")
    315     public RadialTimePickerView(Context context)  {
    316         this(context, null);
    317     }
    318 
    319     public RadialTimePickerView(Context context, AttributeSet attrs)  {
    320         this(context, attrs, R.attr.timePickerStyle);
    321     }
    322 
    323     public RadialTimePickerView(Context context, AttributeSet attrs, int defStyleAttr)  {
    324         this(context, attrs, defStyleAttr, 0);
    325     }
    326 
    327     public RadialTimePickerView(
    328             Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)  {
    329         super(context, attrs);
    330 
    331         applyAttributes(attrs, defStyleAttr, defStyleRes);
    332 
    333         // Pull disabled alpha from theme.
    334         final TypedValue outValue = new TypedValue();
    335         context.getTheme().resolveAttribute(android.R.attr.disabledAlpha, outValue, true);
    336         mDisabledAlpha = outValue.getFloat();
    337 
    338         mTypeface = Typeface.create("sans-serif", Typeface.NORMAL);
    339 
    340         mPaint[HOURS] = new Paint();
    341         mPaint[HOURS].setAntiAlias(true);
    342         mPaint[HOURS].setTextAlign(Paint.Align.CENTER);
    343 
    344         mPaint[MINUTES] = new Paint();
    345         mPaint[MINUTES].setAntiAlias(true);
    346         mPaint[MINUTES].setTextAlign(Paint.Align.CENTER);
    347 
    348         mPaintCenter.setAntiAlias(true);
    349 
    350         mPaintSelector[SELECTOR_CIRCLE] = new Paint();
    351         mPaintSelector[SELECTOR_CIRCLE].setAntiAlias(true);
    352 
    353         mPaintSelector[SELECTOR_DOT] = new Paint();
    354         mPaintSelector[SELECTOR_DOT].setAntiAlias(true);
    355 
    356         mPaintSelector[SELECTOR_LINE] = new Paint();
    357         mPaintSelector[SELECTOR_LINE].setAntiAlias(true);
    358         mPaintSelector[SELECTOR_LINE].setStrokeWidth(2);
    359 
    360         mPaintBackground.setAntiAlias(true);
    361 
    362         final Resources res = getResources();
    363         mSelectorRadius = res.getDimensionPixelSize(R.dimen.timepicker_selector_radius);
    364         mSelectorStroke = res.getDimensionPixelSize(R.dimen.timepicker_selector_stroke);
    365         mSelectorDotRadius = res.getDimensionPixelSize(R.dimen.timepicker_selector_dot_radius);
    366         mCenterDotRadius = res.getDimensionPixelSize(R.dimen.timepicker_center_dot_radius);
    367 
    368         mTextSize[HOURS] = res.getDimensionPixelSize(R.dimen.timepicker_text_size_normal);
    369         mTextSize[MINUTES] = res.getDimensionPixelSize(R.dimen.timepicker_text_size_normal);
    370         mTextSize[HOURS_INNER] = res.getDimensionPixelSize(R.dimen.timepicker_text_size_inner);
    371 
    372         mTextInset[HOURS] = res.getDimensionPixelSize(R.dimen.timepicker_text_inset_normal);
    373         mTextInset[MINUTES] = res.getDimensionPixelSize(R.dimen.timepicker_text_inset_normal);
    374         mTextInset[HOURS_INNER] = res.getDimensionPixelSize(R.dimen.timepicker_text_inset_inner);
    375 
    376         mShowHours = true;
    377         mHoursToMinutes = HOURS;
    378         mIs24HourMode = false;
    379         mAmOrPm = AM;
    380 
    381         // Set up accessibility components.
    382         mTouchHelper = new RadialPickerTouchHelper();
    383         setAccessibilityDelegate(mTouchHelper);
    384 
    385         if (getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
    386             setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
    387         }
    388 
    389         initHoursAndMinutesText();
    390         initData();
    391 
    392         // Initial values
    393         final Calendar calendar = Calendar.getInstance(Locale.getDefault());
    394         final int currentHour = calendar.get(Calendar.HOUR_OF_DAY);
    395         final int currentMinute = calendar.get(Calendar.MINUTE);
    396 
    397         setCurrentHourInternal(currentHour, false, false);
    398         setCurrentMinuteInternal(currentMinute, false);
    399 
    400         setHapticFeedbackEnabled(true);
    401     }
    402 
    403     void applyAttributes(AttributeSet attrs, int defStyleAttr, int defStyleRes) {
    404         final Context context = getContext();
    405         final TypedArray a = getContext().obtainStyledAttributes(attrs,
    406                 R.styleable.TimePicker, defStyleAttr, defStyleRes);
    407 
    408         final ColorStateList numbersTextColor = a.getColorStateList(
    409                 R.styleable.TimePicker_numbersTextColor);
    410         final ColorStateList numbersInnerTextColor = a.getColorStateList(
    411                 R.styleable.TimePicker_numbersInnerTextColor);
    412         mTextColor[HOURS] = numbersTextColor == null ?
    413                 ColorStateList.valueOf(MISSING_COLOR) : numbersTextColor;
    414         mTextColor[HOURS_INNER] = numbersInnerTextColor == null ?
    415                 ColorStateList.valueOf(MISSING_COLOR) : numbersInnerTextColor;
    416         mTextColor[MINUTES] = mTextColor[HOURS];
    417 
    418         // Set up various colors derived from the selector "activated" state.
    419         final ColorStateList selectorColors = a.getColorStateList(
    420                 R.styleable.TimePicker_numbersSelectorColor);
    421         final int selectorActivatedColor;
    422         if (selectorColors != null) {
    423             final int[] stateSetEnabledActivated = StateSet.get(
    424                     StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED);
    425             selectorActivatedColor = selectorColors.getColorForState(
    426                     stateSetEnabledActivated, 0);
    427         }  else {
    428             selectorActivatedColor = MISSING_COLOR;
    429         }
    430 
    431         mPaintCenter.setColor(selectorActivatedColor);
    432 
    433         final int[] stateSetActivated = StateSet.get(
    434                 StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED);
    435 
    436         mSelectorColor = selectorActivatedColor;
    437         mSelectorDotColor = mTextColor[HOURS].getColorForState(stateSetActivated, 0);
    438 
    439         mPaintBackground.setColor(a.getColor(R.styleable.TimePicker_numbersBackgroundColor,
    440                 context.getColor(R.color.timepicker_default_numbers_background_color_material)));
    441 
    442         a.recycle();
    443     }
    444 
    445     public void initialize(int hour, int minute, boolean is24HourMode) {
    446         if (mIs24HourMode != is24HourMode) {
    447             mIs24HourMode = is24HourMode;
    448             initData();
    449         }
    450 
    451         setCurrentHourInternal(hour, false, false);
    452         setCurrentMinuteInternal(minute, false);
    453     }
    454 
    455     public void setCurrentItemShowing(int item, boolean animate) {
    456         switch (item){
    457             case HOURS:
    458                 showHours(animate);
    459                 break;
    460             case MINUTES:
    461                 showMinutes(animate);
    462                 break;
    463             default:
    464                 Log.e(TAG, "ClockView does not support showing item " + item);
    465         }
    466     }
    467 
    468     public int getCurrentItemShowing() {
    469         return mShowHours ? HOURS : MINUTES;
    470     }
    471 
    472     public void setOnValueSelectedListener(OnValueSelectedListener listener) {
    473         mListener = listener;
    474     }
    475 
    476     /**
    477      * Sets the current hour in 24-hour time.
    478      *
    479      * @param hour the current hour between 0 and 23 (inclusive)
    480      */
    481     public void setCurrentHour(int hour) {
    482         setCurrentHourInternal(hour, true, false);
    483     }
    484 
    485     /**
    486      * Sets the current hour.
    487      *
    488      * @param hour The current hour
    489      * @param callback Whether the value listener should be invoked
    490      * @param autoAdvance Whether the listener should auto-advance to the next
    491      *                    selection mode, e.g. hour to minutes
    492      */
    493     private void setCurrentHourInternal(int hour, boolean callback, boolean autoAdvance) {
    494         final int degrees = (hour % 12) * DEGREES_FOR_ONE_HOUR;
    495         mSelectionDegrees[HOURS] = degrees;
    496 
    497         // 0 is 12 AM (midnight) and 12 is 12 PM (noon).
    498         final int amOrPm = (hour == 0 || (hour % 24) < 12) ? AM : PM;
    499         final boolean isOnInnerCircle = getInnerCircleForHour(hour);
    500         if (mAmOrPm != amOrPm || mIsOnInnerCircle != isOnInnerCircle) {
    501             mAmOrPm = amOrPm;
    502             mIsOnInnerCircle = isOnInnerCircle;
    503 
    504             initData();
    505             mTouchHelper.invalidateRoot();
    506         }
    507 
    508         invalidate();
    509 
    510         if (callback && mListener != null) {
    511             mListener.onValueSelected(HOURS, hour, autoAdvance);
    512         }
    513     }
    514 
    515     /**
    516      * Returns the current hour in 24-hour time.
    517      *
    518      * @return the current hour between 0 and 23 (inclusive)
    519      */
    520     public int getCurrentHour() {
    521         return getHourForDegrees(mSelectionDegrees[HOURS], mIsOnInnerCircle);
    522     }
    523 
    524     private int getHourForDegrees(int degrees, boolean innerCircle) {
    525         int hour = (degrees / DEGREES_FOR_ONE_HOUR) % 12;
    526         if (mIs24HourMode) {
    527             // Convert the 12-hour value into 24-hour time based on where the
    528             // selector is positioned.
    529             if (!innerCircle && hour == 0) {
    530                 // Outer circle is 1 through 12.
    531                 hour = 12;
    532             } else if (innerCircle && hour != 0) {
    533                 // Inner circle is 13 through 23 and 0.
    534                 hour += 12;
    535             }
    536         } else if (mAmOrPm == PM) {
    537             hour += 12;
    538         }
    539         return hour;
    540     }
    541 
    542     /**
    543      * @param hour the hour in 24-hour time or 12-hour time
    544      */
    545     private int getDegreesForHour(int hour) {
    546         // Convert to be 0-11.
    547         if (mIs24HourMode) {
    548             if (hour >= 12) {
    549                 hour -= 12;
    550             }
    551         } else if (hour == 12) {
    552             hour = 0;
    553         }
    554         return hour * DEGREES_FOR_ONE_HOUR;
    555     }
    556 
    557     /**
    558      * @param hour the hour in 24-hour time or 12-hour time
    559      */
    560     private boolean getInnerCircleForHour(int hour) {
    561         return mIs24HourMode && (hour == 0 || hour > 12);
    562     }
    563 
    564     public void setCurrentMinute(int minute) {
    565         setCurrentMinuteInternal(minute, true);
    566     }
    567 
    568     private void setCurrentMinuteInternal(int minute, boolean callback) {
    569         mSelectionDegrees[MINUTES] = (minute % MINUTES_IN_CIRCLE) * DEGREES_FOR_ONE_MINUTE;
    570 
    571         invalidate();
    572 
    573         if (callback && mListener != null) {
    574             mListener.onValueSelected(MINUTES, minute, false);
    575         }
    576     }
    577 
    578     // Returns minutes in 0-59 range
    579     public int getCurrentMinute() {
    580         return getMinuteForDegrees(mSelectionDegrees[MINUTES]);
    581     }
    582 
    583     private int getMinuteForDegrees(int degrees) {
    584         return degrees / DEGREES_FOR_ONE_MINUTE;
    585     }
    586 
    587     private int getDegreesForMinute(int minute) {
    588         return minute * DEGREES_FOR_ONE_MINUTE;
    589     }
    590 
    591     /**
    592      * Sets whether the picker is showing AM or PM hours. Has no effect when
    593      * in 24-hour mode.
    594      *
    595      * @param amOrPm {@link #AM} or {@link #PM}
    596      * @return {@code true} if the value changed from what was previously set,
    597      *         or {@code false} otherwise
    598      */
    599     public boolean setAmOrPm(int amOrPm) {
    600         if (mAmOrPm == amOrPm || mIs24HourMode) {
    601             return false;
    602         }
    603 
    604         mAmOrPm = amOrPm;
    605         invalidate();
    606         mTouchHelper.invalidateRoot();
    607         return true;
    608     }
    609 
    610     public int getAmOrPm() {
    611         return mAmOrPm;
    612     }
    613 
    614     public void showHours(boolean animate) {
    615         showPicker(true, animate);
    616     }
    617 
    618     public void showMinutes(boolean animate) {
    619         showPicker(false, animate);
    620     }
    621 
    622     private void initHoursAndMinutesText() {
    623         // Initialize the hours and minutes numbers.
    624         for (int i = 0; i < 12; i++) {
    625             mHours12Texts[i] = String.format("%d", HOURS_NUMBERS[i]);
    626             mInnerHours24Texts[i] = String.format("%02d", HOURS_NUMBERS_24[i]);
    627             mOuterHours24Texts[i] = String.format("%d", HOURS_NUMBERS[i]);
    628             mMinutesTexts[i] = String.format("%02d", MINUTES_NUMBERS[i]);
    629         }
    630     }
    631 
    632     private void initData() {
    633         if (mIs24HourMode) {
    634             mOuterTextHours = mOuterHours24Texts;
    635             mInnerTextHours = mInnerHours24Texts;
    636         } else {
    637             mOuterTextHours = mHours12Texts;
    638             mInnerTextHours = mHours12Texts;
    639         }
    640 
    641         mMinutesText = mMinutesTexts;
    642     }
    643 
    644     @Override
    645     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    646         if (!changed) {
    647             return;
    648         }
    649 
    650         mXCenter = getWidth() / 2;
    651         mYCenter = getHeight() / 2;
    652         mCircleRadius = Math.min(mXCenter, mYCenter);
    653 
    654         mMinDistForInnerNumber = mCircleRadius - mTextInset[HOURS_INNER] - mSelectorRadius;
    655         mMaxDistForOuterNumber = mCircleRadius - mTextInset[HOURS] + mSelectorRadius;
    656         mHalfwayDist = mCircleRadius - (mTextInset[HOURS] + mTextInset[HOURS_INNER]) / 2;
    657 
    658         calculatePositionsHours();
    659         calculatePositionsMinutes();
    660 
    661         mTouchHelper.invalidateRoot();
    662     }
    663 
    664     @Override
    665     public void onDraw(Canvas canvas) {
    666         final float alphaMod = mInputEnabled ? 1 : mDisabledAlpha;
    667 
    668         drawCircleBackground(canvas);
    669 
    670         final Path selectorPath = mSelectorPath;
    671         drawSelector(canvas, selectorPath);
    672         drawHours(canvas, selectorPath, alphaMod);
    673         drawMinutes(canvas, selectorPath, alphaMod);
    674         drawCenter(canvas, alphaMod);
    675     }
    676 
    677     private void showPicker(boolean hours, boolean animate) {
    678         if (mShowHours == hours) {
    679             return;
    680         }
    681 
    682         mShowHours = hours;
    683 
    684         if (animate) {
    685             animatePicker(hours, ANIM_DURATION_NORMAL);
    686         } else {
    687             // If we have a pending or running animator, cancel it.
    688             if (mHoursToMinutesAnimator != null && mHoursToMinutesAnimator.isStarted()) {
    689                 mHoursToMinutesAnimator.cancel();
    690                 mHoursToMinutesAnimator = null;
    691             }
    692             mHoursToMinutes = hours ? 0.0f : 1.0f;
    693         }
    694 
    695         initData();
    696         invalidate();
    697         mTouchHelper.invalidateRoot();
    698     }
    699 
    700     private void animatePicker(boolean hoursToMinutes, long duration) {
    701         final float target = hoursToMinutes ? HOURS : MINUTES;
    702         if (mHoursToMinutes == target) {
    703             // If we have a pending or running animator, cancel it.
    704             if (mHoursToMinutesAnimator != null && mHoursToMinutesAnimator.isStarted()) {
    705                 mHoursToMinutesAnimator.cancel();
    706                 mHoursToMinutesAnimator = null;
    707             }
    708 
    709             // We're already showing the correct picker.
    710             return;
    711         }
    712 
    713         mHoursToMinutesAnimator = ObjectAnimator.ofFloat(this, HOURS_TO_MINUTES, target);
    714         mHoursToMinutesAnimator.setAutoCancel(true);
    715         mHoursToMinutesAnimator.setDuration(duration);
    716         mHoursToMinutesAnimator.start();
    717     }
    718 
    719     private void drawCircleBackground(Canvas canvas) {
    720         canvas.drawCircle(mXCenter, mYCenter, mCircleRadius, mPaintBackground);
    721     }
    722 
    723     private void drawHours(Canvas canvas, Path selectorPath, float alphaMod) {
    724         final int hoursAlpha = (int) (255f * (1f - mHoursToMinutes) * alphaMod + 0.5f);
    725         if (hoursAlpha > 0) {
    726             // Exclude the selector region, then draw inner/outer hours with no
    727             // activated states.
    728             canvas.save(Canvas.CLIP_SAVE_FLAG);
    729             canvas.clipPath(selectorPath, Region.Op.DIFFERENCE);
    730             drawHoursClipped(canvas, hoursAlpha, false);
    731             canvas.restore();
    732 
    733             // Intersect the selector region, then draw minutes with only
    734             // activated states.
    735             canvas.save(Canvas.CLIP_SAVE_FLAG);
    736             canvas.clipPath(selectorPath, Region.Op.INTERSECT);
    737             drawHoursClipped(canvas, hoursAlpha, true);
    738             canvas.restore();
    739         }
    740     }
    741 
    742     private void drawHoursClipped(Canvas canvas, int hoursAlpha, boolean showActivated) {
    743         // Draw outer hours.
    744         drawTextElements(canvas, mTextSize[HOURS], mTypeface, mTextColor[HOURS], mOuterTextHours,
    745                 mOuterTextX[HOURS], mOuterTextY[HOURS], mPaint[HOURS], hoursAlpha,
    746                 showActivated && !mIsOnInnerCircle, mSelectionDegrees[HOURS], showActivated);
    747 
    748         // Draw inner hours (13-00) for 24-hour time.
    749         if (mIs24HourMode && mInnerTextHours != null) {
    750             drawTextElements(canvas, mTextSize[HOURS_INNER], mTypeface, mTextColor[HOURS_INNER],
    751                     mInnerTextHours, mInnerTextX, mInnerTextY, mPaint[HOURS], hoursAlpha,
    752                     showActivated && mIsOnInnerCircle, mSelectionDegrees[HOURS], showActivated);
    753         }
    754     }
    755 
    756     private void drawMinutes(Canvas canvas, Path selectorPath, float alphaMod) {
    757         final int minutesAlpha = (int) (255f * mHoursToMinutes * alphaMod + 0.5f);
    758         if (minutesAlpha > 0) {
    759             // Exclude the selector region, then draw minutes with no
    760             // activated states.
    761             canvas.save(Canvas.CLIP_SAVE_FLAG);
    762             canvas.clipPath(selectorPath, Region.Op.DIFFERENCE);
    763             drawMinutesClipped(canvas, minutesAlpha, false);
    764             canvas.restore();
    765 
    766             // Intersect the selector region, then draw minutes with only
    767             // activated states.
    768             canvas.save(Canvas.CLIP_SAVE_FLAG);
    769             canvas.clipPath(selectorPath, Region.Op.INTERSECT);
    770             drawMinutesClipped(canvas, minutesAlpha, true);
    771             canvas.restore();
    772         }
    773     }
    774 
    775     private void drawMinutesClipped(Canvas canvas, int minutesAlpha, boolean showActivated) {
    776         drawTextElements(canvas, mTextSize[MINUTES], mTypeface, mTextColor[MINUTES], mMinutesText,
    777                 mOuterTextX[MINUTES], mOuterTextY[MINUTES], mPaint[MINUTES], minutesAlpha,
    778                 showActivated, mSelectionDegrees[MINUTES], showActivated);
    779     }
    780 
    781     private void drawCenter(Canvas canvas, float alphaMod) {
    782         mPaintCenter.setAlpha((int) (255 * alphaMod + 0.5f));
    783         canvas.drawCircle(mXCenter, mYCenter, mCenterDotRadius, mPaintCenter);
    784     }
    785 
    786     private int getMultipliedAlpha(int argb, int alpha) {
    787         return (int) (Color.alpha(argb) * (alpha / 255.0) + 0.5);
    788     }
    789 
    790     private void drawSelector(Canvas canvas, Path selectorPath) {
    791         // Determine the current length, angle, and dot scaling factor.
    792         final int hoursIndex = mIsOnInnerCircle ? HOURS_INNER : HOURS;
    793         final int hoursInset = mTextInset[hoursIndex];
    794         final int hoursAngleDeg = mSelectionDegrees[hoursIndex % 2];
    795         final float hoursDotScale = mSelectionDegrees[hoursIndex % 2] % 30 != 0 ? 1 : 0;
    796 
    797         final int minutesIndex = MINUTES;
    798         final int minutesInset = mTextInset[minutesIndex];
    799         final int minutesAngleDeg = mSelectionDegrees[minutesIndex];
    800         final float minutesDotScale = mSelectionDegrees[minutesIndex] % 30 != 0 ? 1 : 0;
    801 
    802         // Calculate the current radius at which to place the selection circle.
    803         final int selRadius = mSelectorRadius;
    804         final float selLength =
    805                 mCircleRadius - MathUtils.lerp(hoursInset, minutesInset, mHoursToMinutes);
    806         final double selAngleRad =
    807                 Math.toRadians(MathUtils.lerpDeg(hoursAngleDeg, minutesAngleDeg, mHoursToMinutes));
    808         final float selCenterX = mXCenter + selLength * (float) Math.sin(selAngleRad);
    809         final float selCenterY = mYCenter - selLength * (float) Math.cos(selAngleRad);
    810 
    811         // Draw the selection circle.
    812         final Paint paint = mPaintSelector[SELECTOR_CIRCLE];
    813         paint.setColor(mSelectorColor);
    814         canvas.drawCircle(selCenterX, selCenterY, selRadius, paint);
    815 
    816         // If needed, set up the clip path for later.
    817         if (selectorPath != null) {
    818             selectorPath.reset();
    819             selectorPath.addCircle(selCenterX, selCenterY, selRadius, Path.Direction.CCW);
    820         }
    821 
    822         // Draw the dot if we're between two items.
    823         final float dotScale = MathUtils.lerp(hoursDotScale, minutesDotScale, mHoursToMinutes);
    824         if (dotScale > 0) {
    825             final Paint dotPaint = mPaintSelector[SELECTOR_DOT];
    826             dotPaint.setColor(mSelectorDotColor);
    827             canvas.drawCircle(selCenterX, selCenterY, mSelectorDotRadius * dotScale, dotPaint);
    828         }
    829 
    830         // Shorten the line to only go from the edge of the center dot to the
    831         // edge of the selection circle.
    832         final double sin = Math.sin(selAngleRad);
    833         final double cos = Math.cos(selAngleRad);
    834         final float lineLength = selLength - selRadius;
    835         final int centerX = mXCenter + (int) (mCenterDotRadius * sin);
    836         final int centerY = mYCenter - (int) (mCenterDotRadius * cos);
    837         final float linePointX = centerX + (int) (lineLength * sin);
    838         final float linePointY = centerY - (int) (lineLength * cos);
    839 
    840         // Draw the line.
    841         final Paint linePaint = mPaintSelector[SELECTOR_LINE];
    842         linePaint.setColor(mSelectorColor);
    843         linePaint.setStrokeWidth(mSelectorStroke);
    844         canvas.drawLine(mXCenter, mYCenter, linePointX, linePointY, linePaint);
    845     }
    846 
    847     private void calculatePositionsHours() {
    848         // Calculate the text positions
    849         final float numbersRadius = mCircleRadius - mTextInset[HOURS];
    850 
    851         // Calculate the positions for the 12 numbers in the main circle.
    852         calculatePositions(mPaint[HOURS], numbersRadius, mXCenter, mYCenter,
    853                 mTextSize[HOURS], mOuterTextX[HOURS], mOuterTextY[HOURS]);
    854 
    855         // If we have an inner circle, calculate those positions too.
    856         if (mIs24HourMode) {
    857             final int innerNumbersRadius = mCircleRadius - mTextInset[HOURS_INNER];
    858             calculatePositions(mPaint[HOURS], innerNumbersRadius, mXCenter, mYCenter,
    859                     mTextSize[HOURS_INNER], mInnerTextX, mInnerTextY);
    860         }
    861     }
    862 
    863     private void calculatePositionsMinutes() {
    864         // Calculate the text positions
    865         final float numbersRadius = mCircleRadius - mTextInset[MINUTES];
    866 
    867         // Calculate the positions for the 12 numbers in the main circle.
    868         calculatePositions(mPaint[MINUTES], numbersRadius, mXCenter, mYCenter,
    869                 mTextSize[MINUTES], mOuterTextX[MINUTES], mOuterTextY[MINUTES]);
    870     }
    871 
    872     /**
    873      * Using the trigonometric Unit Circle, calculate the positions that the text will need to be
    874      * drawn at based on the specified circle radius. Place the values in the textGridHeights and
    875      * textGridWidths parameters.
    876      */
    877     private static void calculatePositions(Paint paint, float radius, float xCenter, float yCenter,
    878             float textSize, float[] x, float[] y) {
    879         // Adjust yCenter to account for the text's baseline.
    880         paint.setTextSize(textSize);
    881         yCenter -= (paint.descent() + paint.ascent()) / 2;
    882 
    883         for (int i = 0; i < NUM_POSITIONS; i++) {
    884             x[i] = xCenter - radius * COS_30[i];
    885             y[i] = yCenter - radius * SIN_30[i];
    886         }
    887     }
    888 
    889     /**
    890      * Draw the 12 text values at the positions specified by the textGrid parameters.
    891      */
    892     private void drawTextElements(Canvas canvas, float textSize, Typeface typeface,
    893             ColorStateList textColor, String[] texts, float[] textX, float[] textY, Paint paint,
    894             int alpha, boolean showActivated, int activatedDegrees, boolean activatedOnly) {
    895         paint.setTextSize(textSize);
    896         paint.setTypeface(typeface);
    897 
    898         // The activated index can touch a range of elements.
    899         final float activatedIndex = activatedDegrees / (360.0f / NUM_POSITIONS);
    900         final int activatedFloor = (int) activatedIndex;
    901         final int activatedCeil = ((int) Math.ceil(activatedIndex)) % NUM_POSITIONS;
    902 
    903         for (int i = 0; i < 12; i++) {
    904             final boolean activated = (activatedFloor == i || activatedCeil == i);
    905             if (activatedOnly && !activated) {
    906                 continue;
    907             }
    908 
    909             final int stateMask = StateSet.VIEW_STATE_ENABLED
    910                     | (showActivated && activated ? StateSet.VIEW_STATE_ACTIVATED : 0);
    911             final int color = textColor.getColorForState(StateSet.get(stateMask), 0);
    912             paint.setColor(color);
    913             paint.setAlpha(getMultipliedAlpha(color, alpha));
    914 
    915             canvas.drawText(texts[i], textX[i], textY[i], paint);
    916         }
    917     }
    918 
    919     private int getDegreesFromXY(float x, float y, boolean constrainOutside) {
    920         // Ensure the point is inside the touchable area.
    921         final int innerBound;
    922         final int outerBound;
    923         if (mIs24HourMode && mShowHours) {
    924             innerBound = mMinDistForInnerNumber;
    925             outerBound = mMaxDistForOuterNumber;
    926         } else {
    927             final int index = mShowHours ? HOURS : MINUTES;
    928             final int center = mCircleRadius - mTextInset[index];
    929             innerBound = center - mSelectorRadius;
    930             outerBound = center + mSelectorRadius;
    931         }
    932 
    933         final double dX = x - mXCenter;
    934         final double dY = y - mYCenter;
    935         final double distFromCenter = Math.sqrt(dX * dX + dY * dY);
    936         if (distFromCenter < innerBound || constrainOutside && distFromCenter > outerBound) {
    937             return -1;
    938         }
    939 
    940         // Convert to degrees.
    941         final int degrees = (int) (Math.toDegrees(Math.atan2(dY, dX) + Math.PI / 2) + 0.5);
    942         if (degrees < 0) {
    943             return degrees + 360;
    944         } else {
    945             return degrees;
    946         }
    947     }
    948 
    949     private boolean getInnerCircleFromXY(float x, float y) {
    950         if (mIs24HourMode && mShowHours) {
    951             final double dX = x - mXCenter;
    952             final double dY = y - mYCenter;
    953             final double distFromCenter = Math.sqrt(dX * dX + dY * dY);
    954             return distFromCenter <= mHalfwayDist;
    955         }
    956         return false;
    957     }
    958 
    959     boolean mChangedDuringTouch = false;
    960 
    961     @Override
    962     public boolean onTouchEvent(MotionEvent event) {
    963         if (!mInputEnabled) {
    964             return true;
    965         }
    966 
    967         final int action = event.getActionMasked();
    968         if (action == MotionEvent.ACTION_MOVE
    969                 || action == MotionEvent.ACTION_UP
    970                 || action == MotionEvent.ACTION_DOWN) {
    971             boolean forceSelection = false;
    972             boolean autoAdvance = false;
    973 
    974             if (action == MotionEvent.ACTION_DOWN) {
    975                 // This is a new event stream, reset whether the value changed.
    976                 mChangedDuringTouch = false;
    977             } else if (action == MotionEvent.ACTION_UP) {
    978                 autoAdvance = true;
    979 
    980                 // If we saw a down/up pair without the value changing, assume
    981                 // this is a single-tap selection and force a change.
    982                 if (!mChangedDuringTouch) {
    983                     forceSelection = true;
    984                 }
    985             }
    986 
    987             mChangedDuringTouch |= handleTouchInput(
    988                     event.getX(), event.getY(), forceSelection, autoAdvance);
    989         }
    990 
    991         return true;
    992     }
    993 
    994     private boolean handleTouchInput(
    995             float x, float y, boolean forceSelection, boolean autoAdvance) {
    996         final boolean isOnInnerCircle = getInnerCircleFromXY(x, y);
    997         final int degrees = getDegreesFromXY(x, y, false);
    998         if (degrees == -1) {
    999             return false;
   1000         }
   1001 
   1002         // Ensure we're showing the correct picker.
   1003         animatePicker(mShowHours, ANIM_DURATION_TOUCH);
   1004 
   1005         final @PickerType int type;
   1006         final int newValue;
   1007         final boolean valueChanged;
   1008 
   1009         if (mShowHours) {
   1010             final int snapDegrees = snapOnly30s(degrees, 0) % 360;
   1011             valueChanged = mIsOnInnerCircle != isOnInnerCircle
   1012                     || mSelectionDegrees[HOURS] != snapDegrees;
   1013             mIsOnInnerCircle = isOnInnerCircle;
   1014             mSelectionDegrees[HOURS] = snapDegrees;
   1015             type = HOURS;
   1016             newValue = getCurrentHour();
   1017         } else {
   1018             final int snapDegrees = snapPrefer30s(degrees) % 360;
   1019             valueChanged = mSelectionDegrees[MINUTES] != snapDegrees;
   1020             mSelectionDegrees[MINUTES] = snapDegrees;
   1021             type = MINUTES;
   1022             newValue = getCurrentMinute();
   1023         }
   1024 
   1025         if (valueChanged || forceSelection || autoAdvance) {
   1026             // Fire the listener even if we just need to auto-advance.
   1027             if (mListener != null) {
   1028                 mListener.onValueSelected(type, newValue, autoAdvance);
   1029             }
   1030 
   1031             // Only provide feedback if the value actually changed.
   1032             if (valueChanged || forceSelection) {
   1033                 performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
   1034                 invalidate();
   1035             }
   1036             return true;
   1037         }
   1038 
   1039         return false;
   1040     }
   1041 
   1042     @Override
   1043     public boolean dispatchHoverEvent(MotionEvent event) {
   1044         // First right-of-refusal goes the touch exploration helper.
   1045         if (mTouchHelper.dispatchHoverEvent(event)) {
   1046             return true;
   1047         }
   1048         return super.dispatchHoverEvent(event);
   1049     }
   1050 
   1051     public void setInputEnabled(boolean inputEnabled) {
   1052         mInputEnabled = inputEnabled;
   1053         invalidate();
   1054     }
   1055 
   1056     @Override
   1057     public PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) {
   1058         if (!isEnabled()) {
   1059             return null;
   1060         }
   1061         final int degrees = getDegreesFromXY(event.getX(), event.getY(), false);
   1062         if (degrees != -1) {
   1063             return PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_HAND);
   1064         }
   1065         return super.onResolvePointerIcon(event, pointerIndex);
   1066     }
   1067 
   1068     private class RadialPickerTouchHelper extends ExploreByTouchHelper {
   1069         private final Rect mTempRect = new Rect();
   1070 
   1071         private final int TYPE_HOUR = 1;
   1072         private final int TYPE_MINUTE = 2;
   1073 
   1074         private final int SHIFT_TYPE = 0;
   1075         private final int MASK_TYPE = 0xF;
   1076 
   1077         private final int SHIFT_VALUE = 8;
   1078         private final int MASK_VALUE = 0xFF;
   1079 
   1080         /** Increment in which virtual views are exposed for minutes. */
   1081         private final int MINUTE_INCREMENT = 5;
   1082 
   1083         public RadialPickerTouchHelper() {
   1084             super(RadialTimePickerView.this);
   1085         }
   1086 
   1087         @Override
   1088         public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
   1089             super.onInitializeAccessibilityNodeInfo(host, info);
   1090 
   1091             info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD);
   1092             info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD);
   1093         }
   1094 
   1095         @Override
   1096         public boolean performAccessibilityAction(View host, int action, Bundle arguments) {
   1097             if (super.performAccessibilityAction(host, action, arguments)) {
   1098                 return true;
   1099             }
   1100 
   1101             switch (action) {
   1102                 case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
   1103                     adjustPicker(1);
   1104                     return true;
   1105                 case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
   1106                     adjustPicker(-1);
   1107                     return true;
   1108             }
   1109 
   1110             return false;
   1111         }
   1112 
   1113         private void adjustPicker(int step) {
   1114             final int stepSize;
   1115             final int initialStep;
   1116             final int maxValue;
   1117             final int minValue;
   1118             if (mShowHours) {
   1119                 stepSize = 1;
   1120 
   1121                 final int currentHour24 = getCurrentHour();
   1122                 if (mIs24HourMode) {
   1123                     initialStep = currentHour24;
   1124                     minValue = 0;
   1125                     maxValue = 23;
   1126                 } else {
   1127                     initialStep = hour24To12(currentHour24);
   1128                     minValue = 1;
   1129                     maxValue = 12;
   1130                 }
   1131             } else {
   1132                 stepSize = 5;
   1133                 initialStep = getCurrentMinute() / stepSize;
   1134                 minValue = 0;
   1135                 maxValue = 55;
   1136             }
   1137 
   1138             final int nextValue = (initialStep + step) * stepSize;
   1139             final int clampedValue = MathUtils.constrain(nextValue, minValue, maxValue);
   1140             if (mShowHours) {
   1141                 setCurrentHour(clampedValue);
   1142             } else {
   1143                 setCurrentMinute(clampedValue);
   1144             }
   1145         }
   1146 
   1147         @Override
   1148         protected int getVirtualViewAt(float x, float y) {
   1149             final int id;
   1150             final int degrees = getDegreesFromXY(x, y, true);
   1151             if (degrees != -1) {
   1152                 final int snapDegrees = snapOnly30s(degrees, 0) % 360;
   1153                 if (mShowHours) {
   1154                     final boolean isOnInnerCircle = getInnerCircleFromXY(x, y);
   1155                     final int hour24 = getHourForDegrees(snapDegrees, isOnInnerCircle);
   1156                     final int hour = mIs24HourMode ? hour24 : hour24To12(hour24);
   1157                     id = makeId(TYPE_HOUR, hour);
   1158                 } else {
   1159                     final int current = getCurrentMinute();
   1160                     final int touched = getMinuteForDegrees(degrees);
   1161                     final int snapped = getMinuteForDegrees(snapDegrees);
   1162 
   1163                     // If the touched minute is closer to the current minute
   1164                     // than it is to the snapped minute, return current.
   1165                     final int currentOffset = getCircularDiff(current, touched, MINUTES_IN_CIRCLE);
   1166                     final int snappedOffset = getCircularDiff(snapped, touched, MINUTES_IN_CIRCLE);
   1167                     final int minute;
   1168                     if (currentOffset < snappedOffset) {
   1169                         minute = current;
   1170                     } else {
   1171                         minute = snapped;
   1172                     }
   1173                     id = makeId(TYPE_MINUTE, minute);
   1174                 }
   1175             } else {
   1176                 id = INVALID_ID;
   1177             }
   1178 
   1179             return id;
   1180         }
   1181 
   1182         /**
   1183          * Returns the difference in degrees between two values along a circle.
   1184          *
   1185          * @param first value in the range [0,max]
   1186          * @param second value in the range [0,max]
   1187          * @param max the maximum value along the circle
   1188          * @return the difference in between the two values
   1189          */
   1190         private int getCircularDiff(int first, int second, int max) {
   1191             final int diff = Math.abs(first - second);
   1192             final int midpoint = max / 2;
   1193             return (diff > midpoint) ? (max - diff) : diff;
   1194         }
   1195 
   1196         @Override
   1197         protected void getVisibleVirtualViews(IntArray virtualViewIds) {
   1198             if (mShowHours) {
   1199                 final int min = mIs24HourMode ? 0 : 1;
   1200                 final int max = mIs24HourMode ? 23 : 12;
   1201                 for (int i = min; i <= max ; i++) {
   1202                     virtualViewIds.add(makeId(TYPE_HOUR, i));
   1203                 }
   1204             } else {
   1205                 final int current = getCurrentMinute();
   1206                 for (int i = 0; i < MINUTES_IN_CIRCLE; i += MINUTE_INCREMENT) {
   1207                     virtualViewIds.add(makeId(TYPE_MINUTE, i));
   1208 
   1209                     // If the current minute falls between two increments,
   1210                     // insert an extra node for it.
   1211                     if (current > i && current < i + MINUTE_INCREMENT) {
   1212                         virtualViewIds.add(makeId(TYPE_MINUTE, current));
   1213                     }
   1214                 }
   1215             }
   1216         }
   1217 
   1218         @Override
   1219         protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
   1220             event.setClassName(getClass().getName());
   1221 
   1222             final int type = getTypeFromId(virtualViewId);
   1223             final int value = getValueFromId(virtualViewId);
   1224             final CharSequence description = getVirtualViewDescription(type, value);
   1225             event.setContentDescription(description);
   1226         }
   1227 
   1228         @Override
   1229         protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node) {
   1230             node.setClassName(getClass().getName());
   1231             node.addAction(AccessibilityAction.ACTION_CLICK);
   1232 
   1233             final int type = getTypeFromId(virtualViewId);
   1234             final int value = getValueFromId(virtualViewId);
   1235             final CharSequence description = getVirtualViewDescription(type, value);
   1236             node.setContentDescription(description);
   1237 
   1238             getBoundsForVirtualView(virtualViewId, mTempRect);
   1239             node.setBoundsInParent(mTempRect);
   1240 
   1241             final boolean selected = isVirtualViewSelected(type, value);
   1242             node.setSelected(selected);
   1243 
   1244             final int nextId = getVirtualViewIdAfter(type, value);
   1245             if (nextId != INVALID_ID) {
   1246                 node.setTraversalBefore(RadialTimePickerView.this, nextId);
   1247             }
   1248         }
   1249 
   1250         private int getVirtualViewIdAfter(int type, int value) {
   1251             if (type == TYPE_HOUR) {
   1252                 final int nextValue = value + 1;
   1253                 final int max = mIs24HourMode ? 23 : 12;
   1254                 if (nextValue <= max) {
   1255                     return makeId(type, nextValue);
   1256                 }
   1257             } else if (type == TYPE_MINUTE) {
   1258                 final int current = getCurrentMinute();
   1259                 final int snapValue = value - (value % MINUTE_INCREMENT);
   1260                 final int nextValue = snapValue + MINUTE_INCREMENT;
   1261                 if (value < current && nextValue > current) {
   1262                     // The current value is between two snap values.
   1263                     return makeId(type, current);
   1264                 } else if (nextValue < MINUTES_IN_CIRCLE) {
   1265                     return makeId(type, nextValue);
   1266                 }
   1267             }
   1268             return INVALID_ID;
   1269         }
   1270 
   1271         @Override
   1272         protected boolean onPerformActionForVirtualView(int virtualViewId, int action,
   1273                 Bundle arguments) {
   1274             if (action == AccessibilityNodeInfo.ACTION_CLICK) {
   1275                 final int type = getTypeFromId(virtualViewId);
   1276                 final int value = getValueFromId(virtualViewId);
   1277                 if (type == TYPE_HOUR) {
   1278                     final int hour = mIs24HourMode ? value : hour12To24(value, mAmOrPm);
   1279                     setCurrentHour(hour);
   1280                     return true;
   1281                 } else if (type == TYPE_MINUTE) {
   1282                     setCurrentMinute(value);
   1283                     return true;
   1284                 }
   1285             }
   1286             return false;
   1287         }
   1288 
   1289         private int hour12To24(int hour12, int amOrPm) {
   1290             int hour24 = hour12;
   1291             if (hour12 == 12) {
   1292                 if (amOrPm == AM) {
   1293                     hour24 = 0;
   1294                 }
   1295             } else if (amOrPm == PM) {
   1296                 hour24 += 12;
   1297             }
   1298             return hour24;
   1299         }
   1300 
   1301         private int hour24To12(int hour24) {
   1302             if (hour24 == 0) {
   1303                 return 12;
   1304             } else if (hour24 > 12) {
   1305                 return hour24 - 12;
   1306             } else {
   1307                 return hour24;
   1308             }
   1309         }
   1310 
   1311         private void getBoundsForVirtualView(int virtualViewId, Rect bounds) {
   1312             final float radius;
   1313             final int type = getTypeFromId(virtualViewId);
   1314             final int value = getValueFromId(virtualViewId);
   1315             final float centerRadius;
   1316             final float degrees;
   1317             if (type == TYPE_HOUR) {
   1318                 final boolean innerCircle = getInnerCircleForHour(value);
   1319                 if (innerCircle) {
   1320                     centerRadius = mCircleRadius - mTextInset[HOURS_INNER];
   1321                     radius = mSelectorRadius;
   1322                 } else {
   1323                     centerRadius = mCircleRadius - mTextInset[HOURS];
   1324                     radius = mSelectorRadius;
   1325                 }
   1326 
   1327                 degrees = getDegreesForHour(value);
   1328             } else if (type == TYPE_MINUTE) {
   1329                 centerRadius = mCircleRadius - mTextInset[MINUTES];
   1330                 degrees = getDegreesForMinute(value);
   1331                 radius = mSelectorRadius;
   1332             } else {
   1333                 // This should never happen.
   1334                 centerRadius = 0;
   1335                 degrees = 0;
   1336                 radius = 0;
   1337             }
   1338 
   1339             final double radians = Math.toRadians(degrees);
   1340             final float xCenter = mXCenter + centerRadius * (float) Math.sin(radians);
   1341             final float yCenter = mYCenter - centerRadius * (float) Math.cos(radians);
   1342 
   1343             bounds.set((int) (xCenter - radius), (int) (yCenter - radius),
   1344                     (int) (xCenter + radius), (int) (yCenter + radius));
   1345         }
   1346 
   1347         private CharSequence getVirtualViewDescription(int type, int value) {
   1348             final CharSequence description;
   1349             if (type == TYPE_HOUR || type == TYPE_MINUTE) {
   1350                 description = Integer.toString(value);
   1351             } else {
   1352                 description = null;
   1353             }
   1354             return description;
   1355         }
   1356 
   1357         private boolean isVirtualViewSelected(int type, int value) {
   1358             final boolean selected;
   1359             if (type == TYPE_HOUR) {
   1360                 selected = getCurrentHour() == value;
   1361             } else if (type == TYPE_MINUTE) {
   1362                 selected = getCurrentMinute() == value;
   1363             } else {
   1364                 selected = false;
   1365             }
   1366             return selected;
   1367         }
   1368 
   1369         private int makeId(int type, int value) {
   1370             return type << SHIFT_TYPE | value << SHIFT_VALUE;
   1371         }
   1372 
   1373         private int getTypeFromId(int id) {
   1374             return id >>> SHIFT_TYPE & MASK_TYPE;
   1375         }
   1376 
   1377         private int getValueFromId(int id) {
   1378             return id >>> SHIFT_VALUE & MASK_VALUE;
   1379         }
   1380     }
   1381 }
   1382