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