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 com.android.datetimepicker.time;
18 
19 import android.animation.AnimatorSet;
20 import android.animation.ObjectAnimator;
21 import android.annotation.SuppressLint;
22 import android.content.Context;
23 import android.content.res.Resources;
24 import android.os.Bundle;
25 import android.os.Handler;
26 import android.text.format.DateUtils;
27 import android.text.format.Time;
28 import android.util.AttributeSet;
29 import android.util.Log;
30 import android.view.MotionEvent;
31 import android.view.View;
32 import android.view.View.OnTouchListener;
33 import android.view.ViewConfiguration;
34 import android.view.ViewGroup;
35 import android.view.accessibility.AccessibilityEvent;
36 import android.view.accessibility.AccessibilityManager;
37 import android.view.accessibility.AccessibilityNodeInfo;
38 import android.widget.FrameLayout;
39 
40 import com.android.datetimepicker.HapticFeedbackController;
41 import com.android.datetimepicker.R;
42 
43 /**
44  * The primary layout to hold the circular picker, and the am/pm buttons. This view well measure
45  * itself to end up as a square. It also handles touches to be passed in to views that need to know
46  * when they'd been touched.
47  */
48 public class RadialPickerLayout extends FrameLayout implements OnTouchListener {
49     private static final String TAG = "RadialPickerLayout";
50 
51     private final int TOUCH_SLOP;
52     private final int TAP_TIMEOUT;
53 
54     private static final int VISIBLE_DEGREES_STEP_SIZE = 30;
55     private static final int HOUR_VALUE_TO_DEGREES_STEP_SIZE = VISIBLE_DEGREES_STEP_SIZE;
56     private static final int MINUTE_VALUE_TO_DEGREES_STEP_SIZE = 6;
57     private static final int HOUR_INDEX = TimePickerDialog.HOUR_INDEX;
58     private static final int MINUTE_INDEX = TimePickerDialog.MINUTE_INDEX;
59     private static final int AMPM_INDEX = TimePickerDialog.AMPM_INDEX;
60     private static final int ENABLE_PICKER_INDEX = TimePickerDialog.ENABLE_PICKER_INDEX;
61     private static final int AM = TimePickerDialog.AM;
62     private static final int PM = TimePickerDialog.PM;
63 
64     private int mLastValueSelected;
65 
66     private HapticFeedbackController mHapticFeedbackController;
67     private OnValueSelectedListener mListener;
68     private boolean mTimeInitialized;
69     private int mCurrentHoursOfDay;
70     private int mCurrentMinutes;
71     private boolean mIs24HourMode;
72     private boolean mHideAmPm;
73     private int mCurrentItemShowing;
74 
75     private CircleView mCircleView;
76     private AmPmCirclesView mAmPmCirclesView;
77     private RadialTextsView mHourRadialTextsView;
78     private RadialTextsView mMinuteRadialTextsView;
79     private RadialSelectorView mHourRadialSelectorView;
80     private RadialSelectorView mMinuteRadialSelectorView;
81     private View mGrayBox;
82 
83     private int[] mSnapPrefer30sMap;
84     private boolean mInputEnabled;
85     private int mIsTouchingAmOrPm = -1;
86     private boolean mDoingMove;
87     private boolean mDoingTouch;
88     private int mDownDegrees;
89     private float mDownX;
90     private float mDownY;
91     private AccessibilityManager mAccessibilityManager;
92 
93     private AnimatorSet mTransition;
94     private Handler mHandler = new Handler();
95 
96     public interface OnValueSelectedListener {
onValueSelected(int pickerIndex, int newValue, boolean autoAdvance)97         void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance);
98     }
99 
RadialPickerLayout(Context context, AttributeSet attrs)100     public RadialPickerLayout(Context context, AttributeSet attrs) {
101         super(context, attrs);
102 
103         setOnTouchListener(this);
104         ViewConfiguration vc = ViewConfiguration.get(context);
105         TOUCH_SLOP = vc.getScaledTouchSlop();
106         TAP_TIMEOUT = ViewConfiguration.getTapTimeout();
107         mDoingMove = false;
108 
109         mCircleView = new CircleView(context);
110         addView(mCircleView);
111 
112         mAmPmCirclesView = new AmPmCirclesView(context);
113         addView(mAmPmCirclesView);
114 
115         mHourRadialTextsView = new RadialTextsView(context);
116         addView(mHourRadialTextsView);
117         mMinuteRadialTextsView = new RadialTextsView(context);
118         addView(mMinuteRadialTextsView);
119 
120         mHourRadialSelectorView = new RadialSelectorView(context);
121         addView(mHourRadialSelectorView);
122         mMinuteRadialSelectorView = new RadialSelectorView(context);
123         addView(mMinuteRadialSelectorView);
124 
125         // Prepare mapping to snap touchable degrees to selectable degrees.
126         preparePrefer30sMap();
127 
128         mLastValueSelected = -1;
129 
130         mInputEnabled = true;
131         mGrayBox = new View(context);
132         mGrayBox.setLayoutParams(new ViewGroup.LayoutParams(
133                 ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
134         mGrayBox.setBackgroundColor(getResources().getColor(R.color.transparent_black));
135         mGrayBox.setVisibility(View.INVISIBLE);
136         addView(mGrayBox);
137 
138         mAccessibilityManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
139 
140         mTimeInitialized = false;
141     }
142 
143     /**
144      * Measure the view to end up as a square, based on the minimum of the height and width.
145      */
146     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)147     public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
148         int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
149         int widthMode = MeasureSpec.getMode(widthMeasureSpec);
150         int measuredHeight = MeasureSpec.getSize(heightMeasureSpec);
151         int heightMode = MeasureSpec.getMode(heightMeasureSpec);
152         int minDimension = Math.min(measuredWidth, measuredHeight);
153 
154         super.onMeasure(MeasureSpec.makeMeasureSpec(minDimension, widthMode),
155                 MeasureSpec.makeMeasureSpec(minDimension, heightMode));
156     }
157 
setOnValueSelectedListener(OnValueSelectedListener listener)158     public void setOnValueSelectedListener(OnValueSelectedListener listener) {
159         mListener = listener;
160     }
161 
162     /**
163      * Initialize the Layout with starting values.
164      * @param context
165      * @param initialHoursOfDay
166      * @param initialMinutes
167      * @param is24HourMode
168      */
initialize(Context context, HapticFeedbackController hapticFeedbackController, int initialHoursOfDay, int initialMinutes, boolean is24HourMode)169     public void initialize(Context context, HapticFeedbackController hapticFeedbackController,
170             int initialHoursOfDay, int initialMinutes, boolean is24HourMode) {
171         if (mTimeInitialized) {
172             Log.e(TAG, "Time has already been initialized.");
173             return;
174         }
175 
176         mHapticFeedbackController = hapticFeedbackController;
177         mIs24HourMode = is24HourMode;
178         mHideAmPm = mAccessibilityManager.isTouchExplorationEnabled()? true : mIs24HourMode;
179 
180         // Initialize the circle and AM/PM circles if applicable.
181         mCircleView.initialize(context, mHideAmPm);
182         mCircleView.invalidate();
183         if (!mHideAmPm) {
184             mAmPmCirclesView.initialize(context, initialHoursOfDay < 12? AM : PM);
185             mAmPmCirclesView.invalidate();
186         }
187 
188         // Initialize the hours and minutes numbers.
189         Resources res = context.getResources();
190         int[] hours = {12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
191         int[] hours_24 = {0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23};
192         int[] minutes = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55};
193         String[] hoursTexts = new String[12];
194         String[] innerHoursTexts = new String[12];
195         String[] minutesTexts = new String[12];
196         for (int i = 0; i < 12; i++) {
197             hoursTexts[i] = is24HourMode?
198                     String.format("%02d", hours_24[i]) : String.format("%d", hours[i]);
199             innerHoursTexts[i] = String.format("%d", hours[i]);
200             minutesTexts[i] = String.format("%02d", minutes[i]);
201         }
202         mHourRadialTextsView.initialize(res,
203                 hoursTexts, (is24HourMode? innerHoursTexts : null), mHideAmPm, true);
204         mHourRadialTextsView.invalidate();
205         mMinuteRadialTextsView.initialize(res, minutesTexts, null, mHideAmPm, false);
206         mMinuteRadialTextsView.invalidate();
207 
208         // Initialize the currently-selected hour and minute.
209         setValueForItem(HOUR_INDEX, initialHoursOfDay);
210         setValueForItem(MINUTE_INDEX, initialMinutes);
211         int hourDegrees = (initialHoursOfDay % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE;
212         mHourRadialSelectorView.initialize(context, mHideAmPm, is24HourMode, true,
213                 hourDegrees, isHourInnerCircle(initialHoursOfDay));
214         int minuteDegrees = initialMinutes * MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
215         mMinuteRadialSelectorView.initialize(context, mHideAmPm, false, false,
216                 minuteDegrees, false);
217 
218         mTimeInitialized = true;
219     }
220 
setTheme(Context context, boolean themeDark)221     /* package */ void setTheme(Context context, boolean themeDark) {
222         mCircleView.setTheme(context, themeDark);
223         mAmPmCirclesView.setTheme(context, themeDark);
224         mHourRadialTextsView.setTheme(context, themeDark);
225         mMinuteRadialTextsView.setTheme(context, themeDark);
226         mHourRadialSelectorView.setTheme(context, themeDark);
227         mMinuteRadialSelectorView.setTheme(context, themeDark);
228    }
229 
setTime(int hours, int minutes)230     public void setTime(int hours, int minutes) {
231         setItem(HOUR_INDEX, hours);
232         setItem(MINUTE_INDEX, minutes);
233     }
234 
235     /**
236      * Set either the hour or the minute. Will set the internal value, and set the selection.
237      */
setItem(int index, int value)238     private void setItem(int index, int value) {
239         if (index == HOUR_INDEX) {
240             setValueForItem(HOUR_INDEX, value);
241             int hourDegrees = (value % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE;
242             mHourRadialSelectorView.setSelection(hourDegrees, isHourInnerCircle(value), false);
243             mHourRadialSelectorView.invalidate();
244         } else if (index == MINUTE_INDEX) {
245             setValueForItem(MINUTE_INDEX, value);
246             int minuteDegrees = value * MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
247             mMinuteRadialSelectorView.setSelection(minuteDegrees, false, false);
248             mMinuteRadialSelectorView.invalidate();
249         }
250     }
251 
252     /**
253      * Check if a given hour appears in the outer circle or the inner circle
254      * @return true if the hour is in the inner circle, false if it's in the outer circle.
255      */
isHourInnerCircle(int hourOfDay)256     private boolean isHourInnerCircle(int hourOfDay) {
257         // We'll have the 00 hours on the outside circle.
258         return mIs24HourMode && (hourOfDay <= 12 && hourOfDay != 0);
259     }
260 
getHours()261     public int getHours() {
262         return mCurrentHoursOfDay;
263     }
264 
getMinutes()265     public int getMinutes() {
266         return mCurrentMinutes;
267     }
268 
269     /**
270      * If the hours are showing, return the current hour. If the minutes are showing, return the
271      * current minute.
272      */
getCurrentlyShowingValue()273     private int getCurrentlyShowingValue() {
274         int currentIndex = getCurrentItemShowing();
275         if (currentIndex == HOUR_INDEX) {
276             return mCurrentHoursOfDay;
277         } else if (currentIndex == MINUTE_INDEX) {
278             return mCurrentMinutes;
279         } else {
280             return -1;
281         }
282     }
283 
getIsCurrentlyAmOrPm()284     public int getIsCurrentlyAmOrPm() {
285         if (mCurrentHoursOfDay < 12) {
286             return AM;
287         } else if (mCurrentHoursOfDay < 24) {
288             return PM;
289         }
290         return -1;
291     }
292 
293     /**
294      * Set the internal value for the hour, minute, or AM/PM.
295      */
setValueForItem(int index, int value)296     private void setValueForItem(int index, int value) {
297         if (index == HOUR_INDEX) {
298             mCurrentHoursOfDay = value;
299         } else if (index == MINUTE_INDEX){
300             mCurrentMinutes = value;
301         } else if (index == AMPM_INDEX) {
302             if (value == AM) {
303                 mCurrentHoursOfDay = mCurrentHoursOfDay % 12;
304             } else if (value == PM) {
305                 mCurrentHoursOfDay = (mCurrentHoursOfDay % 12) + 12;
306             }
307         }
308     }
309 
310     /**
311      * Set the internal value as either AM or PM, and update the AM/PM circle displays.
312      * @param amOrPm
313      */
setAmOrPm(int amOrPm)314     public void setAmOrPm(int amOrPm) {
315         mAmPmCirclesView.setAmOrPm(amOrPm);
316         mAmPmCirclesView.invalidate();
317         setValueForItem(AMPM_INDEX, amOrPm);
318     }
319 
320     /**
321      * Split up the 360 degrees of the circle among the 60 selectable values. Assigns a larger
322      * selectable area to each of the 12 visible values, such that the ratio of space apportioned
323      * to a visible value : space apportioned to a non-visible value will be 14 : 4.
324      * E.g. the output of 30 degrees should have a higher range of input associated with it than
325      * the output of 24 degrees, because 30 degrees corresponds to a visible number on the clock
326      * circle (5 on the minutes, 1 or 13 on the hours).
327      */
preparePrefer30sMap()328     private void preparePrefer30sMap() {
329         // We'll split up the visible output and the non-visible output such that each visible
330         // output will correspond to a range of 14 associated input degrees, and each non-visible
331         // output will correspond to a range of 4 associate input degrees, so visible numbers
332         // are more than 3 times easier to get than non-visible numbers:
333         // {354-359,0-7}:0, {8-11}:6, {12-15}:12, {16-19}:18, {20-23}:24, {24-37}:30, etc.
334         //
335         // If an output of 30 degrees should correspond to a range of 14 associated degrees, then
336         // we'll need any input between 24 - 37 to snap to 30. Working out from there, 20-23 should
337         // snap to 24, while 38-41 should snap to 36. This is somewhat counter-intuitive, that you
338         // can be touching 36 degrees but have the selection snapped to 30 degrees; however, this
339         // inconsistency isn't noticeable at such fine-grained degrees, and it affords us the
340         // ability to aggressively prefer the visible values by a factor of more than 3:1, which
341         // greatly contributes to the selectability of these values.
342 
343         // Our input will be 0 through 360.
344         mSnapPrefer30sMap = new int[361];
345 
346         // The first output is 0, and each following output will increment by 6 {0, 6, 12, ...}.
347         int snappedOutputDegrees = 0;
348         // Count of how many inputs we've designated to the specified output.
349         int count = 1;
350         // How many input we expect for a specified output. This will be 14 for output divisible
351         // by 30, and 4 for the remaining output. We'll special case the outputs of 0 and 360, so
352         // the caller can decide which they need.
353         int expectedCount = 8;
354         // Iterate through the input.
355         for (int degrees = 0; degrees < 361; degrees++) {
356             // Save the input-output mapping.
357             mSnapPrefer30sMap[degrees] = snappedOutputDegrees;
358             // If this is the last input for the specified output, calculate the next output and
359             // the next expected count.
360             if (count == expectedCount) {
361                 snappedOutputDegrees += 6;
362                 if (snappedOutputDegrees == 360) {
363                     expectedCount = 7;
364                 } else if (snappedOutputDegrees % 30 == 0) {
365                     expectedCount = 14;
366                 } else {
367                     expectedCount = 4;
368                 }
369                 count = 1;
370             } else {
371                 count++;
372             }
373         }
374     }
375 
376     /**
377      * Returns mapping of any input degrees (0 to 360) to one of 60 selectable output degrees,
378      * where the degrees corresponding to visible numbers (i.e. those divisible by 30) will be
379      * weighted heavier than the degrees corresponding to non-visible numbers.
380      * See {@link #preparePrefer30sMap()} documentation for the rationale and generation of the
381      * mapping.
382      */
snapPrefer30s(int degrees)383     private int snapPrefer30s(int degrees) {
384         if (mSnapPrefer30sMap == null) {
385             return -1;
386         }
387         return mSnapPrefer30sMap[degrees];
388     }
389 
390     /**
391      * Returns mapping of any input degrees (0 to 360) to one of 12 visible output degrees (all
392      * multiples of 30), where the input will be "snapped" to the closest visible degrees.
393      * @param degrees The input degrees
394      * @param forceAboveOrBelow The output may be forced to either the higher or lower step, or may
395      * be allowed to snap to whichever is closer. Use 1 to force strictly higher, -1 to force
396      * strictly lower, and 0 to snap to the closer one.
397      * @return output degrees, will be a multiple of 30
398      */
snapOnly30s(int degrees, int forceHigherOrLower)399     private static int snapOnly30s(int degrees, int forceHigherOrLower) {
400         int stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
401         int floor = (degrees / stepSize) * stepSize;
402         int ceiling = floor + stepSize;
403         if (forceHigherOrLower == 1) {
404             degrees = ceiling;
405         } else if (forceHigherOrLower == -1) {
406             if (degrees == floor) {
407                 floor -= stepSize;
408             }
409             degrees = floor;
410         } else {
411             if ((degrees - floor) < (ceiling - degrees)) {
412                 degrees = floor;
413             } else {
414                 degrees = ceiling;
415             }
416         }
417         return degrees;
418     }
419 
420     /**
421      * For the currently showing view (either hours or minutes), re-calculate the position for the
422      * selector, and redraw it at that position. The input degrees will be snapped to a selectable
423      * value.
424      * @param degrees Degrees which should be selected.
425      * @param isInnerCircle Whether the selection should be in the inner circle; will be ignored
426      * if there is no inner circle.
427      * @param forceToVisibleValue Even if the currently-showing circle allows for fine-grained
428      * selection (i.e. minutes), force the selection to one of the visibly-showing values.
429      * @param forceDrawDot The dot in the circle will generally only be shown when the selection
430      * is on non-visible values, but use this to force the dot to be shown.
431      * @return The value that was selected, i.e. 0-23 for hours, 0-59 for minutes.
432      */
reselectSelector(int degrees, boolean isInnerCircle, boolean forceToVisibleValue, boolean forceDrawDot)433     private int reselectSelector(int degrees, boolean isInnerCircle,
434             boolean forceToVisibleValue, boolean forceDrawDot) {
435         if (degrees == -1) {
436             return -1;
437         }
438         int currentShowing = getCurrentItemShowing();
439 
440         int stepSize;
441         boolean allowFineGrained = !forceToVisibleValue && (currentShowing == MINUTE_INDEX);
442         if (allowFineGrained) {
443             degrees = snapPrefer30s(degrees);
444         } else {
445             degrees = snapOnly30s(degrees, 0);
446         }
447 
448         RadialSelectorView radialSelectorView;
449         if (currentShowing == HOUR_INDEX) {
450             radialSelectorView = mHourRadialSelectorView;
451             stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
452         } else {
453             radialSelectorView = mMinuteRadialSelectorView;
454             stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
455         }
456         radialSelectorView.setSelection(degrees, isInnerCircle, forceDrawDot);
457         radialSelectorView.invalidate();
458 
459 
460         if (currentShowing == HOUR_INDEX) {
461             if (mIs24HourMode) {
462                 if (degrees == 0 && isInnerCircle) {
463                     degrees = 360;
464                 } else if (degrees == 360 && !isInnerCircle) {
465                     degrees = 0;
466                 }
467             } else if (degrees == 0) {
468                 degrees = 360;
469             }
470         } else if (degrees == 360 && currentShowing == MINUTE_INDEX) {
471             degrees = 0;
472         }
473 
474         int value = degrees / stepSize;
475         if (currentShowing == HOUR_INDEX && mIs24HourMode && !isInnerCircle && degrees != 0) {
476             value += 12;
477         }
478         return value;
479     }
480 
481     /**
482      * Calculate the degrees within the circle that corresponds to the specified coordinates, if
483      * the coordinates are within the range that will trigger a selection.
484      * @param pointX The x coordinate.
485      * @param pointY The y coordinate.
486      * @param forceLegal Force the selection to be legal, regardless of how far the coordinates are
487      * from the actual numbers.
488      * @param isInnerCircle If the selection may be in the inner circle, pass in a size-1 boolean
489      * array here, inside which the value will be true if the selection is in the inner circle,
490      * and false if in the outer circle.
491      * @return Degrees from 0 to 360, if the selection was within the legal range. -1 if not.
492      */
getDegreesFromCoords(float pointX, float pointY, boolean forceLegal, final Boolean[] isInnerCircle)493     private int getDegreesFromCoords(float pointX, float pointY, boolean forceLegal,
494             final Boolean[] isInnerCircle) {
495         int currentItem = getCurrentItemShowing();
496         if (currentItem == HOUR_INDEX) {
497             return mHourRadialSelectorView.getDegreesFromCoords(
498                     pointX, pointY, forceLegal, isInnerCircle);
499         } else if (currentItem == MINUTE_INDEX) {
500             return mMinuteRadialSelectorView.getDegreesFromCoords(
501                     pointX, pointY, forceLegal, isInnerCircle);
502         } else {
503             return -1;
504         }
505     }
506 
507     /**
508      * Get the item (hours or minutes) that is currently showing.
509      */
getCurrentItemShowing()510     public int getCurrentItemShowing() {
511         if (mCurrentItemShowing != HOUR_INDEX && mCurrentItemShowing != MINUTE_INDEX) {
512             Log.e(TAG, "Current item showing was unfortunately set to "+mCurrentItemShowing);
513             return -1;
514         }
515         return mCurrentItemShowing;
516     }
517 
518     /**
519      * Set either minutes or hours as showing.
520      * @param animate True to animate the transition, false to show with no animation.
521      */
setCurrentItemShowing(int index, boolean animate)522     public void setCurrentItemShowing(int index, boolean animate) {
523         if (index != HOUR_INDEX && index != MINUTE_INDEX) {
524             Log.e(TAG, "TimePicker does not support view at index "+index);
525             return;
526         }
527 
528         int lastIndex = getCurrentItemShowing();
529         mCurrentItemShowing = index;
530 
531         if (animate && (index != lastIndex)) {
532             ObjectAnimator[] anims = new ObjectAnimator[4];
533             if (index == MINUTE_INDEX) {
534                 anims[0] = mHourRadialTextsView.getDisappearAnimator();
535                 anims[1] = mHourRadialSelectorView.getDisappearAnimator();
536                 anims[2] = mMinuteRadialTextsView.getReappearAnimator();
537                 anims[3] = mMinuteRadialSelectorView.getReappearAnimator();
538             } else if (index == HOUR_INDEX){
539                 anims[0] = mHourRadialTextsView.getReappearAnimator();
540                 anims[1] = mHourRadialSelectorView.getReappearAnimator();
541                 anims[2] = mMinuteRadialTextsView.getDisappearAnimator();
542                 anims[3] = mMinuteRadialSelectorView.getDisappearAnimator();
543             }
544 
545             if (mTransition != null && mTransition.isRunning()) {
546                 mTransition.end();
547             }
548             mTransition = new AnimatorSet();
549             mTransition.playTogether(anims);
550             mTransition.start();
551         } else {
552             int hourAlpha = (index == HOUR_INDEX) ? 255 : 0;
553             int minuteAlpha = (index == MINUTE_INDEX) ? 255 : 0;
554             mHourRadialTextsView.setAlpha(hourAlpha);
555             mHourRadialSelectorView.setAlpha(hourAlpha);
556             mMinuteRadialTextsView.setAlpha(minuteAlpha);
557             mMinuteRadialSelectorView.setAlpha(minuteAlpha);
558         }
559 
560     }
561 
562     @Override
onTouch(View v, MotionEvent event)563     public boolean onTouch(View v, MotionEvent event) {
564         final float eventX = event.getX();
565         final float eventY = event.getY();
566         int degrees;
567         int value;
568         final Boolean[] isInnerCircle = new Boolean[1];
569         isInnerCircle[0] = false;
570 
571         switch(event.getAction()) {
572             case MotionEvent.ACTION_DOWN:
573                 if (!mInputEnabled) {
574                     return true;
575                 }
576 
577                 mDownX = eventX;
578                 mDownY = eventY;
579 
580                 mLastValueSelected = -1;
581                 mDoingMove = false;
582                 mDoingTouch = true;
583                 // If we're showing the AM/PM, check to see if the user is touching it.
584                 if (!mHideAmPm) {
585                     mIsTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY);
586                 } else {
587                     mIsTouchingAmOrPm = -1;
588                 }
589                 if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) {
590                     // If the touch is on AM or PM, set it as "touched" after the TAP_TIMEOUT
591                     // in case the user moves their finger quickly.
592                     mHapticFeedbackController.tryVibrate();
593                     mDownDegrees = -1;
594                     mHandler.postDelayed(new Runnable() {
595                         @Override
596                         public void run() {
597                             mAmPmCirclesView.setAmOrPmPressed(mIsTouchingAmOrPm);
598                             mAmPmCirclesView.invalidate();
599                         }
600                     }, TAP_TIMEOUT);
601                 } else {
602                     // If we're in accessibility mode, force the touch to be legal. Otherwise,
603                     // it will only register within the given touch target zone.
604                     boolean forceLegal = mAccessibilityManager.isTouchExplorationEnabled();
605                     // Calculate the degrees that is currently being touched.
606                     mDownDegrees = getDegreesFromCoords(eventX, eventY, forceLegal, isInnerCircle);
607                     if (mDownDegrees != -1) {
608                         // If it's a legal touch, set that number as "selected" after the
609                         // TAP_TIMEOUT in case the user moves their finger quickly.
610                         mHapticFeedbackController.tryVibrate();
611                         mHandler.postDelayed(new Runnable() {
612                             @Override
613                             public void run() {
614                                 mDoingMove = true;
615                                 int value = reselectSelector(mDownDegrees, isInnerCircle[0],
616                                         false, true);
617                                 mLastValueSelected = value;
618                                 mListener.onValueSelected(getCurrentItemShowing(), value, false);
619                             }
620                         }, TAP_TIMEOUT);
621                     }
622                 }
623                 return true;
624             case MotionEvent.ACTION_MOVE:
625                 if (!mInputEnabled) {
626                     // We shouldn't be in this state, because input is disabled.
627                     Log.e(TAG, "Input was disabled, but received ACTION_MOVE.");
628                     return true;
629                 }
630 
631                 float dY = Math.abs(eventY - mDownY);
632                 float dX = Math.abs(eventX - mDownX);
633 
634                 if (!mDoingMove && dX <= TOUCH_SLOP && dY <= TOUCH_SLOP) {
635                     // Hasn't registered down yet, just slight, accidental movement of finger.
636                     break;
637                 }
638 
639                 // If we're in the middle of touching down on AM or PM, check if we still are.
640                 // If so, no-op. If not, remove its pressed state. Either way, no need to check
641                 // for touches on the other circle.
642                 if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) {
643                     mHandler.removeCallbacksAndMessages(null);
644                     int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY);
645                     if (isTouchingAmOrPm != mIsTouchingAmOrPm) {
646                         mAmPmCirclesView.setAmOrPmPressed(-1);
647                         mAmPmCirclesView.invalidate();
648                         mIsTouchingAmOrPm = -1;
649                     }
650                     break;
651                 }
652 
653                 if (mDownDegrees == -1) {
654                     // Original down was illegal, so no movement will register.
655                     break;
656                 }
657 
658                 // We're doing a move along the circle, so move the selection as appropriate.
659                 mDoingMove = true;
660                 mHandler.removeCallbacksAndMessages(null);
661                 degrees = getDegreesFromCoords(eventX, eventY, true, isInnerCircle);
662                 if (degrees != -1) {
663                     value = reselectSelector(degrees, isInnerCircle[0], false, true);
664                     if (value != mLastValueSelected) {
665                         mHapticFeedbackController.tryVibrate();
666                         mLastValueSelected = value;
667                         mListener.onValueSelected(getCurrentItemShowing(), value, false);
668                     }
669                 }
670                 return true;
671             case MotionEvent.ACTION_UP:
672                 if (!mInputEnabled) {
673                     // If our touch input was disabled, tell the listener to re-enable us.
674                     Log.d(TAG, "Input was disabled, but received ACTION_UP.");
675                     mListener.onValueSelected(ENABLE_PICKER_INDEX, 1, false);
676                     return true;
677                 }
678 
679                 mHandler.removeCallbacksAndMessages(null);
680                 mDoingTouch = false;
681 
682                 // If we're touching AM or PM, set it as selected, and tell the listener.
683                 if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) {
684                     int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY);
685                     mAmPmCirclesView.setAmOrPmPressed(-1);
686                     mAmPmCirclesView.invalidate();
687 
688                     if (isTouchingAmOrPm == mIsTouchingAmOrPm) {
689                         mAmPmCirclesView.setAmOrPm(isTouchingAmOrPm);
690                         if (getIsCurrentlyAmOrPm() != isTouchingAmOrPm) {
691                             mListener.onValueSelected(AMPM_INDEX, mIsTouchingAmOrPm, false);
692                             setValueForItem(AMPM_INDEX, isTouchingAmOrPm);
693                         }
694                     }
695                     mIsTouchingAmOrPm = -1;
696                     break;
697                 }
698 
699                 // If we have a legal degrees selected, set the value and tell the listener.
700                 if (mDownDegrees != -1) {
701                     degrees = getDegreesFromCoords(eventX, eventY, mDoingMove, isInnerCircle);
702                     if (degrees != -1) {
703                         value = reselectSelector(degrees, isInnerCircle[0], !mDoingMove, false);
704                         if (getCurrentItemShowing() == HOUR_INDEX && !mIs24HourMode) {
705                             int amOrPm = getIsCurrentlyAmOrPm();
706                             if (amOrPm == AM && value == 12) {
707                                 value = 0;
708                             } else if (amOrPm == PM && value != 12) {
709                                 value += 12;
710                             }
711                         }
712                         setValueForItem(getCurrentItemShowing(), value);
713                         mListener.onValueSelected(getCurrentItemShowing(), value, true);
714                     }
715                 }
716                 mDoingMove = false;
717                 return true;
718             default:
719                 break;
720         }
721         return false;
722     }
723 
724     /**
725      * Set touch input as enabled or disabled, for use with keyboard mode.
726      */
trySettingInputEnabled(boolean inputEnabled)727     public boolean trySettingInputEnabled(boolean inputEnabled) {
728         if (mDoingTouch && !inputEnabled) {
729             // If we're trying to disable input, but we're in the middle of a touch event,
730             // we'll allow the touch event to continue before disabling input.
731             return false;
732         }
733         mInputEnabled = inputEnabled;
734         mGrayBox.setVisibility(inputEnabled? View.INVISIBLE : View.VISIBLE);
735         return true;
736     }
737 
738     /**
739      * Necessary for accessibility, to ensure we support "scrolling" forward and backward
740      * in the circle.
741      */
742     @Override
onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info)743     public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
744       super.onInitializeAccessibilityNodeInfo(info);
745       info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
746       info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
747     }
748 
749     /**
750      * Announce the currently-selected time when launched.
751      */
752     @Override
dispatchPopulateAccessibilityEvent(AccessibilityEvent event)753     public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
754         if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
755             // Clear the event's current text so that only the current time will be spoken.
756             event.getText().clear();
757             Time time = new Time();
758             time.hour = getHours();
759             time.minute = getMinutes();
760             long millis = time.normalize(true);
761             int flags = DateUtils.FORMAT_SHOW_TIME;
762             if (mIs24HourMode) {
763                 flags |= DateUtils.FORMAT_24HOUR;
764             }
765             String timeString = DateUtils.formatDateTime(getContext(), millis, flags);
766             event.getText().add(timeString);
767             return true;
768         }
769         return super.dispatchPopulateAccessibilityEvent(event);
770     }
771 
772     /**
773      * When scroll forward/backward events are received, jump the time to the higher/lower
774      * discrete, visible value on the circle.
775      */
776     @SuppressLint("NewApi")
777     @Override
performAccessibilityAction(int action, Bundle arguments)778     public boolean performAccessibilityAction(int action, Bundle arguments) {
779         if (super.performAccessibilityAction(action, arguments)) {
780             return true;
781         }
782 
783         int changeMultiplier = 0;
784         if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
785             changeMultiplier = 1;
786         } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
787             changeMultiplier = -1;
788         }
789         if (changeMultiplier != 0) {
790             int value = getCurrentlyShowingValue();
791             int stepSize = 0;
792             int currentItemShowing = getCurrentItemShowing();
793             if (currentItemShowing == HOUR_INDEX) {
794                 stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE;
795                 value %= 12;
796             } else if (currentItemShowing == MINUTE_INDEX) {
797                 stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE;
798             }
799 
800             int degrees = value * stepSize;
801             degrees = snapOnly30s(degrees, changeMultiplier);
802             value = degrees / stepSize;
803             int maxValue = 0;
804             int minValue = 0;
805             if (currentItemShowing == HOUR_INDEX) {
806                 if (mIs24HourMode) {
807                     maxValue = 23;
808                 } else {
809                     maxValue = 12;
810                     minValue = 1;
811                 }
812             } else {
813                 maxValue = 55;
814             }
815             if (value > maxValue) {
816                 // If we scrolled forward past the highest number, wrap around to the lowest.
817                 value = minValue;
818             } else if (value < minValue) {
819                 // If we scrolled backward past the lowest number, wrap around to the highest.
820                 value = maxValue;
821             }
822             setItem(currentItemShowing, value);
823             mListener.onValueSelected(currentItemShowing, value, false);
824             return true;
825         }
826 
827         return false;
828     }
829 }
830