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