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