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