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         saveAttributeDataForStyleable(context, R.styleable.TimePicker,
    408                 attrs, a, defStyleAttr, defStyleRes);
    409 
    410         final ColorStateList numbersTextColor = a.getColorStateList(
    411                 R.styleable.TimePicker_numbersTextColor);
    412         final ColorStateList numbersInnerTextColor = a.getColorStateList(
    413                 R.styleable.TimePicker_numbersInnerTextColor);
    414         mTextColor[HOURS] = numbersTextColor == null ?
    415                 ColorStateList.valueOf(MISSING_COLOR) : numbersTextColor;
    416         mTextColor[HOURS_INNER] = numbersInnerTextColor == null ?
    417                 ColorStateList.valueOf(MISSING_COLOR) : numbersInnerTextColor;
    418         mTextColor[MINUTES] = mTextColor[HOURS];
    419 
    420         // Set up various colors derived from the selector "activated" state.
    421         final ColorStateList selectorColors = a.getColorStateList(
    422                 R.styleable.TimePicker_numbersSelectorColor);
    423         final int selectorActivatedColor;
    424         if (selectorColors != null) {
    425             final int[] stateSetEnabledActivated = StateSet.get(
    426                     StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED);
    427             selectorActivatedColor = selectorColors.getColorForState(
    428                     stateSetEnabledActivated, 0);
    429         }  else {
    430             selectorActivatedColor = MISSING_COLOR;
    431         }
    432 
    433         mPaintCenter.setColor(selectorActivatedColor);
    434 
    435         final int[] stateSetActivated = StateSet.get(
    436                 StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED);
    437 
    438         mSelectorColor = selectorActivatedColor;
    439         mSelectorDotColor = mTextColor[HOURS].getColorForState(stateSetActivated, 0);
    440 
    441         mPaintBackground.setColor(a.getColor(R.styleable.TimePicker_numbersBackgroundColor,
    442                 context.getColor(R.color.timepicker_default_numbers_background_color_material)));
    443 
    444         a.recycle();
    445     }
    446 
    447     public void initialize(int hour, int minute, boolean is24HourMode) {
    448         if (mIs24HourMode != is24HourMode) {
    449             mIs24HourMode = is24HourMode;
    450             initData();
    451         }
    452 
    453         setCurrentHourInternal(hour, false, false);
    454         setCurrentMinuteInternal(minute, false);
    455     }
    456 
    457     public void setCurrentItemShowing(int item, boolean animate) {
    458         switch (item){
    459             case HOURS:
    460                 showHours(animate);
    461                 break;
    462             case MINUTES:
    463                 showMinutes(animate);
    464                 break;
    465             default:
    466                 Log.e(TAG, "ClockView does not support showing item " + item);
    467         }
    468     }
    469 
    470     public int getCurrentItemShowing() {
    471         return mShowHours ? HOURS : MINUTES;
    472     }
    473 
    474     public void setOnValueSelectedListener(OnValueSelectedListener listener) {
    475         mListener = listener;
    476     }
    477 
    478     /**
    479      * Sets the current hour in 24-hour time.
    480      *
    481      * @param hour the current hour between 0 and 23 (inclusive)
    482      */
    483     public void setCurrentHour(int hour) {
    484         setCurrentHourInternal(hour, true, false);
    485     }
    486 
    487     /**
    488      * Sets the current hour.
    489      *
    490      * @param hour The current hour
    491      * @param callback Whether the value listener should be invoked
    492      * @param autoAdvance Whether the listener should auto-advance to the next
    493      *                    selection mode, e.g. hour to minutes
    494      */
    495     private void setCurrentHourInternal(int hour, boolean callback, boolean autoAdvance) {
    496         final int degrees = (hour % 12) * DEGREES_FOR_ONE_HOUR;
    497         mSelectionDegrees[HOURS] = degrees;
    498 
    499         // 0 is 12 AM (midnight) and 12 is 12 PM (noon).
    500         final int amOrPm = (hour == 0 || (hour % 24) < 12) ? AM : PM;
    501         final boolean isOnInnerCircle = getInnerCircleForHour(hour);
    502         if (mAmOrPm != amOrPm || mIsOnInnerCircle != isOnInnerCircle) {
    503             mAmOrPm = amOrPm;
    504             mIsOnInnerCircle = isOnInnerCircle;
    505 
    506             initData();
    507             mTouchHelper.invalidateRoot();
    508         }
    509 
    510         invalidate();
    511 
    512         if (callback && mListener != null) {
    513             mListener.onValueSelected(HOURS, hour, autoAdvance);
    514         }
    515     }
    516 
    517     /**
    518      * Returns the current hour in 24-hour time.
    519      *
    520      * @return the current hour between 0 and 23 (inclusive)
    521      */
    522     public int getCurrentHour() {
    523         return getHourForDegrees(mSelectionDegrees[HOURS], mIsOnInnerCircle);
    524     }
    525 
    526     private int getHourForDegrees(int degrees, boolean innerCircle) {
    527         int hour = (degrees / DEGREES_FOR_ONE_HOUR) % 12;
    528         if (mIs24HourMode) {
    529             // Convert the 12-hour value into 24-hour time based on where the
    530             // selector is positioned.
    531             if (!innerCircle && hour == 0) {
    532                 // Outer circle is 1 through 12.
    533                 hour = 12;
    534             } else if (innerCircle && hour != 0) {
    535                 // Inner circle is 13 through 23 and 0.
    536                 hour += 12;
    537             }
    538         } else if (mAmOrPm == PM) {
    539             hour += 12;
    540         }
    541         return hour;
    542     }
    543 
    544     /**
    545      * @param hour the hour in 24-hour time or 12-hour time
    546      */
    547     private int getDegreesForHour(int hour) {
    548         // Convert to be 0-11.
    549         if (mIs24HourMode) {
    550             if (hour >= 12) {
    551                 hour -= 12;
    552             }
    553         } else if (hour == 12) {
    554             hour = 0;
    555         }
    556         return hour * DEGREES_FOR_ONE_HOUR;
    557     }
    558 
    559     /**
    560      * @param hour the hour in 24-hour time or 12-hour time
    561      */
    562     private boolean getInnerCircleForHour(int hour) {
    563         return mIs24HourMode && (hour == 0 || hour > 12);
    564     }
    565 
    566     public void setCurrentMinute(int minute) {
    567         setCurrentMinuteInternal(minute, true);
    568     }
    569 
    570     private void setCurrentMinuteInternal(int minute, boolean callback) {
    571         mSelectionDegrees[MINUTES] = (minute % MINUTES_IN_CIRCLE) * DEGREES_FOR_ONE_MINUTE;
    572 
    573         invalidate();
    574 
    575         if (callback && mListener != null) {
    576             mListener.onValueSelected(MINUTES, minute, false);
    577         }
    578     }
    579 
    580     // Returns minutes in 0-59 range
    581     public int getCurrentMinute() {
    582         return getMinuteForDegrees(mSelectionDegrees[MINUTES]);
    583     }
    584 
    585     private int getMinuteForDegrees(int degrees) {
    586         return degrees / DEGREES_FOR_ONE_MINUTE;
    587     }
    588 
    589     private int getDegreesForMinute(int minute) {
    590         return minute * DEGREES_FOR_ONE_MINUTE;
    591     }
    592 
    593     /**
    594      * Sets whether the picker is showing AM or PM hours. Has no effect when
    595      * in 24-hour mode.
    596      *
    597      * @param amOrPm {@link #AM} or {@link #PM}
    598      * @return {@code true} if the value changed from what was previously set,
    599      *         or {@code false} otherwise
    600      */
    601     public boolean setAmOrPm(int amOrPm) {
    602         if (mAmOrPm == amOrPm || mIs24HourMode) {
    603             return false;
    604         }
    605 
    606         mAmOrPm = amOrPm;
    607         invalidate();
    608         mTouchHelper.invalidateRoot();
    609         return true;
    610     }
    611 
    612     public int getAmOrPm() {
    613         return mAmOrPm;
    614     }
    615 
    616     public void showHours(boolean animate) {
    617         showPicker(true, animate);
    618     }
    619 
    620     public void showMinutes(boolean animate) {
    621         showPicker(false, animate);
    622     }
    623 
    624     private void initHoursAndMinutesText() {
    625         // Initialize the hours and minutes numbers.
    626         for (int i = 0; i < 12; i++) {
    627             mHours12Texts[i] = String.format("%d", HOURS_NUMBERS[i]);
    628             mInnerHours24Texts[i] = String.format("%02d", HOURS_NUMBERS_24[i]);
    629             mOuterHours24Texts[i] = String.format("%d", HOURS_NUMBERS[i]);
    630             mMinutesTexts[i] = String.format("%02d", MINUTES_NUMBERS[i]);
    631         }
    632     }
    633 
    634     private void initData() {
    635         if (mIs24HourMode) {
    636             mOuterTextHours = mOuterHours24Texts;
    637             mInnerTextHours = mInnerHours24Texts;
    638         } else {
    639             mOuterTextHours = mHours12Texts;
    640             mInnerTextHours = mHours12Texts;
    641         }
    642 
    643         mMinutesText = mMinutesTexts;
    644     }
    645 
    646     @Override
    647     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    648         if (!changed) {
    649             return;
    650         }
    651 
    652         mXCenter = getWidth() / 2;
    653         mYCenter = getHeight() / 2;
    654         mCircleRadius = Math.min(mXCenter, mYCenter);
    655 
    656         mMinDistForInnerNumber = mCircleRadius - mTextInset[HOURS_INNER] - mSelectorRadius;
    657         mMaxDistForOuterNumber = mCircleRadius - mTextInset[HOURS] + mSelectorRadius;
    658         mHalfwayDist = mCircleRadius - (mTextInset[HOURS] + mTextInset[HOURS_INNER]) / 2;
    659 
    660         calculatePositionsHours();
    661         calculatePositionsMinutes();
    662 
    663         mTouchHelper.invalidateRoot();
    664     }
    665 
    666     @Override
    667     public void onDraw(Canvas canvas) {
    668         final float alphaMod = mInputEnabled ? 1 : mDisabledAlpha;
    669 
    670         drawCircleBackground(canvas);
    671 
    672         final Path selectorPath = mSelectorPath;
    673         drawSelector(canvas, selectorPath);
    674         drawHours(canvas, selectorPath, alphaMod);
    675         drawMinutes(canvas, selectorPath, alphaMod);
    676         drawCenter(canvas, alphaMod);
    677     }
    678 
    679     private void showPicker(boolean hours, boolean animate) {
    680         if (mShowHours == hours) {
    681             return;
    682         }
    683 
    684         mShowHours = hours;
    685 
    686         if (animate) {
    687             animatePicker(hours, ANIM_DURATION_NORMAL);
    688         } else {
    689             // If we have a pending or running animator, cancel it.
    690             if (mHoursToMinutesAnimator != null && mHoursToMinutesAnimator.isStarted()) {
    691                 mHoursToMinutesAnimator.cancel();
    692                 mHoursToMinutesAnimator = null;
    693             }
    694             mHoursToMinutes = hours ? 0.0f : 1.0f;
    695         }
    696 
    697         initData();
    698         invalidate();
    699         mTouchHelper.invalidateRoot();
    700     }
    701 
    702     private void animatePicker(boolean hoursToMinutes, long duration) {
    703         final float target = hoursToMinutes ? HOURS : MINUTES;
    704         if (mHoursToMinutes == target) {
    705             // If we have a pending or running animator, cancel it.
    706             if (mHoursToMinutesAnimator != null && mHoursToMinutesAnimator.isStarted()) {
    707                 mHoursToMinutesAnimator.cancel();
    708                 mHoursToMinutesAnimator = null;
    709             }
    710 
    711             // We're already showing the correct picker.
    712             return;
    713         }
    714 
    715         mHoursToMinutesAnimator = ObjectAnimator.ofFloat(this, HOURS_TO_MINUTES, target);
    716         mHoursToMinutesAnimator.setAutoCancel(true);
    717         mHoursToMinutesAnimator.setDuration(duration);
    718         mHoursToMinutesAnimator.start();
    719     }
    720 
    721     private void drawCircleBackground(Canvas canvas) {
    722         canvas.drawCircle(mXCenter, mYCenter, mCircleRadius, mPaintBackground);
    723     }
    724 
    725     private void drawHours(Canvas canvas, Path selectorPath, float alphaMod) {
    726         final int hoursAlpha = (int) (255f * (1f - mHoursToMinutes) * alphaMod + 0.5f);
    727         if (hoursAlpha > 0) {
    728             // Exclude the selector region, then draw inner/outer hours with no
    729             // activated states.
    730             canvas.save(Canvas.CLIP_SAVE_FLAG);
    731             canvas.clipPath(selectorPath, Region.Op.DIFFERENCE);
    732             drawHoursClipped(canvas, hoursAlpha, false);
    733             canvas.restore();
    734 
    735             // Intersect the selector region, then draw minutes with only
    736             // activated states.
    737             canvas.save(Canvas.CLIP_SAVE_FLAG);
    738             canvas.clipPath(selectorPath, Region.Op.INTERSECT);
    739             drawHoursClipped(canvas, hoursAlpha, true);
    740             canvas.restore();
    741         }
    742     }
    743 
    744     private void drawHoursClipped(Canvas canvas, int hoursAlpha, boolean showActivated) {
    745         // Draw outer hours.
    746         drawTextElements(canvas, mTextSize[HOURS], mTypeface, mTextColor[HOURS], mOuterTextHours,
    747                 mOuterTextX[HOURS], mOuterTextY[HOURS], mPaint[HOURS], hoursAlpha,
    748                 showActivated && !mIsOnInnerCircle, mSelectionDegrees[HOURS], showActivated);
    749 
    750         // Draw inner hours (13-00) for 24-hour time.
    751         if (mIs24HourMode && mInnerTextHours != null) {
    752             drawTextElements(canvas, mTextSize[HOURS_INNER], mTypeface, mTextColor[HOURS_INNER],
    753                     mInnerTextHours, mInnerTextX, mInnerTextY, mPaint[HOURS], hoursAlpha,
    754                     showActivated && mIsOnInnerCircle, mSelectionDegrees[HOURS], showActivated);
    755         }
    756     }
    757 
    758     private void drawMinutes(Canvas canvas, Path selectorPath, float alphaMod) {
    759         final int minutesAlpha = (int) (255f * mHoursToMinutes * alphaMod + 0.5f);
    760         if (minutesAlpha > 0) {
    761             // Exclude the selector region, then draw minutes with no
    762             // activated states.
    763             canvas.save(Canvas.CLIP_SAVE_FLAG);
    764             canvas.clipPath(selectorPath, Region.Op.DIFFERENCE);
    765             drawMinutesClipped(canvas, minutesAlpha, false);
    766             canvas.restore();
    767 
    768             // Intersect the selector region, then draw minutes with only
    769             // activated states.
    770             canvas.save(Canvas.CLIP_SAVE_FLAG);
    771             canvas.clipPath(selectorPath, Region.Op.INTERSECT);
    772             drawMinutesClipped(canvas, minutesAlpha, true);
    773             canvas.restore();
    774         }
    775     }
    776 
    777     private void drawMinutesClipped(Canvas canvas, int minutesAlpha, boolean showActivated) {
    778         drawTextElements(canvas, mTextSize[MINUTES], mTypeface, mTextColor[MINUTES], mMinutesText,
    779                 mOuterTextX[MINUTES], mOuterTextY[MINUTES], mPaint[MINUTES], minutesAlpha,
    780                 showActivated, mSelectionDegrees[MINUTES], showActivated);
    781     }
    782 
    783     private void drawCenter(Canvas canvas, float alphaMod) {
    784         mPaintCenter.setAlpha((int) (255 * alphaMod + 0.5f));
    785         canvas.drawCircle(mXCenter, mYCenter, mCenterDotRadius, mPaintCenter);
    786     }
    787 
    788     private int getMultipliedAlpha(int argb, int alpha) {
    789         return (int) (Color.alpha(argb) * (alpha / 255.0) + 0.5);
    790     }
    791 
    792     private void drawSelector(Canvas canvas, Path selectorPath) {
    793         // Determine the current length, angle, and dot scaling factor.
    794         final int hoursIndex = mIsOnInnerCircle ? HOURS_INNER : HOURS;
    795         final int hoursInset = mTextInset[hoursIndex];
    796         final int hoursAngleDeg = mSelectionDegrees[hoursIndex % 2];
    797         final float hoursDotScale = mSelectionDegrees[hoursIndex % 2] % 30 != 0 ? 1 : 0;
    798 
    799         final int minutesIndex = MINUTES;
    800         final int minutesInset = mTextInset[minutesIndex];
    801         final int minutesAngleDeg = mSelectionDegrees[minutesIndex];
    802         final float minutesDotScale = mSelectionDegrees[minutesIndex] % 30 != 0 ? 1 : 0;
    803 
    804         // Calculate the current radius at which to place the selection circle.
    805         final int selRadius = mSelectorRadius;
    806         final float selLength =
    807                 mCircleRadius - MathUtils.lerp(hoursInset, minutesInset, mHoursToMinutes);
    808         final double selAngleRad =
    809                 Math.toRadians(MathUtils.lerpDeg(hoursAngleDeg, minutesAngleDeg, mHoursToMinutes));
    810         final float selCenterX = mXCenter + selLength * (float) Math.sin(selAngleRad);
    811         final float selCenterY = mYCenter - selLength * (float) Math.cos(selAngleRad);
    812 
    813         // Draw the selection circle.
    814         final Paint paint = mPaintSelector[SELECTOR_CIRCLE];
    815         paint.setColor(mSelectorColor);
    816         canvas.drawCircle(selCenterX, selCenterY, selRadius, paint);
    817 
    818         // If needed, set up the clip path for later.
    819         if (selectorPath != null) {
    820             selectorPath.reset();
    821             selectorPath.addCircle(selCenterX, selCenterY, selRadius, Path.Direction.CCW);
    822         }
    823 
    824         // Draw the dot if we're between two items.
    825         final float dotScale = MathUtils.lerp(hoursDotScale, minutesDotScale, mHoursToMinutes);
    826         if (dotScale > 0) {
    827             final Paint dotPaint = mPaintSelector[SELECTOR_DOT];
    828             dotPaint.setColor(mSelectorDotColor);
    829             canvas.drawCircle(selCenterX, selCenterY, mSelectorDotRadius * dotScale, dotPaint);
    830         }
    831 
    832         // Shorten the line to only go from the edge of the center dot to the
    833         // edge of the selection circle.
    834         final double sin = Math.sin(selAngleRad);
    835         final double cos = Math.cos(selAngleRad);
    836         final float lineLength = selLength - selRadius;
    837         final int centerX = mXCenter + (int) (mCenterDotRadius * sin);
    838         final int centerY = mYCenter - (int) (mCenterDotRadius * cos);
    839         final float linePointX = centerX + (int) (lineLength * sin);
    840         final float linePointY = centerY - (int) (lineLength * cos);
    841 
    842         // Draw the line.
    843         final Paint linePaint = mPaintSelector[SELECTOR_LINE];
    844         linePaint.setColor(mSelectorColor);
    845         linePaint.setStrokeWidth(mSelectorStroke);
    846         canvas.drawLine(mXCenter, mYCenter, linePointX, linePointY, linePaint);
    847     }
    848 
    849     private void calculatePositionsHours() {
    850         // Calculate the text positions
    851         final float numbersRadius = mCircleRadius - mTextInset[HOURS];
    852 
    853         // Calculate the positions for the 12 numbers in the main circle.
    854         calculatePositions(mPaint[HOURS], numbersRadius, mXCenter, mYCenter,
    855                 mTextSize[HOURS], mOuterTextX[HOURS], mOuterTextY[HOURS]);
    856 
    857         // If we have an inner circle, calculate those positions too.
    858         if (mIs24HourMode) {
    859             final int innerNumbersRadius = mCircleRadius - mTextInset[HOURS_INNER];
    860             calculatePositions(mPaint[HOURS], innerNumbersRadius, mXCenter, mYCenter,
    861                     mTextSize[HOURS_INNER], mInnerTextX, mInnerTextY);
    862         }
    863     }
    864 
    865     private void calculatePositionsMinutes() {
    866         // Calculate the text positions
    867         final float numbersRadius = mCircleRadius - mTextInset[MINUTES];
    868 
    869         // Calculate the positions for the 12 numbers in the main circle.
    870         calculatePositions(mPaint[MINUTES], numbersRadius, mXCenter, mYCenter,
    871                 mTextSize[MINUTES], mOuterTextX[MINUTES], mOuterTextY[MINUTES]);
    872     }
    873 
    874     /**
    875      * Using the trigonometric Unit Circle, calculate the positions that the text will need to be
    876      * drawn at based on the specified circle radius. Place the values in the textGridHeights and
    877      * textGridWidths parameters.
    878      */
    879     private static void calculatePositions(Paint paint, float radius, float xCenter, float yCenter,
    880             float textSize, float[] x, float[] y) {
    881         // Adjust yCenter to account for the text's baseline.
    882         paint.setTextSize(textSize);
    883         yCenter -= (paint.descent() + paint.ascent()) / 2;
    884 
    885         for (int i = 0; i < NUM_POSITIONS; i++) {
    886             x[i] = xCenter - radius * COS_30[i];
    887             y[i] = yCenter - radius * SIN_30[i];
    888         }
    889     }
    890 
    891     /**
    892      * Draw the 12 text values at the positions specified by the textGrid parameters.
    893      */
    894     private void drawTextElements(Canvas canvas, float textSize, Typeface typeface,
    895             ColorStateList textColor, String[] texts, float[] textX, float[] textY, Paint paint,
    896             int alpha, boolean showActivated, int activatedDegrees, boolean activatedOnly) {
    897         paint.setTextSize(textSize);
    898         paint.setTypeface(typeface);
    899 
    900         // The activated index can touch a range of elements.
    901         final float activatedIndex = activatedDegrees / (360.0f / NUM_POSITIONS);
    902         final int activatedFloor = (int) activatedIndex;
    903         final int activatedCeil = ((int) Math.ceil(activatedIndex)) % NUM_POSITIONS;
    904 
    905         for (int i = 0; i < 12; i++) {
    906             final boolean activated = (activatedFloor == i || activatedCeil == i);
    907             if (activatedOnly && !activated) {
    908                 continue;
    909             }
    910 
    911             final int stateMask = StateSet.VIEW_STATE_ENABLED
    912                     | (showActivated && activated ? StateSet.VIEW_STATE_ACTIVATED : 0);
    913             final int color = textColor.getColorForState(StateSet.get(stateMask), 0);
    914             paint.setColor(color);
    915             paint.setAlpha(getMultipliedAlpha(color, alpha));
    916 
    917             canvas.drawText(texts[i], textX[i], textY[i], paint);
    918         }
    919     }
    920 
    921     private int getDegreesFromXY(float x, float y, boolean constrainOutside) {
    922         // Ensure the point is inside the touchable area.
    923         final int innerBound;
    924         final int outerBound;
    925         if (mIs24HourMode && mShowHours) {
    926             innerBound = mMinDistForInnerNumber;
    927             outerBound = mMaxDistForOuterNumber;
    928         } else {
    929             final int index = mShowHours ? HOURS : MINUTES;
    930             final int center = mCircleRadius - mTextInset[index];
    931             innerBound = center - mSelectorRadius;
    932             outerBound = center + mSelectorRadius;
    933         }
    934 
    935         final double dX = x - mXCenter;
    936         final double dY = y - mYCenter;
    937         final double distFromCenter = Math.sqrt(dX * dX + dY * dY);
    938         if (distFromCenter < innerBound || constrainOutside && distFromCenter > outerBound) {
    939             return -1;
    940         }
    941 
    942         // Convert to degrees.
    943         final int degrees = (int) (Math.toDegrees(Math.atan2(dY, dX) + Math.PI / 2) + 0.5);
    944         if (degrees < 0) {
    945             return degrees + 360;
    946         } else {
    947             return degrees;
    948         }
    949     }
    950 
    951     private boolean getInnerCircleFromXY(float x, float y) {
    952         if (mIs24HourMode && mShowHours) {
    953             final double dX = x - mXCenter;
    954             final double dY = y - mYCenter;
    955             final double distFromCenter = Math.sqrt(dX * dX + dY * dY);
    956             return distFromCenter <= mHalfwayDist;
    957         }
    958         return false;
    959     }
    960 
    961     boolean mChangedDuringTouch = false;
    962 
    963     @Override
    964     public boolean onTouchEvent(MotionEvent event) {
    965         if (!mInputEnabled) {
    966             return true;
    967         }
    968 
    969         final int action = event.getActionMasked();
    970         if (action == MotionEvent.ACTION_MOVE
    971                 || action == MotionEvent.ACTION_UP
    972                 || action == MotionEvent.ACTION_DOWN) {
    973             boolean forceSelection = false;
    974             boolean autoAdvance = false;
    975 
    976             if (action == MotionEvent.ACTION_DOWN) {
    977                 // This is a new event stream, reset whether the value changed.
    978                 mChangedDuringTouch = false;
    979             } else if (action == MotionEvent.ACTION_UP) {
    980                 autoAdvance = true;
    981 
    982                 // If we saw a down/up pair without the value changing, assume
    983                 // this is a single-tap selection and force a change.
    984                 if (!mChangedDuringTouch) {
    985                     forceSelection = true;
    986                 }
    987             }
    988 
    989             mChangedDuringTouch |= handleTouchInput(
    990                     event.getX(), event.getY(), forceSelection, autoAdvance);
    991         }
    992 
    993         return true;
    994     }
    995 
    996     private boolean handleTouchInput(
    997             float x, float y, boolean forceSelection, boolean autoAdvance) {
    998         final boolean isOnInnerCircle = getInnerCircleFromXY(x, y);
    999         final int degrees = getDegreesFromXY(x, y, false);
   1000         if (degrees == -1) {
   1001             return false;
   1002         }
   1003 
   1004         // Ensure we're showing the correct picker.
   1005         animatePicker(mShowHours, ANIM_DURATION_TOUCH);
   1006 
   1007         final @PickerType int type;
   1008         final int newValue;
   1009         final boolean valueChanged;
   1010 
   1011         if (mShowHours) {
   1012             final int snapDegrees = snapOnly30s(degrees, 0) % 360;
   1013             valueChanged = mIsOnInnerCircle != isOnInnerCircle
   1014                     || mSelectionDegrees[HOURS] != snapDegrees;
   1015             mIsOnInnerCircle = isOnInnerCircle;
   1016             mSelectionDegrees[HOURS] = snapDegrees;
   1017             type = HOURS;
   1018             newValue = getCurrentHour();
   1019         } else {
   1020             final int snapDegrees = snapPrefer30s(degrees) % 360;
   1021             valueChanged = mSelectionDegrees[MINUTES] != snapDegrees;
   1022             mSelectionDegrees[MINUTES] = snapDegrees;
   1023             type = MINUTES;
   1024             newValue = getCurrentMinute();
   1025         }
   1026 
   1027         if (valueChanged || forceSelection || autoAdvance) {
   1028             // Fire the listener even if we just need to auto-advance.
   1029             if (mListener != null) {
   1030                 mListener.onValueSelected(type, newValue, autoAdvance);
   1031             }
   1032 
   1033             // Only provide feedback if the value actually changed.
   1034             if (valueChanged || forceSelection) {
   1035                 performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
   1036                 invalidate();
   1037             }
   1038             return true;
   1039         }
   1040 
   1041         return false;
   1042     }
   1043 
   1044     @Override
   1045     public boolean dispatchHoverEvent(MotionEvent event) {
   1046         // First right-of-refusal goes the touch exploration helper.
   1047         if (mTouchHelper.dispatchHoverEvent(event)) {
   1048             return true;
   1049         }
   1050         return super.dispatchHoverEvent(event);
   1051     }
   1052 
   1053     public void setInputEnabled(boolean inputEnabled) {
   1054         mInputEnabled = inputEnabled;
   1055         invalidate();
   1056     }
   1057 
   1058     @Override
   1059     public PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) {
   1060         if (!isEnabled()) {
   1061             return null;
   1062         }
   1063         final int degrees = getDegreesFromXY(event.getX(), event.getY(), false);
   1064         if (degrees != -1) {
   1065             return PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_HAND);
   1066         }
   1067         return super.onResolvePointerIcon(event, pointerIndex);
   1068     }
   1069 
   1070     private class RadialPickerTouchHelper extends ExploreByTouchHelper {
   1071         private final Rect mTempRect = new Rect();
   1072 
   1073         private final int TYPE_HOUR = 1;
   1074         private final int TYPE_MINUTE = 2;
   1075 
   1076         private final int SHIFT_TYPE = 0;
   1077         private final int MASK_TYPE = 0xF;
   1078 
   1079         private final int SHIFT_VALUE = 8;
   1080         private final int MASK_VALUE = 0xFF;
   1081 
   1082         /** Increment in which virtual views are exposed for minutes. */
   1083         private final int MINUTE_INCREMENT = 5;
   1084 
   1085         public RadialPickerTouchHelper() {
   1086             super(RadialTimePickerView.this);
   1087         }
   1088 
   1089         @Override
   1090         public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
   1091             super.onInitializeAccessibilityNodeInfo(host, info);
   1092 
   1093             info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD);
   1094             info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD);
   1095         }
   1096 
   1097         @Override
   1098         public boolean performAccessibilityAction(View host, int action, Bundle arguments) {
   1099             if (super.performAccessibilityAction(host, action, arguments)) {
   1100                 return true;
   1101             }
   1102 
   1103             switch (action) {
   1104                 case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
   1105                     adjustPicker(1);
   1106                     return true;
   1107                 case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
   1108                     adjustPicker(-1);
   1109                     return true;
   1110             }
   1111 
   1112             return false;
   1113         }
   1114 
   1115         private void adjustPicker(int step) {
   1116             final int stepSize;
   1117             final int initialStep;
   1118             final int maxValue;
   1119             final int minValue;
   1120             if (mShowHours) {
   1121                 stepSize = 1;
   1122 
   1123                 final int currentHour24 = getCurrentHour();
   1124                 if (mIs24HourMode) {
   1125                     initialStep = currentHour24;
   1126                     minValue = 0;
   1127                     maxValue = 23;
   1128                 } else {
   1129                     initialStep = hour24To12(currentHour24);
   1130                     minValue = 1;
   1131                     maxValue = 12;
   1132                 }
   1133             } else {
   1134                 stepSize = 5;
   1135                 initialStep = getCurrentMinute() / stepSize;
   1136                 minValue = 0;
   1137                 maxValue = 55;
   1138             }
   1139 
   1140             final int nextValue = (initialStep + step) * stepSize;
   1141             final int clampedValue = MathUtils.constrain(nextValue, minValue, maxValue);
   1142             if (mShowHours) {
   1143                 setCurrentHour(clampedValue);
   1144             } else {
   1145                 setCurrentMinute(clampedValue);
   1146             }
   1147         }
   1148 
   1149         @Override
   1150         protected int getVirtualViewAt(float x, float y) {
   1151             final int id;
   1152             final int degrees = getDegreesFromXY(x, y, true);
   1153             if (degrees != -1) {
   1154                 final int snapDegrees = snapOnly30s(degrees, 0) % 360;
   1155                 if (mShowHours) {
   1156                     final boolean isOnInnerCircle = getInnerCircleFromXY(x, y);
   1157                     final int hour24 = getHourForDegrees(snapDegrees, isOnInnerCircle);
   1158                     final int hour = mIs24HourMode ? hour24 : hour24To12(hour24);
   1159                     id = makeId(TYPE_HOUR, hour);
   1160                 } else {
   1161                     final int current = getCurrentMinute();
   1162                     final int touched = getMinuteForDegrees(degrees);
   1163                     final int snapped = getMinuteForDegrees(snapDegrees);
   1164 
   1165                     // If the touched minute is closer to the current minute
   1166                     // than it is to the snapped minute, return current.
   1167                     final int currentOffset = getCircularDiff(current, touched, MINUTES_IN_CIRCLE);
   1168                     final int snappedOffset = getCircularDiff(snapped, touched, MINUTES_IN_CIRCLE);
   1169                     final int minute;
   1170                     if (currentOffset < snappedOffset) {
   1171                         minute = current;
   1172                     } else {
   1173                         minute = snapped;
   1174                     }
   1175                     id = makeId(TYPE_MINUTE, minute);
   1176                 }
   1177             } else {
   1178                 id = INVALID_ID;
   1179             }
   1180 
   1181             return id;
   1182         }
   1183 
   1184         /**
   1185          * Returns the difference in degrees between two values along a circle.
   1186          *
   1187          * @param first value in the range [0,max]
   1188          * @param second value in the range [0,max]
   1189          * @param max the maximum value along the circle
   1190          * @return the difference in between the two values
   1191          */
   1192         private int getCircularDiff(int first, int second, int max) {
   1193             final int diff = Math.abs(first - second);
   1194             final int midpoint = max / 2;
   1195             return (diff > midpoint) ? (max - diff) : diff;
   1196         }
   1197 
   1198         @Override
   1199         protected void getVisibleVirtualViews(IntArray virtualViewIds) {
   1200             if (mShowHours) {
   1201                 final int min = mIs24HourMode ? 0 : 1;
   1202                 final int max = mIs24HourMode ? 23 : 12;
   1203                 for (int i = min; i <= max ; i++) {
   1204                     virtualViewIds.add(makeId(TYPE_HOUR, i));
   1205                 }
   1206             } else {
   1207                 final int current = getCurrentMinute();
   1208                 for (int i = 0; i < MINUTES_IN_CIRCLE; i += MINUTE_INCREMENT) {
   1209                     virtualViewIds.add(makeId(TYPE_MINUTE, i));
   1210 
   1211                     // If the current minute falls between two increments,
   1212                     // insert an extra node for it.
   1213                     if (current > i && current < i + MINUTE_INCREMENT) {
   1214                         virtualViewIds.add(makeId(TYPE_MINUTE, current));
   1215                     }
   1216                 }
   1217             }
   1218         }
   1219 
   1220         @Override
   1221         protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
   1222             event.setClassName(getClass().getName());
   1223 
   1224             final int type = getTypeFromId(virtualViewId);
   1225             final int value = getValueFromId(virtualViewId);
   1226             final CharSequence description = getVirtualViewDescription(type, value);
   1227             event.setContentDescription(description);
   1228         }
   1229 
   1230         @Override
   1231         protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node) {
   1232             node.setClassName(getClass().getName());
   1233             node.addAction(AccessibilityAction.ACTION_CLICK);
   1234 
   1235             final int type = getTypeFromId(virtualViewId);
   1236             final int value = getValueFromId(virtualViewId);
   1237             final CharSequence description = getVirtualViewDescription(type, value);
   1238             node.setContentDescription(description);
   1239 
   1240             getBoundsForVirtualView(virtualViewId, mTempRect);
   1241             node.setBoundsInParent(mTempRect);
   1242 
   1243             final boolean selected = isVirtualViewSelected(type, value);
   1244             node.setSelected(selected);
   1245 
   1246             final int nextId = getVirtualViewIdAfter(type, value);
   1247             if (nextId != INVALID_ID) {
   1248                 node.setTraversalBefore(RadialTimePickerView.this, nextId);
   1249             }
   1250         }
   1251 
   1252         private int getVirtualViewIdAfter(int type, int value) {
   1253             if (type == TYPE_HOUR) {
   1254                 final int nextValue = value + 1;
   1255                 final int max = mIs24HourMode ? 23 : 12;
   1256                 if (nextValue <= max) {
   1257                     return makeId(type, nextValue);
   1258                 }
   1259             } else if (type == TYPE_MINUTE) {
   1260                 final int current = getCurrentMinute();
   1261                 final int snapValue = value - (value % MINUTE_INCREMENT);
   1262                 final int nextValue = snapValue + MINUTE_INCREMENT;
   1263                 if (value < current && nextValue > current) {
   1264                     // The current value is between two snap values.
   1265                     return makeId(type, current);
   1266                 } else if (nextValue < MINUTES_IN_CIRCLE) {
   1267                     return makeId(type, nextValue);
   1268                 }
   1269             }
   1270             return INVALID_ID;
   1271         }
   1272 
   1273         @Override
   1274         protected boolean onPerformActionForVirtualView(int virtualViewId, int action,
   1275                 Bundle arguments) {
   1276             if (action == AccessibilityNodeInfo.ACTION_CLICK) {
   1277                 final int type = getTypeFromId(virtualViewId);
   1278                 final int value = getValueFromId(virtualViewId);
   1279                 if (type == TYPE_HOUR) {
   1280                     final int hour = mIs24HourMode ? value : hour12To24(value, mAmOrPm);
   1281                     setCurrentHour(hour);
   1282                     return true;
   1283                 } else if (type == TYPE_MINUTE) {
   1284                     setCurrentMinute(value);
   1285                     return true;
   1286                 }
   1287             }
   1288             return false;
   1289         }
   1290 
   1291         private int hour12To24(int hour12, int amOrPm) {
   1292             int hour24 = hour12;
   1293             if (hour12 == 12) {
   1294                 if (amOrPm == AM) {
   1295                     hour24 = 0;
   1296                 }
   1297             } else if (amOrPm == PM) {
   1298                 hour24 += 12;
   1299             }
   1300             return hour24;
   1301         }
   1302 
   1303         private int hour24To12(int hour24) {
   1304             if (hour24 == 0) {
   1305                 return 12;
   1306             } else if (hour24 > 12) {
   1307                 return hour24 - 12;
   1308             } else {
   1309                 return hour24;
   1310             }
   1311         }
   1312 
   1313         private void getBoundsForVirtualView(int virtualViewId, Rect bounds) {
   1314             final float radius;
   1315             final int type = getTypeFromId(virtualViewId);
   1316             final int value = getValueFromId(virtualViewId);
   1317             final float centerRadius;
   1318             final float degrees;
   1319             if (type == TYPE_HOUR) {
   1320                 final boolean innerCircle = getInnerCircleForHour(value);
   1321                 if (innerCircle) {
   1322                     centerRadius = mCircleRadius - mTextInset[HOURS_INNER];
   1323                     radius = mSelectorRadius;
   1324                 } else {
   1325                     centerRadius = mCircleRadius - mTextInset[HOURS];
   1326                     radius = mSelectorRadius;
   1327                 }
   1328 
   1329                 degrees = getDegreesForHour(value);
   1330             } else if (type == TYPE_MINUTE) {
   1331                 centerRadius = mCircleRadius - mTextInset[MINUTES];
   1332                 degrees = getDegreesForMinute(value);
   1333                 radius = mSelectorRadius;
   1334             } else {
   1335                 // This should never happen.
   1336                 centerRadius = 0;
   1337                 degrees = 0;
   1338                 radius = 0;
   1339             }
   1340 
   1341             final double radians = Math.toRadians(degrees);
   1342             final float xCenter = mXCenter + centerRadius * (float) Math.sin(radians);
   1343             final float yCenter = mYCenter - centerRadius * (float) Math.cos(radians);
   1344 
   1345             bounds.set((int) (xCenter - radius), (int) (yCenter - radius),
   1346                     (int) (xCenter + radius), (int) (yCenter + radius));
   1347         }
   1348 
   1349         private CharSequence getVirtualViewDescription(int type, int value) {
   1350             final CharSequence description;
   1351             if (type == TYPE_HOUR || type == TYPE_MINUTE) {
   1352                 description = Integer.toString(value);
   1353             } else {
   1354                 description = null;
   1355             }
   1356             return description;
   1357         }
   1358 
   1359         private boolean isVirtualViewSelected(int type, int value) {
   1360             final boolean selected;
   1361             if (type == TYPE_HOUR) {
   1362                 selected = getCurrentHour() == value;
   1363             } else if (type == TYPE_MINUTE) {
   1364                 selected = getCurrentMinute() == value;
   1365             } else {
   1366                 selected = false;
   1367             }
   1368             return selected;
   1369         }
   1370 
   1371         private int makeId(int type, int value) {
   1372             return type << SHIFT_TYPE | value << SHIFT_VALUE;
   1373         }
   1374 
   1375         private int getTypeFromId(int id) {
   1376             return id >>> SHIFT_TYPE & MASK_TYPE;
   1377         }
   1378 
   1379         private int getValueFromId(int id) {
   1380             return id >>> SHIFT_VALUE & MASK_VALUE;
   1381         }
   1382     }
   1383 }
   1384