1 /*
2  * Copyright (C) 2013 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.widget;
18 
19 import android.animation.Animator;
20 import android.animation.AnimatorSet;
21 import android.animation.Keyframe;
22 import android.animation.ObjectAnimator;
23 import android.animation.PropertyValuesHolder;
24 import android.animation.ValueAnimator;
25 import android.content.Context;
26 import android.content.res.ColorStateList;
27 import android.content.res.Resources;
28 import android.content.res.TypedArray;
29 import android.graphics.Canvas;
30 import android.graphics.Color;
31 import android.graphics.Paint;
32 import android.graphics.Path;
33 import android.graphics.Rect;
34 import android.graphics.Region;
35 import android.graphics.Typeface;
36 import android.os.Bundle;
37 import android.util.AttributeSet;
38 import android.util.IntArray;
39 import android.util.Log;
40 import android.util.MathUtils;
41 import android.util.StateSet;
42 import android.util.TypedValue;
43 import android.view.HapticFeedbackConstants;
44 import android.view.MotionEvent;
45 import android.view.View;
46 import android.view.accessibility.AccessibilityEvent;
47 import android.view.accessibility.AccessibilityNodeInfo;
48 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
49 
50 import com.android.internal.R;
51 import com.android.internal.widget.ExploreByTouchHelper;
52 
53 import java.util.ArrayList;
54 import java.util.Calendar;
55 import java.util.Locale;
56 
57 /**
58  * View to show a clock circle picker (with one or two picking circles)
59  *
60  * @hide
61  */
62 public 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_CIRCLE = 12;
83     private static final int MINUTES_IN_CIRCLE = 60;
84     private static final int DEGREES_FOR_ONE_HOUR = 360 / HOURS_IN_CIRCLE;
85     private static final int DEGREES_FOR_ONE_MINUTE = 360 / MINUTES_IN_CIRCLE;
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.
preparePrefer30sMap()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 {
onValueSelected(int pickerIndex, int newValue, boolean autoAdvance)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      */
preparePrefer30sMap()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      */
snapPrefer30s(int degrees)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      */
snapOnly30s(int degrees, int forceHigherOrLower)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")
RadialTimePickerView(Context context)291     public RadialTimePickerView(Context context)  {
292         this(context, null);
293     }
294 
RadialTimePickerView(Context context, AttributeSet attrs)295     public RadialTimePickerView(Context context, AttributeSet attrs)  {
296         this(context, attrs, R.attr.timePickerStyle);
297     }
298 
RadialTimePickerView(Context context, AttributeSet attrs, int defStyleAttr)299     public RadialTimePickerView(Context context, AttributeSet attrs, int defStyleAttr)  {
300         this(context, attrs, defStyleAttr, 0);
301     }
302 
RadialTimePickerView( Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)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 
initialize(int hour, int minute, boolean is24HourMode)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 
setCurrentItemShowing(int item, boolean animate)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 
getCurrentItemShowing()438     public int getCurrentItemShowing() {
439         return mShowHours ? HOURS : MINUTES;
440     }
441 
setOnValueSelectedListener(OnValueSelectedListener listener)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      */
setCurrentHour(int hour)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      */
setCurrentHourInternal(int hour, boolean callback, boolean autoAdvance)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      */
getCurrentHour()490     public int getCurrentHour() {
491         return getHourForDegrees(mSelectionDegrees[HOURS], mIsOnInnerCircle);
492     }
493 
getHourForDegrees(int degrees, boolean innerCircle)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      */
getDegreesForHour(int hour)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      */
getInnerCircleForHour(int hour)530     private boolean getInnerCircleForHour(int hour) {
531         return mIs24HourMode && (hour == 0 || hour > 12);
532     }
533 
setCurrentMinute(int minute)534     public void setCurrentMinute(int minute) {
535         setCurrentMinuteInternal(minute, true);
536     }
537 
setCurrentMinuteInternal(int minute, boolean callback)538     private void setCurrentMinuteInternal(int minute, boolean callback) {
539         mSelectionDegrees[MINUTES] = (minute % MINUTES_IN_CIRCLE) * 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
getCurrentMinute()549     public int getCurrentMinute() {
550         return getMinuteForDegrees(mSelectionDegrees[MINUTES]);
551     }
552 
getMinuteForDegrees(int degrees)553     private int getMinuteForDegrees(int degrees) {
554         return degrees / DEGREES_FOR_ONE_MINUTE;
555     }
556 
getDegreesForMinute(int minute)557     private int getDegreesForMinute(int minute) {
558         return minute * DEGREES_FOR_ONE_MINUTE;
559     }
560 
setAmOrPm(int val)561     public void setAmOrPm(int val) {
562         mAmOrPm = (val % 2);
563         invalidate();
564         mTouchHelper.invalidateRoot();
565     }
566 
getAmOrPm()567     public int getAmOrPm() {
568         return mAmOrPm;
569     }
570 
showHours(boolean animate)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 
showMinutes(boolean animate)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 
initHoursAndMinutesText()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 
initData()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
onLayout(boolean changed, int left, int top, int right, int bottom)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
onDraw(Canvas canvas)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 
drawCircleBackground(Canvas canvas)661     private void drawCircleBackground(Canvas canvas) {
662         canvas.drawCircle(mXCenter, mYCenter, mCircleRadius, mPaintBackground);
663     }
664 
drawHours(Canvas canvas, float alphaMod)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 
drawMinutes(Canvas canvas, float alphaMod)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 
drawCenter(Canvas canvas, float alphaMod)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 
applyAlpha(int argb, int alpha)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 
getMultipliedAlpha(int argb, int alpha)722     private int getMultipliedAlpha(int argb, int alpha) {
723         return (int) (Color.alpha(argb) * (alpha / 255.0) + 0.5);
724     }
725 
drawSelector(Canvas canvas, int index, Path selectorPath, float alphaMod)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 
calculatePositionsHours()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 
calculatePositionsMinutes()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      */
calculatePositions(Paint paint, float radius, float xCenter, float yCenter, float textSize, float[] x, float[] y)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      */
drawTextElements(Canvas canvas, float textSize, Typeface typeface, ColorStateList textColor, String[] texts, float[] textX, float[] textY, Paint paint, int alpha, boolean showActivated, int activatedDegrees, boolean activatedOnly)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 
getFadeOutAnimator(IntHolder target, int startAlpha, int endAlpha, InvalidateUpdateListener updateListener)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 
getFadeInAnimator(IntHolder target, int startAlpha, int endAlpha, InvalidateUpdateListener updateListener)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
onAnimationUpdate(ValueAnimator animation)875         public void onAnimationUpdate(ValueAnimator animation) {
876             RadialTimePickerView.this.invalidate();
877         }
878     }
879 
startHoursToMinutesAnimation()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 
startMinutesToHoursAnimation()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 
getDegreesFromXY(float x, float y, boolean constrainOutside)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 
getInnerCircleFromXY(float x, float y)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
onTouchEvent(MotionEvent event)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 
handleTouchInput( float x, float y, boolean forceSelection, boolean autoAdvance)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
dispatchHoverEvent(MotionEvent event)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 
setInputEnabled(boolean inputEnabled)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 
RadialPickerTouchHelper()1061         public RadialPickerTouchHelper() {
1062             super(RadialTimePickerView.this);
1063         }
1064 
1065         @Override
onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info)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
performAccessibilityAction(View host, int action, Bundle arguments)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 
adjustPicker(int step)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
getVirtualViewAt(float x, float y)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_CIRCLE);
1144                     final int snappedOffset = getCircularDiff(snapped, touched, MINUTES_IN_CIRCLE);
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          */
getCircularDiff(int first, int second, int max)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
getVisibleVirtualViews(IntArray virtualViewIds)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_CIRCLE; 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
onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event)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
onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node)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 
getVirtualViewIdAfter(int type, int value)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_CIRCLE) {
1243                     return makeId(type, nextValue);
1244                 }
1245             }
1246             return INVALID_ID;
1247         }
1248 
1249         @Override
onPerformActionForVirtualView(int virtualViewId, int action, Bundle arguments)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 
hour12To24(int hour12, int amOrPm)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 
hour24To12(int hour24)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 
getBoundsForVirtualView(int virtualViewId, Rect bounds)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 
getVirtualViewDescription(int type, int value)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 
isVirtualViewSelected(int type, int value)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 
makeId(int type, int value)1347         private int makeId(int type, int value) {
1348             return type << SHIFT_TYPE | value << SHIFT_VALUE;
1349         }
1350 
getTypeFromId(int id)1351         private int getTypeFromId(int id) {
1352             return id >>> SHIFT_TYPE & MASK_TYPE;
1353         }
1354 
getValueFromId(int id)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 
IntHolder(int value)1363         public IntHolder(int value) {
1364             mValue = value;
1365         }
1366 
setValue(int value)1367         public void setValue(int value) {
1368             mValue = value;
1369         }
1370 
getValue()1371         public int getValue() {
1372             return mValue;
1373         }
1374     }
1375 }
1376