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