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