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