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