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