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