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