RadialTimePickerView.java revision 3fc00e3139706c2c90f8e7261eef48086887dc11
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 android.widget;
18
19import android.animation.Animator;
20import android.animation.AnimatorSet;
21import android.animation.Keyframe;
22import android.animation.ObjectAnimator;
23import android.animation.PropertyValuesHolder;
24import android.animation.ValueAnimator;
25import android.content.Context;
26import android.content.res.Resources;
27import android.content.res.TypedArray;
28import android.graphics.Canvas;
29import android.graphics.Color;
30import android.graphics.Paint;
31import android.graphics.Rect;
32import android.graphics.Typeface;
33import android.os.Bundle;
34import android.util.AttributeSet;
35import android.util.IntArray;
36import android.util.Log;
37import android.util.MathUtils;
38import android.util.TypedValue;
39import android.view.HapticFeedbackConstants;
40import android.view.MotionEvent;
41import android.view.View;
42import android.view.ViewGroup;
43import android.view.accessibility.AccessibilityEvent;
44import android.view.accessibility.AccessibilityNodeInfo;
45import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
46
47import com.android.internal.R;
48import com.android.internal.widget.ExploreByTouchHelper;
49
50import java.util.ArrayList;
51import java.util.Calendar;
52import java.util.Locale;
53
54/**
55 * View to show a clock circle picker (with one or two picking circles)
56 *
57 * @hide
58 */
59public class RadialTimePickerView extends View implements View.OnTouchListener {
60    private static final String TAG = "ClockView";
61
62    private static final boolean DEBUG = false;
63
64    private static final int DEBUG_COLOR = 0x20FF0000;
65    private static final int DEBUG_TEXT_COLOR = 0x60FF0000;
66    private static final int DEBUG_STROKE_WIDTH = 2;
67
68    private static final int HOURS = 0;
69    private static final int MINUTES = 1;
70    private static final int HOURS_INNER = 2;
71
72    private static final int SELECTOR_CIRCLE = 0;
73    private static final int SELECTOR_DOT = 1;
74    private static final int SELECTOR_LINE = 2;
75
76    private static final int AM = 0;
77    private static final int PM = 1;
78
79    // Opaque alpha level
80    private static final int ALPHA_OPAQUE = 255;
81
82    // Transparent alpha level
83    private static final int ALPHA_TRANSPARENT = 0;
84
85    // Alpha level of color for selector.
86    private static final int ALPHA_SELECTOR = 60; // was 51
87
88    private static final float COSINE_30_DEGREES = ((float) Math.sqrt(3)) * 0.5f;
89    private static final float SINE_30_DEGREES = 0.5f;
90
91    private static final int DEGREES_FOR_ONE_HOUR = 30;
92    private static final int DEGREES_FOR_ONE_MINUTE = 6;
93
94    private static final int[] HOURS_NUMBERS = {12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
95    private static final int[] HOURS_NUMBERS_24 = {0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23};
96    private static final int[] MINUTES_NUMBERS = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55};
97
98    private static final int CENTER_RADIUS = 2;
99
100    private static int[] sSnapPrefer30sMap = new int[361];
101
102    private final InvalidateUpdateListener mInvalidateUpdateListener =
103            new InvalidateUpdateListener();
104
105    private final String[] mHours12Texts = new String[12];
106    private final String[] mOuterHours24Texts = new String[12];
107    private final String[] mInnerHours24Texts = new String[12];
108    private final String[] mMinutesTexts = new String[12];
109
110    private final Paint[] mPaint = new Paint[2];
111    private final int[] mColor = new int[2];
112    private final IntHolder[] mAlpha = new IntHolder[2];
113
114    private final Paint mPaintCenter = new Paint();
115
116    private final Paint[][] mPaintSelector = new Paint[2][3];
117    private final int[][] mColorSelector = new int[2][3];
118    private final IntHolder[][] mAlphaSelector = new IntHolder[2][3];
119
120    private final Paint mPaintBackground = new Paint();
121    private final Paint mPaintDebug = new Paint();
122
123    private final Typeface mTypeface;
124
125    private final float[] mCircleRadius = new float[3];
126
127    private final float[] mTextSize = new float[2];
128
129    private final float[][] mTextGridHeights = new float[2][7];
130    private final float[][] mTextGridWidths = new float[2][7];
131
132    private final float[] mInnerTextGridHeights = new float[7];
133    private final float[] mInnerTextGridWidths = new float[7];
134
135    private final float[] mCircleRadiusMultiplier = new float[2];
136    private final float[] mNumbersRadiusMultiplier = new float[3];
137
138    private final float[] mTextSizeMultiplier = new float[3];
139
140    private final float[] mAnimationRadiusMultiplier = new float[3];
141
142    private final float mTransitionMidRadiusMultiplier;
143    private final float mTransitionEndRadiusMultiplier;
144
145    private final int[] mLineLength = new int[3];
146    private final int[] mSelectionRadius = new int[3];
147    private final float mSelectionRadiusMultiplier;
148    private final int[] mSelectionDegrees = new int[3];
149
150    private final ArrayList<Animator> mHoursToMinutesAnims = new ArrayList<Animator>();
151    private final ArrayList<Animator> mMinuteToHoursAnims = new ArrayList<Animator>();
152
153    private final RadialPickerTouchHelper mTouchHelper;
154
155    private float mInnerTextSize;
156
157    private boolean mIs24HourMode;
158    private boolean mShowHours;
159
160    /**
161     * When in 24-hour mode, indicates that the current hour is between
162     * 1 and 12 (inclusive).
163     */
164    private boolean mIsOnInnerCircle;
165
166    private int mXCenter;
167    private int mYCenter;
168
169    private int mMinHypotenuseForInnerNumber;
170    private int mMaxHypotenuseForOuterNumber;
171    private int mHalfwayHypotenusePoint;
172
173    private String[] mOuterTextHours;
174    private String[] mInnerTextHours;
175    private String[] mOuterTextMinutes;
176    private AnimatorSet mTransition;
177
178    private int mAmOrPm;
179    private int mDisabledAlpha;
180
181    private OnValueSelectedListener mListener;
182
183    private boolean mInputEnabled = true;
184
185    public interface OnValueSelectedListener {
186        void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance);
187    }
188
189    static {
190        // Prepare mapping to snap touchable degrees to selectable degrees.
191        preparePrefer30sMap();
192    }
193
194    /**
195     * Split up the 360 degrees of the circle among the 60 selectable values. Assigns a larger
196     * selectable area to each of the 12 visible values, such that the ratio of space apportioned
197     * to a visible value : space apportioned to a non-visible value will be 14 : 4.
198     * E.g. the output of 30 degrees should have a higher range of input associated with it than
199     * the output of 24 degrees, because 30 degrees corresponds to a visible number on the clock
200     * circle (5 on the minutes, 1 or 13 on the hours).
201     */
202    private static void preparePrefer30sMap() {
203        // We'll split up the visible output and the non-visible output such that each visible
204        // output will correspond to a range of 14 associated input degrees, and each non-visible
205        // output will correspond to a range of 4 associate input degrees, so visible numbers
206        // are more than 3 times easier to get than non-visible numbers:
207        // {354-359,0-7}:0, {8-11}:6, {12-15}:12, {16-19}:18, {20-23}:24, {24-37}:30, etc.
208        //
209        // If an output of 30 degrees should correspond to a range of 14 associated degrees, then
210        // we'll need any input between 24 - 37 to snap to 30. Working out from there, 20-23 should
211        // snap to 24, while 38-41 should snap to 36. This is somewhat counter-intuitive, that you
212        // can be touching 36 degrees but have the selection snapped to 30 degrees; however, this
213        // inconsistency isn't noticeable at such fine-grained degrees, and it affords us the
214        // ability to aggressively prefer the visible values by a factor of more than 3:1, which
215        // greatly contributes to the selectability of these values.
216
217        // The first output is 0, and each following output will increment by 6 {0, 6, 12, ...}.
218        int snappedOutputDegrees = 0;
219        // Count of how many inputs we've designated to the specified output.
220        int count = 1;
221        // How many input we expect for a specified output. This will be 14 for output divisible
222        // by 30, and 4 for the remaining output. We'll special case the outputs of 0 and 360, so
223        // the caller can decide which they need.
224        int expectedCount = 8;
225        // Iterate through the input.
226        for (int degrees = 0; degrees < 361; degrees++) {
227            // Save the input-output mapping.
228            sSnapPrefer30sMap[degrees] = snappedOutputDegrees;
229            // If this is the last input for the specified output, calculate the next output and
230            // the next expected count.
231            if (count == expectedCount) {
232                snappedOutputDegrees += 6;
233                if (snappedOutputDegrees == 360) {
234                    expectedCount = 7;
235                } else if (snappedOutputDegrees % 30 == 0) {
236                    expectedCount = 14;
237                } else {
238                    expectedCount = 4;
239                }
240                count = 1;
241            } else {
242                count++;
243            }
244        }
245    }
246
247    /**
248     * Returns mapping of any input degrees (0 to 360) to one of 60 selectable output degrees,
249     * where the degrees corresponding to visible numbers (i.e. those divisible by 30) will be
250     * weighted heavier than the degrees corresponding to non-visible numbers.
251     * See {@link #preparePrefer30sMap()} documentation for the rationale and generation of the
252     * mapping.
253     */
254    private static int snapPrefer30s(int degrees) {
255        if (sSnapPrefer30sMap == null) {
256            return -1;
257        }
258        return sSnapPrefer30sMap[degrees];
259    }
260
261    /**
262     * Returns mapping of any input degrees (0 to 360) to one of 12 visible output degrees (all
263     * multiples of 30), where the input will be "snapped" to the closest visible degrees.
264     * @param degrees The input degrees
265     * @param forceHigherOrLower The output may be forced to either the higher or lower step, or may
266     * be allowed to snap to whichever is closer. Use 1 to force strictly higher, -1 to force
267     * strictly lower, and 0 to snap to the closer one.
268     * @return output degrees, will be a multiple of 30
269     */
270    private static int snapOnly30s(int degrees, int forceHigherOrLower) {
271        final int stepSize = DEGREES_FOR_ONE_HOUR;
272        int floor = (degrees / stepSize) * stepSize;
273        final int ceiling = floor + stepSize;
274        if (forceHigherOrLower == 1) {
275            degrees = ceiling;
276        } else if (forceHigherOrLower == -1) {
277            if (degrees == floor) {
278                floor -= stepSize;
279            }
280            degrees = floor;
281        } else {
282            if ((degrees - floor) < (ceiling - degrees)) {
283                degrees = floor;
284            } else {
285                degrees = ceiling;
286            }
287        }
288        return degrees;
289    }
290
291    @SuppressWarnings("unused")
292    public RadialTimePickerView(Context context)  {
293        this(context, null);
294    }
295
296    public RadialTimePickerView(Context context, AttributeSet attrs)  {
297        this(context, attrs, R.attr.timePickerStyle);
298    }
299
300    public RadialTimePickerView(Context context, AttributeSet attrs, int defStyleAttr)  {
301        this(context, attrs, defStyleAttr, 0);
302    }
303
304    public RadialTimePickerView(
305            Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)  {
306        super(context, attrs);
307
308        // Pull disabled alpha from theme.
309        final TypedValue outValue = new TypedValue();
310        context.getTheme().resolveAttribute(android.R.attr.disabledAlpha, outValue, true);
311        mDisabledAlpha = (int) (outValue.getFloat() * 255 + 0.5f);
312
313        // process style attributes
314        final Resources res = getResources();
315        final TypedArray a = mContext.obtainStyledAttributes(attrs, R.styleable.TimePicker,
316                defStyleAttr, defStyleRes);
317
318        mTypeface = Typeface.create("sans-serif", Typeface.NORMAL);
319
320        // Initialize all alpha values to opaque.
321        for (int i = 0; i < mAlpha.length; i++) {
322            mAlpha[i] = new IntHolder(ALPHA_OPAQUE);
323        }
324        for (int i = 0; i < mAlphaSelector.length; i++) {
325            for (int j = 0; j < mAlphaSelector[i].length; j++) {
326                mAlphaSelector[i][j] = new IntHolder(ALPHA_OPAQUE);
327            }
328        }
329
330        final int numbersTextColor = a.getColor(R.styleable.TimePicker_numbersTextColor,
331                res.getColor(R.color.timepicker_default_text_color_material));
332
333        mPaint[HOURS] = new Paint();
334        mPaint[HOURS].setAntiAlias(true);
335        mPaint[HOURS].setTextAlign(Paint.Align.CENTER);
336        mColor[HOURS] = numbersTextColor;
337
338        mPaint[MINUTES] = new Paint();
339        mPaint[MINUTES].setAntiAlias(true);
340        mPaint[MINUTES].setTextAlign(Paint.Align.CENTER);
341        mColor[MINUTES] = numbersTextColor;
342
343        mPaintCenter.setColor(numbersTextColor);
344        mPaintCenter.setAntiAlias(true);
345        mPaintCenter.setTextAlign(Paint.Align.CENTER);
346
347        mPaintSelector[HOURS][SELECTOR_CIRCLE] = new Paint();
348        mPaintSelector[HOURS][SELECTOR_CIRCLE].setAntiAlias(true);
349        mColorSelector[HOURS][SELECTOR_CIRCLE] = a.getColor(
350                R.styleable.TimePicker_numbersSelectorColor,
351                R.color.timepicker_default_selector_color_material);
352
353        mPaintSelector[HOURS][SELECTOR_DOT] = new Paint();
354        mPaintSelector[HOURS][SELECTOR_DOT].setAntiAlias(true);
355        mColorSelector[HOURS][SELECTOR_DOT] = a.getColor(
356                R.styleable.TimePicker_numbersSelectorColor,
357                R.color.timepicker_default_selector_color_material);
358
359        mPaintSelector[HOURS][SELECTOR_LINE] = new Paint();
360        mPaintSelector[HOURS][SELECTOR_LINE].setAntiAlias(true);
361        mPaintSelector[HOURS][SELECTOR_LINE].setStrokeWidth(2);
362        mColorSelector[HOURS][SELECTOR_LINE] = a.getColor(
363                R.styleable.TimePicker_numbersSelectorColor,
364                R.color.timepicker_default_selector_color_material);
365
366        mPaintSelector[MINUTES][SELECTOR_CIRCLE] = new Paint();
367        mPaintSelector[MINUTES][SELECTOR_CIRCLE].setAntiAlias(true);
368        mColorSelector[MINUTES][SELECTOR_CIRCLE] = a.getColor(
369                R.styleable.TimePicker_numbersSelectorColor,
370                R.color.timepicker_default_selector_color_material);
371
372        mPaintSelector[MINUTES][SELECTOR_DOT] = new Paint();
373        mPaintSelector[MINUTES][SELECTOR_DOT].setAntiAlias(true);
374        mColorSelector[MINUTES][SELECTOR_DOT] = a.getColor(
375                R.styleable.TimePicker_numbersSelectorColor,
376                R.color.timepicker_default_selector_color_material);
377
378        mPaintSelector[MINUTES][SELECTOR_LINE] = new Paint();
379        mPaintSelector[MINUTES][SELECTOR_LINE].setAntiAlias(true);
380        mPaintSelector[MINUTES][SELECTOR_LINE].setStrokeWidth(2);
381        mColorSelector[MINUTES][SELECTOR_LINE] = a.getColor(
382                R.styleable.TimePicker_numbersSelectorColor,
383                R.color.timepicker_default_selector_color_material);
384
385        mPaintBackground.setColor(a.getColor(R.styleable.TimePicker_numbersBackgroundColor,
386                res.getColor(R.color.timepicker_default_numbers_background_color_material)));
387        mPaintBackground.setAntiAlias(true);
388
389        if (DEBUG) {
390            mPaintDebug.setColor(DEBUG_COLOR);
391            mPaintDebug.setAntiAlias(true);
392            mPaintDebug.setStrokeWidth(DEBUG_STROKE_WIDTH);
393            mPaintDebug.setStyle(Paint.Style.STROKE);
394            mPaintDebug.setTextAlign(Paint.Align.CENTER);
395        }
396
397        mShowHours = true;
398        mIs24HourMode = false;
399        mAmOrPm = AM;
400
401        // Set up accessibility components.
402        mTouchHelper = new RadialPickerTouchHelper();
403        setAccessibilityDelegate(mTouchHelper);
404
405        if (getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
406            setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
407        }
408
409        initHoursAndMinutesText();
410        initData();
411
412        mTransitionMidRadiusMultiplier =  Float.parseFloat(
413                res.getString(R.string.timepicker_transition_mid_radius_multiplier));
414        mTransitionEndRadiusMultiplier = Float.parseFloat(
415                res.getString(R.string.timepicker_transition_end_radius_multiplier));
416
417        mTextGridHeights[HOURS] = new float[7];
418        mTextGridHeights[MINUTES] = new float[7];
419
420        mSelectionRadiusMultiplier = Float.parseFloat(
421                res.getString(R.string.timepicker_selection_radius_multiplier));
422
423        a.recycle();
424
425        setOnTouchListener(this);
426        setClickable(true);
427
428        // Initial values
429        final Calendar calendar = Calendar.getInstance(Locale.getDefault());
430        final int currentHour = calendar.get(Calendar.HOUR_OF_DAY);
431        final int currentMinute = calendar.get(Calendar.MINUTE);
432
433        setCurrentHourInternal(currentHour, false, false);
434        setCurrentMinuteInternal(currentMinute, false);
435
436        setHapticFeedbackEnabled(true);
437    }
438
439    /**
440     * Measure the view to end up as a square, based on the minimum of the height and width.
441     */
442    @Override
443    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
444        int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
445        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
446        int measuredHeight = MeasureSpec.getSize(heightMeasureSpec);
447        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
448        int minDimension = Math.min(measuredWidth, measuredHeight);
449
450        super.onMeasure(MeasureSpec.makeMeasureSpec(minDimension, widthMode),
451                MeasureSpec.makeMeasureSpec(minDimension, heightMode));
452    }
453
454    public void initialize(int hour, int minute, boolean is24HourMode) {
455        if (mIs24HourMode != is24HourMode) {
456            mIs24HourMode = is24HourMode;
457            initData();
458        }
459
460        setCurrentHourInternal(hour, false, false);
461        setCurrentMinuteInternal(minute, false);
462    }
463
464    public void setCurrentItemShowing(int item, boolean animate) {
465        switch (item){
466            case HOURS:
467                showHours(animate);
468                break;
469            case MINUTES:
470                showMinutes(animate);
471                break;
472            default:
473                Log.e(TAG, "ClockView does not support showing item " + item);
474        }
475    }
476
477    public int getCurrentItemShowing() {
478        return mShowHours ? HOURS : MINUTES;
479    }
480
481    public void setOnValueSelectedListener(OnValueSelectedListener listener) {
482        mListener = listener;
483    }
484
485    /**
486     * Sets the current hour in 24-hour time.
487     *
488     * @param hour the current hour between 0 and 23 (inclusive)
489     */
490    public void setCurrentHour(int hour) {
491        setCurrentHourInternal(hour, true, false);
492    }
493
494    /**
495     * Sets the current hour.
496     *
497     * @param hour The current hour
498     * @param callback Whether the value listener should be invoked
499     * @param autoAdvance Whether the listener should auto-advance to the next
500     *                    selection mode, e.g. hour to minutes
501     */
502    private void setCurrentHourInternal(int hour, boolean callback, boolean autoAdvance) {
503        final int degrees = (hour % 12) * DEGREES_FOR_ONE_HOUR;
504        mSelectionDegrees[HOURS] = degrees;
505        mSelectionDegrees[HOURS_INNER] = degrees;
506
507        // 0 is 12 AM (midnight) and 12 is 12 PM (noon).
508        final int amOrPm = (hour == 0 || (hour % 24) < 12) ? AM : PM;
509        final boolean isOnInnerCircle = mIs24HourMode && hour >= 1 && hour <= 12;
510        if (mAmOrPm != amOrPm || mIsOnInnerCircle != isOnInnerCircle) {
511            mAmOrPm = amOrPm;
512            mIsOnInnerCircle = isOnInnerCircle;
513
514            initData();
515            updateLayoutData();
516            mTouchHelper.invalidateRoot();
517        }
518
519        invalidate();
520
521        if (callback && mListener != null) {
522            mListener.onValueSelected(HOURS, hour, autoAdvance);
523        }
524    }
525
526    /**
527     * Returns the current hour in 24-hour time.
528     *
529     * @return the current hour between 0 and 23 (inclusive)
530     */
531    public int getCurrentHour() {
532        return getHourForDegrees(
533                mSelectionDegrees[mIsOnInnerCircle ? HOURS_INNER : HOURS], mIsOnInnerCircle);
534    }
535
536    private int getHourForDegrees(int degrees, boolean innerCircle) {
537        int hour = (degrees / DEGREES_FOR_ONE_HOUR) % 12;
538        if (mIs24HourMode) {
539            // Convert the 12-hour value into 24-hour time based on where the
540            // selector is positioned.
541            if (innerCircle && hour == 0) {
542                // Inner circle is 1 through 12.
543                hour = 12;
544            } else if (!innerCircle && hour != 0) {
545                // Outer circle is 13 through 23 and 0.
546                hour += 12;
547            }
548        } else if (mAmOrPm == PM) {
549            hour += 12;
550        }
551        return hour;
552    }
553
554    private int getDegreesForHour(int hour) {
555        // Convert to be 0-11.
556        if (mIs24HourMode) {
557            if (hour >= 12) {
558                hour -= 12;
559            }
560        } else if (hour == 12) {
561            hour = 0;
562        }
563        return hour * DEGREES_FOR_ONE_HOUR;
564    }
565
566    public void setCurrentMinute(int minute) {
567        setCurrentMinuteInternal(minute, true);
568    }
569
570    private void setCurrentMinuteInternal(int minute, boolean callback) {
571        mSelectionDegrees[MINUTES] = (minute % 60) * DEGREES_FOR_ONE_MINUTE;
572
573        invalidate();
574
575        if (callback && mListener != null) {
576            mListener.onValueSelected(MINUTES, minute, false);
577        }
578    }
579
580    // Returns minutes in 0-59 range
581    public int getCurrentMinute() {
582        return getMinuteForDegrees(mSelectionDegrees[MINUTES]);
583    }
584
585    private int getMinuteForDegrees(int degrees) {
586        return degrees / DEGREES_FOR_ONE_MINUTE;
587    }
588
589    private int getDegreesForMinute(int minute) {
590        return minute * DEGREES_FOR_ONE_MINUTE;
591    }
592
593    public void setAmOrPm(int val) {
594        mAmOrPm = (val % 2);
595        invalidate();
596        mTouchHelper.invalidateRoot();
597    }
598
599    public int getAmOrPm() {
600        return mAmOrPm;
601    }
602
603    public void showHours(boolean animate) {
604        if (mShowHours) return;
605        mShowHours = true;
606        if (animate) {
607            startMinutesToHoursAnimation();
608        }
609        initData();
610        updateLayoutData();
611        invalidate();
612    }
613
614    public void showMinutes(boolean animate) {
615        if (!mShowHours) return;
616        mShowHours = false;
617        if (animate) {
618            startHoursToMinutesAnimation();
619        }
620        initData();
621        updateLayoutData();
622        invalidate();
623    }
624
625    private void initHoursAndMinutesText() {
626        // Initialize the hours and minutes numbers.
627        for (int i = 0; i < 12; i++) {
628            mHours12Texts[i] = String.format("%d", HOURS_NUMBERS[i]);
629            mOuterHours24Texts[i] = String.format("%02d", HOURS_NUMBERS_24[i]);
630            mInnerHours24Texts[i] = String.format("%d", HOURS_NUMBERS[i]);
631            mMinutesTexts[i] = String.format("%02d", MINUTES_NUMBERS[i]);
632        }
633    }
634
635    private void initData() {
636        if (mIs24HourMode) {
637            mOuterTextHours = mOuterHours24Texts;
638            mInnerTextHours = mInnerHours24Texts;
639        } else {
640            mOuterTextHours = mHours12Texts;
641            mInnerTextHours = null;
642        }
643
644        mOuterTextMinutes = mMinutesTexts;
645
646        final Resources res = getResources();
647
648        if (mShowHours) {
649            if (mIs24HourMode) {
650                mCircleRadiusMultiplier[HOURS] = Float.parseFloat(
651                        res.getString(R.string.timepicker_circle_radius_multiplier_24HourMode));
652                mNumbersRadiusMultiplier[HOURS] = Float.parseFloat(
653                        res.getString(R.string.timepicker_numbers_radius_multiplier_outer));
654                mTextSizeMultiplier[HOURS] = Float.parseFloat(
655                        res.getString(R.string.timepicker_text_size_multiplier_outer));
656
657                mNumbersRadiusMultiplier[HOURS_INNER] = Float.parseFloat(
658                        res.getString(R.string.timepicker_numbers_radius_multiplier_inner));
659                mTextSizeMultiplier[HOURS_INNER] = Float.parseFloat(
660                        res.getString(R.string.timepicker_text_size_multiplier_inner));
661            } else {
662                mCircleRadiusMultiplier[HOURS] = Float.parseFloat(
663                        res.getString(R.string.timepicker_circle_radius_multiplier));
664                mNumbersRadiusMultiplier[HOURS] = Float.parseFloat(
665                        res.getString(R.string.timepicker_numbers_radius_multiplier_normal));
666                mTextSizeMultiplier[HOURS] = Float.parseFloat(
667                        res.getString(R.string.timepicker_text_size_multiplier_normal));
668            }
669        } else {
670            mCircleRadiusMultiplier[MINUTES] = Float.parseFloat(
671                    res.getString(R.string.timepicker_circle_radius_multiplier));
672            mNumbersRadiusMultiplier[MINUTES] = Float.parseFloat(
673                    res.getString(R.string.timepicker_numbers_radius_multiplier_normal));
674            mTextSizeMultiplier[MINUTES] = Float.parseFloat(
675                    res.getString(R.string.timepicker_text_size_multiplier_normal));
676        }
677
678        mAnimationRadiusMultiplier[HOURS] = 1;
679        mAnimationRadiusMultiplier[HOURS_INNER] = 1;
680        mAnimationRadiusMultiplier[MINUTES] = 1;
681
682        mAlpha[HOURS].setValue(mShowHours ? ALPHA_OPAQUE : ALPHA_TRANSPARENT);
683        mAlpha[MINUTES].setValue(mShowHours ? ALPHA_TRANSPARENT : ALPHA_OPAQUE);
684
685        mAlphaSelector[HOURS][SELECTOR_CIRCLE].setValue(
686                mShowHours ? ALPHA_SELECTOR : ALPHA_TRANSPARENT);
687        mAlphaSelector[HOURS][SELECTOR_DOT].setValue(
688                mShowHours ? ALPHA_OPAQUE : ALPHA_TRANSPARENT);
689        mAlphaSelector[HOURS][SELECTOR_LINE].setValue(
690                mShowHours ? ALPHA_SELECTOR : ALPHA_TRANSPARENT);
691
692        mAlphaSelector[MINUTES][SELECTOR_CIRCLE].setValue(
693                mShowHours ? ALPHA_TRANSPARENT : ALPHA_SELECTOR);
694        mAlphaSelector[MINUTES][SELECTOR_DOT].setValue(
695                mShowHours ? ALPHA_TRANSPARENT : ALPHA_OPAQUE);
696        mAlphaSelector[MINUTES][SELECTOR_LINE].setValue(
697                mShowHours ? ALPHA_TRANSPARENT : ALPHA_SELECTOR);
698    }
699
700    @Override
701    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
702        updateLayoutData();
703    }
704
705    private void updateLayoutData() {
706        mXCenter = getWidth() / 2;
707        mYCenter = getHeight() / 2;
708
709        final int min = Math.min(mXCenter, mYCenter);
710
711        mCircleRadius[HOURS] = min * mCircleRadiusMultiplier[HOURS];
712        mCircleRadius[HOURS_INNER] = min * mCircleRadiusMultiplier[HOURS];
713        mCircleRadius[MINUTES] = min * mCircleRadiusMultiplier[MINUTES];
714
715        mMinHypotenuseForInnerNumber = (int) (mCircleRadius[HOURS]
716                * mNumbersRadiusMultiplier[HOURS_INNER]) - mSelectionRadius[HOURS];
717        mMaxHypotenuseForOuterNumber = (int) (mCircleRadius[HOURS]
718                * mNumbersRadiusMultiplier[HOURS]) + mSelectionRadius[HOURS];
719        mHalfwayHypotenusePoint = (int) (mCircleRadius[HOURS]
720                * ((mNumbersRadiusMultiplier[HOURS] + mNumbersRadiusMultiplier[HOURS_INNER]) / 2));
721
722        mTextSize[HOURS] = mCircleRadius[HOURS] * mTextSizeMultiplier[HOURS];
723        mTextSize[MINUTES] = mCircleRadius[MINUTES] * mTextSizeMultiplier[MINUTES];
724
725        if (mIs24HourMode) {
726            mInnerTextSize = mCircleRadius[HOURS] * mTextSizeMultiplier[HOURS_INNER];
727        }
728
729        calculateGridSizesHours();
730        calculateGridSizesMinutes();
731
732        mSelectionRadius[HOURS] = (int) (mCircleRadius[HOURS] * mSelectionRadiusMultiplier);
733        mSelectionRadius[HOURS_INNER] = mSelectionRadius[HOURS];
734        mSelectionRadius[MINUTES] = (int) (mCircleRadius[MINUTES] * mSelectionRadiusMultiplier);
735
736        mTouchHelper.invalidateRoot();
737    }
738
739    @Override
740    public void onDraw(Canvas canvas) {
741        if (!mInputEnabled) {
742            canvas.saveLayerAlpha(0, 0, getWidth(), getHeight(), mDisabledAlpha);
743        } else {
744            canvas.save();
745        }
746
747        calculateGridSizesHours();
748        calculateGridSizesMinutes();
749
750        drawCircleBackground(canvas);
751        drawSelector(canvas);
752
753        drawTextElements(canvas, mTextSize[HOURS], mTypeface, mOuterTextHours,
754                mTextGridWidths[HOURS], mTextGridHeights[HOURS], mPaint[HOURS],
755                mColor[HOURS], mAlpha[HOURS].getValue());
756
757        if (mIs24HourMode && mInnerTextHours != null) {
758            drawTextElements(canvas, mInnerTextSize, mTypeface, mInnerTextHours,
759                    mInnerTextGridWidths, mInnerTextGridHeights, mPaint[HOURS],
760                    mColor[HOURS], mAlpha[HOURS].getValue());
761        }
762
763        drawTextElements(canvas, mTextSize[MINUTES], mTypeface, mOuterTextMinutes,
764                mTextGridWidths[MINUTES], mTextGridHeights[MINUTES], mPaint[MINUTES],
765                mColor[MINUTES], mAlpha[MINUTES].getValue());
766
767        drawCenter(canvas);
768
769        if (DEBUG) {
770            drawDebug(canvas);
771        }
772
773        canvas.restore();
774    }
775
776    private void drawCircleBackground(Canvas canvas) {
777        canvas.drawCircle(mXCenter, mYCenter, mCircleRadius[HOURS], mPaintBackground);
778    }
779
780    private void drawCenter(Canvas canvas) {
781        canvas.drawCircle(mXCenter, mYCenter, CENTER_RADIUS, mPaintCenter);
782    }
783
784    private void drawSelector(Canvas canvas) {
785        drawSelector(canvas, mIsOnInnerCircle ? HOURS_INNER : HOURS);
786        drawSelector(canvas, MINUTES);
787    }
788
789    private int getMultipliedAlpha(int argb, int alpha) {
790        return (int) (Color.alpha(argb) * (alpha / 255.0) + 0.5);
791    }
792
793    private void drawSelector(Canvas canvas, int index) {
794        // Calculate the current radius at which to place the selection circle.
795        mLineLength[index] = (int) (mCircleRadius[index]
796                * mNumbersRadiusMultiplier[index] * mAnimationRadiusMultiplier[index]);
797
798        double selectionRadians = Math.toRadians(mSelectionDegrees[index]);
799
800        int pointX = mXCenter + (int) (mLineLength[index] * Math.sin(selectionRadians));
801        int pointY = mYCenter - (int) (mLineLength[index] * Math.cos(selectionRadians));
802
803        int color;
804        int alpha;
805        Paint paint;
806
807        // Draw the selection circle
808        color = mColorSelector[index % 2][SELECTOR_CIRCLE];
809        alpha = mAlphaSelector[index % 2][SELECTOR_CIRCLE].getValue();
810        paint = mPaintSelector[index % 2][SELECTOR_CIRCLE];
811        paint.setColor(color);
812        paint.setAlpha(getMultipliedAlpha(color, alpha));
813        canvas.drawCircle(pointX, pointY, mSelectionRadius[index], paint);
814
815        // Draw the dot if needed
816        if (mSelectionDegrees[index] % 30 != 0) {
817            // We're not on a direct tick
818            color = mColorSelector[index % 2][SELECTOR_DOT];
819            alpha = mAlphaSelector[index % 2][SELECTOR_DOT].getValue();
820            paint = mPaintSelector[index % 2][SELECTOR_DOT];
821            paint.setColor(color);
822            paint.setAlpha(getMultipliedAlpha(color, alpha));
823            canvas.drawCircle(pointX, pointY, (mSelectionRadius[index] * 2 / 7), paint);
824        } else {
825            // We're not drawing the dot, so shorten the line to only go as far as the edge of the
826            // selection circle
827            int lineLength = mLineLength[index] - mSelectionRadius[index];
828            pointX = mXCenter + (int) (lineLength * Math.sin(selectionRadians));
829            pointY = mYCenter - (int) (lineLength * Math.cos(selectionRadians));
830        }
831
832        // Draw the line
833        color = mColorSelector[index % 2][SELECTOR_LINE];
834        alpha = mAlphaSelector[index % 2][SELECTOR_LINE].getValue();
835        paint = mPaintSelector[index % 2][SELECTOR_LINE];
836        paint.setColor(color);
837        paint.setAlpha(getMultipliedAlpha(color, alpha));
838        canvas.drawLine(mXCenter, mYCenter, pointX, pointY, paint);
839    }
840
841    private void drawDebug(Canvas canvas) {
842        // Draw outer numbers circle
843        final float outerRadius = mCircleRadius[HOURS] * mNumbersRadiusMultiplier[HOURS];
844        canvas.drawCircle(mXCenter, mYCenter, outerRadius, mPaintDebug);
845
846        // Draw inner numbers circle
847        final float innerRadius = mCircleRadius[HOURS] * mNumbersRadiusMultiplier[HOURS_INNER];
848        canvas.drawCircle(mXCenter, mYCenter, innerRadius, mPaintDebug);
849
850        // Draw outer background circle
851        canvas.drawCircle(mXCenter, mYCenter, mCircleRadius[HOURS], mPaintDebug);
852
853        // Draw outer rectangle for circles
854        float left = mXCenter - outerRadius;
855        float top = mYCenter - outerRadius;
856        float right = mXCenter + outerRadius;
857        float bottom = mYCenter + outerRadius;
858        canvas.drawRect(left, top, right, bottom, mPaintDebug);
859
860        // Draw outer rectangle for background
861        left = mXCenter - mCircleRadius[HOURS];
862        top = mYCenter - mCircleRadius[HOURS];
863        right = mXCenter + mCircleRadius[HOURS];
864        bottom = mYCenter + mCircleRadius[HOURS];
865        canvas.drawRect(left, top, right, bottom, mPaintDebug);
866
867        // Draw outer view rectangle
868        canvas.drawRect(0, 0, getWidth(), getHeight(), mPaintDebug);
869
870        // Draw selected time
871        final String selected = String.format("%02d:%02d", getCurrentHour(), getCurrentMinute());
872
873        ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
874                ViewGroup.LayoutParams.WRAP_CONTENT);
875        TextView tv = new TextView(getContext());
876        tv.setLayoutParams(lp);
877        tv.setText(selected);
878        tv.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
879        Paint paint = tv.getPaint();
880        paint.setColor(DEBUG_TEXT_COLOR);
881
882        final int width = tv.getMeasuredWidth();
883
884        float height = paint.descent() - paint.ascent();
885        float x = mXCenter - width / 2;
886        float y = mYCenter + 1.5f * height;
887
888        canvas.drawText(selected, x, y, paint);
889    }
890
891    private void calculateGridSizesHours() {
892        // Calculate the text positions
893        float numbersRadius = mCircleRadius[HOURS]
894                * mNumbersRadiusMultiplier[HOURS] * mAnimationRadiusMultiplier[HOURS];
895
896        // Calculate the positions for the 12 numbers in the main circle.
897        calculateGridSizes(mPaint[HOURS], numbersRadius, mXCenter, mYCenter,
898                mTextSize[HOURS], mTextGridHeights[HOURS], mTextGridWidths[HOURS]);
899
900        // If we have an inner circle, calculate those positions too.
901        if (mIs24HourMode) {
902            float innerNumbersRadius = mCircleRadius[HOURS_INNER]
903                    * mNumbersRadiusMultiplier[HOURS_INNER]
904                    * mAnimationRadiusMultiplier[HOURS_INNER];
905
906            calculateGridSizes(mPaint[HOURS], innerNumbersRadius, mXCenter, mYCenter,
907                    mInnerTextSize, mInnerTextGridHeights, mInnerTextGridWidths);
908        }
909    }
910
911    private void calculateGridSizesMinutes() {
912        // Calculate the text positions
913        float numbersRadius = mCircleRadius[MINUTES]
914                * mNumbersRadiusMultiplier[MINUTES] * mAnimationRadiusMultiplier[MINUTES];
915
916        // Calculate the positions for the 12 numbers in the main circle.
917        calculateGridSizes(mPaint[MINUTES], numbersRadius, mXCenter, mYCenter,
918                mTextSize[MINUTES], mTextGridHeights[MINUTES], mTextGridWidths[MINUTES]);
919    }
920
921
922    /**
923     * Using the trigonometric Unit Circle, calculate the positions that the text will need to be
924     * drawn at based on the specified circle radius. Place the values in the textGridHeights and
925     * textGridWidths parameters.
926     */
927    private static void calculateGridSizes(Paint paint, float numbersRadius, float xCenter,
928            float yCenter, float textSize, float[] textGridHeights, float[] textGridWidths) {
929        /*
930         * The numbers need to be drawn in a 7x7 grid, representing the points on the Unit Circle.
931         */
932        final float offset1 = numbersRadius;
933        // cos(30) = a / r => r * cos(30)
934        final float offset2 = numbersRadius * COSINE_30_DEGREES;
935        // sin(30) = o / r => r * sin(30)
936        final float offset3 = numbersRadius * SINE_30_DEGREES;
937
938        paint.setTextSize(textSize);
939        // We'll need yTextBase to be slightly lower to account for the text's baseline.
940        yCenter -= (paint.descent() + paint.ascent()) / 2;
941
942        textGridHeights[0] = yCenter - offset1;
943        textGridWidths[0] = xCenter - offset1;
944        textGridHeights[1] = yCenter - offset2;
945        textGridWidths[1] = xCenter - offset2;
946        textGridHeights[2] = yCenter - offset3;
947        textGridWidths[2] = xCenter - offset3;
948        textGridHeights[3] = yCenter;
949        textGridWidths[3] = xCenter;
950        textGridHeights[4] = yCenter + offset3;
951        textGridWidths[4] = xCenter + offset3;
952        textGridHeights[5] = yCenter + offset2;
953        textGridWidths[5] = xCenter + offset2;
954        textGridHeights[6] = yCenter + offset1;
955        textGridWidths[6] = xCenter + offset1;
956    }
957
958    /**
959     * Draw the 12 text values at the positions specified by the textGrid parameters.
960     */
961    private void drawTextElements(Canvas canvas, float textSize, Typeface typeface, String[] texts,
962            float[] textGridWidths, float[] textGridHeights, Paint paint, int color, int alpha) {
963        paint.setTextSize(textSize);
964        paint.setTypeface(typeface);
965        paint.setColor(color);
966        paint.setAlpha(getMultipliedAlpha(color, alpha));
967        canvas.drawText(texts[0], textGridWidths[3], textGridHeights[0], paint);
968        canvas.drawText(texts[1], textGridWidths[4], textGridHeights[1], paint);
969        canvas.drawText(texts[2], textGridWidths[5], textGridHeights[2], paint);
970        canvas.drawText(texts[3], textGridWidths[6], textGridHeights[3], paint);
971        canvas.drawText(texts[4], textGridWidths[5], textGridHeights[4], paint);
972        canvas.drawText(texts[5], textGridWidths[4], textGridHeights[5], paint);
973        canvas.drawText(texts[6], textGridWidths[3], textGridHeights[6], paint);
974        canvas.drawText(texts[7], textGridWidths[2], textGridHeights[5], paint);
975        canvas.drawText(texts[8], textGridWidths[1], textGridHeights[4], paint);
976        canvas.drawText(texts[9], textGridWidths[0], textGridHeights[3], paint);
977        canvas.drawText(texts[10], textGridWidths[1], textGridHeights[2], paint);
978        canvas.drawText(texts[11], textGridWidths[2], textGridHeights[1], paint);
979    }
980
981    // Used for animating the hours by changing their radius
982    @SuppressWarnings("unused")
983    private void setAnimationRadiusMultiplierHours(float animationRadiusMultiplier) {
984        mAnimationRadiusMultiplier[HOURS] = animationRadiusMultiplier;
985        mAnimationRadiusMultiplier[HOURS_INNER] = animationRadiusMultiplier;
986    }
987
988    // Used for animating the minutes by changing their radius
989    @SuppressWarnings("unused")
990    private void setAnimationRadiusMultiplierMinutes(float animationRadiusMultiplier) {
991        mAnimationRadiusMultiplier[MINUTES] = animationRadiusMultiplier;
992    }
993
994    private static ObjectAnimator getRadiusDisappearAnimator(Object target,
995            String radiusPropertyName, InvalidateUpdateListener updateListener,
996            float midRadiusMultiplier, float endRadiusMultiplier) {
997        Keyframe kf0, kf1, kf2;
998        float midwayPoint = 0.2f;
999        int duration = 500;
1000
1001        kf0 = Keyframe.ofFloat(0f, 1);
1002        kf1 = Keyframe.ofFloat(midwayPoint, midRadiusMultiplier);
1003        kf2 = Keyframe.ofFloat(1f, endRadiusMultiplier);
1004        PropertyValuesHolder radiusDisappear = PropertyValuesHolder.ofKeyframe(
1005                radiusPropertyName, kf0, kf1, kf2);
1006
1007        ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(
1008                target, radiusDisappear).setDuration(duration);
1009        animator.addUpdateListener(updateListener);
1010        return animator;
1011    }
1012
1013    private static ObjectAnimator getRadiusReappearAnimator(Object target,
1014            String radiusPropertyName, InvalidateUpdateListener updateListener,
1015            float midRadiusMultiplier, float endRadiusMultiplier) {
1016        Keyframe kf0, kf1, kf2, kf3;
1017        float midwayPoint = 0.2f;
1018        int duration = 500;
1019
1020        // Set up animator for reappearing.
1021        float delayMultiplier = 0.25f;
1022        float transitionDurationMultiplier = 1f;
1023        float totalDurationMultiplier = transitionDurationMultiplier + delayMultiplier;
1024        int totalDuration = (int) (duration * totalDurationMultiplier);
1025        float delayPoint = (delayMultiplier * duration) / totalDuration;
1026        midwayPoint = 1 - (midwayPoint * (1 - delayPoint));
1027
1028        kf0 = Keyframe.ofFloat(0f, endRadiusMultiplier);
1029        kf1 = Keyframe.ofFloat(delayPoint, endRadiusMultiplier);
1030        kf2 = Keyframe.ofFloat(midwayPoint, midRadiusMultiplier);
1031        kf3 = Keyframe.ofFloat(1f, 1);
1032        PropertyValuesHolder radiusReappear = PropertyValuesHolder.ofKeyframe(
1033                radiusPropertyName, kf0, kf1, kf2, kf3);
1034
1035        ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(
1036                target, radiusReappear).setDuration(totalDuration);
1037        animator.addUpdateListener(updateListener);
1038        return animator;
1039    }
1040
1041    private static ObjectAnimator getFadeOutAnimator(IntHolder target, int startAlpha, int endAlpha,
1042                InvalidateUpdateListener updateListener) {
1043        int duration = 500;
1044        ObjectAnimator animator = ObjectAnimator.ofInt(target, "value", startAlpha, endAlpha);
1045        animator.setDuration(duration);
1046        animator.addUpdateListener(updateListener);
1047
1048        return animator;
1049    }
1050
1051    private static ObjectAnimator getFadeInAnimator(IntHolder target, int startAlpha, int endAlpha,
1052                InvalidateUpdateListener updateListener) {
1053        Keyframe kf0, kf1, kf2;
1054        int duration = 500;
1055
1056        // Set up animator for reappearing.
1057        float delayMultiplier = 0.25f;
1058        float transitionDurationMultiplier = 1f;
1059        float totalDurationMultiplier = transitionDurationMultiplier + delayMultiplier;
1060        int totalDuration = (int) (duration * totalDurationMultiplier);
1061        float delayPoint = (delayMultiplier * duration) / totalDuration;
1062
1063        kf0 = Keyframe.ofInt(0f, startAlpha);
1064        kf1 = Keyframe.ofInt(delayPoint, startAlpha);
1065        kf2 = Keyframe.ofInt(1f, endAlpha);
1066        PropertyValuesHolder fadeIn = PropertyValuesHolder.ofKeyframe("value", kf0, kf1, kf2);
1067
1068        ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(
1069                target, fadeIn).setDuration(totalDuration);
1070        animator.addUpdateListener(updateListener);
1071        return animator;
1072    }
1073
1074    private class InvalidateUpdateListener implements ValueAnimator.AnimatorUpdateListener {
1075        @Override
1076        public void onAnimationUpdate(ValueAnimator animation) {
1077            RadialTimePickerView.this.invalidate();
1078        }
1079    }
1080
1081    private void startHoursToMinutesAnimation() {
1082        if (mHoursToMinutesAnims.size() == 0) {
1083            mHoursToMinutesAnims.add(getRadiusDisappearAnimator(this,
1084                    "animationRadiusMultiplierHours", mInvalidateUpdateListener,
1085                    mTransitionMidRadiusMultiplier, mTransitionEndRadiusMultiplier));
1086            mHoursToMinutesAnims.add(getFadeOutAnimator(mAlpha[HOURS],
1087                    ALPHA_OPAQUE, ALPHA_TRANSPARENT, mInvalidateUpdateListener));
1088            mHoursToMinutesAnims.add(getFadeOutAnimator(mAlphaSelector[HOURS][SELECTOR_CIRCLE],
1089                    ALPHA_SELECTOR, ALPHA_TRANSPARENT, mInvalidateUpdateListener));
1090            mHoursToMinutesAnims.add(getFadeOutAnimator(mAlphaSelector[HOURS][SELECTOR_DOT],
1091                    ALPHA_OPAQUE, ALPHA_TRANSPARENT, mInvalidateUpdateListener));
1092            mHoursToMinutesAnims.add(getFadeOutAnimator(mAlphaSelector[HOURS][SELECTOR_LINE],
1093                    ALPHA_SELECTOR, ALPHA_TRANSPARENT, mInvalidateUpdateListener));
1094
1095            mHoursToMinutesAnims.add(getRadiusReappearAnimator(this,
1096                    "animationRadiusMultiplierMinutes", mInvalidateUpdateListener,
1097                    mTransitionMidRadiusMultiplier, mTransitionEndRadiusMultiplier));
1098            mHoursToMinutesAnims.add(getFadeInAnimator(mAlpha[MINUTES],
1099                    ALPHA_TRANSPARENT, ALPHA_OPAQUE, mInvalidateUpdateListener));
1100            mHoursToMinutesAnims.add(getFadeInAnimator(mAlphaSelector[MINUTES][SELECTOR_CIRCLE],
1101                    ALPHA_TRANSPARENT, ALPHA_SELECTOR, mInvalidateUpdateListener));
1102            mHoursToMinutesAnims.add(getFadeInAnimator(mAlphaSelector[MINUTES][SELECTOR_DOT],
1103                    ALPHA_TRANSPARENT, ALPHA_OPAQUE, mInvalidateUpdateListener));
1104            mHoursToMinutesAnims.add(getFadeInAnimator(mAlphaSelector[MINUTES][SELECTOR_LINE],
1105                    ALPHA_TRANSPARENT, ALPHA_SELECTOR, mInvalidateUpdateListener));
1106        }
1107
1108        if (mTransition != null && mTransition.isRunning()) {
1109            mTransition.end();
1110        }
1111        mTransition = new AnimatorSet();
1112        mTransition.playTogether(mHoursToMinutesAnims);
1113        mTransition.start();
1114    }
1115
1116    private void startMinutesToHoursAnimation() {
1117        if (mMinuteToHoursAnims.size() == 0) {
1118            mMinuteToHoursAnims.add(getRadiusDisappearAnimator(this,
1119                    "animationRadiusMultiplierMinutes", mInvalidateUpdateListener,
1120                    mTransitionMidRadiusMultiplier, mTransitionEndRadiusMultiplier));
1121            mMinuteToHoursAnims.add(getFadeOutAnimator(mAlpha[MINUTES],
1122                    ALPHA_OPAQUE, ALPHA_TRANSPARENT, mInvalidateUpdateListener));
1123            mMinuteToHoursAnims.add(getFadeOutAnimator(mAlphaSelector[MINUTES][SELECTOR_CIRCLE],
1124                    ALPHA_SELECTOR, ALPHA_TRANSPARENT, mInvalidateUpdateListener));
1125            mMinuteToHoursAnims.add(getFadeOutAnimator(mAlphaSelector[MINUTES][SELECTOR_DOT],
1126                    ALPHA_OPAQUE, ALPHA_TRANSPARENT, mInvalidateUpdateListener));
1127            mMinuteToHoursAnims.add(getFadeOutAnimator(mAlphaSelector[MINUTES][SELECTOR_LINE],
1128                    ALPHA_SELECTOR, ALPHA_TRANSPARENT, mInvalidateUpdateListener));
1129
1130            mMinuteToHoursAnims.add(getRadiusReappearAnimator(this,
1131                    "animationRadiusMultiplierHours", mInvalidateUpdateListener,
1132                    mTransitionMidRadiusMultiplier, mTransitionEndRadiusMultiplier));
1133            mMinuteToHoursAnims.add(getFadeInAnimator(mAlpha[HOURS],
1134                    ALPHA_TRANSPARENT, ALPHA_OPAQUE, mInvalidateUpdateListener));
1135            mMinuteToHoursAnims.add(getFadeInAnimator(mAlphaSelector[HOURS][SELECTOR_CIRCLE],
1136                    ALPHA_TRANSPARENT, ALPHA_SELECTOR, mInvalidateUpdateListener));
1137            mMinuteToHoursAnims.add(getFadeInAnimator(mAlphaSelector[HOURS][SELECTOR_DOT],
1138                    ALPHA_TRANSPARENT, ALPHA_OPAQUE, mInvalidateUpdateListener));
1139            mMinuteToHoursAnims.add(getFadeInAnimator(mAlphaSelector[HOURS][SELECTOR_LINE],
1140                    ALPHA_TRANSPARENT, ALPHA_SELECTOR, mInvalidateUpdateListener));
1141        }
1142
1143        if (mTransition != null && mTransition.isRunning()) {
1144            mTransition.end();
1145        }
1146        mTransition = new AnimatorSet();
1147        mTransition.playTogether(mMinuteToHoursAnims);
1148        mTransition.start();
1149    }
1150
1151    private int getDegreesFromXY(float x, float y) {
1152        final double hypotenuse = Math.sqrt(
1153                (y - mYCenter) * (y - mYCenter) + (x - mXCenter) * (x - mXCenter));
1154
1155        // Basic check if we're outside the range of the disk
1156        if (hypotenuse > mCircleRadius[HOURS]) {
1157            return -1;
1158        }
1159        // Check
1160        if (mIs24HourMode && mShowHours) {
1161            if (hypotenuse >= mMinHypotenuseForInnerNumber
1162                    && hypotenuse <= mHalfwayHypotenusePoint) {
1163                mIsOnInnerCircle = true;
1164            } else if (hypotenuse <= mMaxHypotenuseForOuterNumber
1165                    && hypotenuse >= mHalfwayHypotenusePoint) {
1166                mIsOnInnerCircle = false;
1167            } else {
1168                return -1;
1169            }
1170        } else {
1171            final int index =  (mShowHours) ? HOURS : MINUTES;
1172            final float length = (mCircleRadius[index] * mNumbersRadiusMultiplier[index]);
1173            final int distanceToNumber = (int) Math.abs(hypotenuse - length);
1174            final int maxAllowedDistance =
1175                    (int) (mCircleRadius[index] * (1 - mNumbersRadiusMultiplier[index]));
1176            if (distanceToNumber > maxAllowedDistance) {
1177                return -1;
1178            }
1179        }
1180
1181        final float opposite = Math.abs(y - mYCenter);
1182        int degrees = (int) (Math.toDegrees(Math.asin(opposite / hypotenuse)) + 0.5);
1183
1184        // Now we have to translate to the correct quadrant.
1185        final boolean rightSide = (x > mXCenter);
1186        final boolean topSide = (y < mYCenter);
1187        if (rightSide) {
1188            if (topSide) {
1189                degrees = 90 - degrees;
1190            } else {
1191                degrees = 90 + degrees;
1192            }
1193        } else {
1194            if (topSide) {
1195                degrees = 270 + degrees;
1196            } else {
1197                degrees = 270 - degrees;
1198            }
1199        }
1200        return degrees;
1201    }
1202
1203    boolean mChangedDuringTouch = false;
1204
1205    @Override
1206    public boolean onTouch(View v, MotionEvent event) {
1207        if (!mInputEnabled) {
1208            return true;
1209        }
1210
1211        final int action = event.getActionMasked();
1212        if (action == MotionEvent.ACTION_MOVE
1213                || action == MotionEvent.ACTION_UP
1214                || action == MotionEvent.ACTION_DOWN) {
1215            boolean forceSelection = false;
1216            boolean autoAdvance = false;
1217
1218            if (action == MotionEvent.ACTION_DOWN) {
1219                // This is a new event stream, reset whether the value changed.
1220                mChangedDuringTouch = false;
1221            } else if (action == MotionEvent.ACTION_UP) {
1222                autoAdvance = true;
1223
1224                // If we saw a down/up pair without the value changing, assume
1225                // this is a single-tap selection and force a change.
1226                if (!mChangedDuringTouch) {
1227                    forceSelection = true;
1228                }
1229            }
1230
1231            mChangedDuringTouch |= handleTouchInput(
1232                    event.getX(), event.getY(), forceSelection, autoAdvance);
1233        }
1234
1235        return true;
1236    }
1237
1238    private boolean handleTouchInput(
1239            float x, float y, boolean forceSelection, boolean autoAdvance) {
1240        // Calling getDegreesFromXY has side effects, so cache
1241        // whether we used to be on the inner circle.
1242        final boolean wasOnInnerCircle = mIsOnInnerCircle;
1243        final int degrees = getDegreesFromXY(x, y);
1244        if (degrees == -1) {
1245            return false;
1246        }
1247
1248        final int[] selectionDegrees = mSelectionDegrees;
1249        final int type;
1250        final int newValue;
1251        final boolean valueChanged;
1252
1253        if (mShowHours) {
1254            final int snapDegrees = snapOnly30s(degrees, 0) % 360;
1255            valueChanged = selectionDegrees[HOURS] != snapDegrees
1256                    || selectionDegrees[HOURS_INNER] != snapDegrees
1257                    || wasOnInnerCircle != mIsOnInnerCircle;
1258
1259            selectionDegrees[HOURS] = snapDegrees;
1260            selectionDegrees[HOURS_INNER] = snapDegrees;
1261            type = HOURS;
1262            newValue = getCurrentHour();
1263        } else {
1264            final int snapDegrees = snapPrefer30s(degrees) % 360;
1265            valueChanged = selectionDegrees[MINUTES] != snapDegrees;
1266
1267            selectionDegrees[MINUTES] = snapDegrees;
1268            type = MINUTES;
1269            newValue = getCurrentMinute();
1270        }
1271
1272        if (valueChanged || forceSelection || autoAdvance) {
1273            // Fire the listener even if we just need to auto-advance.
1274            if (mListener != null) {
1275                mListener.onValueSelected(type, newValue, autoAdvance);
1276            }
1277
1278            // Only provide feedback if the value actually changed.
1279            if (valueChanged || forceSelection) {
1280                performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
1281                invalidate();
1282            }
1283            return true;
1284        }
1285
1286        return false;
1287    }
1288
1289    @Override
1290    public boolean dispatchHoverEvent(MotionEvent event) {
1291        // First right-of-refusal goes the touch exploration helper.
1292        if (mTouchHelper.dispatchHoverEvent(event)) {
1293            return true;
1294        }
1295        return super.dispatchHoverEvent(event);
1296    }
1297
1298    public void setInputEnabled(boolean inputEnabled) {
1299        mInputEnabled = inputEnabled;
1300        invalidate();
1301    }
1302
1303    private class RadialPickerTouchHelper extends ExploreByTouchHelper {
1304        private final Rect mTempRect = new Rect();
1305
1306        private final int TYPE_HOUR = 1;
1307        private final int TYPE_MINUTE = 2;
1308
1309        private final int SHIFT_TYPE = 0;
1310        private final int MASK_TYPE = 0xF;
1311
1312        private final int SHIFT_VALUE = 8;
1313        private final int MASK_VALUE = 0xFF;
1314
1315        /** Increment in which virtual views are exposed for minutes. */
1316        private final int MINUTE_INCREMENT = 5;
1317
1318        public RadialPickerTouchHelper() {
1319            super(RadialTimePickerView.this);
1320        }
1321
1322        @Override
1323        public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
1324            super.onInitializeAccessibilityNodeInfo(host, info);
1325
1326            info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD);
1327            info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD);
1328        }
1329
1330        @Override
1331        public boolean performAccessibilityAction(View host, int action, Bundle arguments) {
1332            if (super.performAccessibilityAction(host, action, arguments)) {
1333                return true;
1334            }
1335
1336            switch (action) {
1337                case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
1338                    adjustPicker(1);
1339                    return true;
1340                case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
1341                    adjustPicker(-1);
1342                    return true;
1343            }
1344
1345            return false;
1346        }
1347
1348        private void adjustPicker(int step) {
1349            final int stepSize;
1350            final int initialValue;
1351            final int maxValue;
1352            final int minValue;
1353            if (mShowHours) {
1354                stepSize = DEGREES_FOR_ONE_HOUR;
1355                initialValue = getCurrentHour() % 12;
1356
1357                if (mIs24HourMode) {
1358                    maxValue = 23;
1359                    minValue = 0;
1360                } else {
1361                    maxValue = 12;
1362                    minValue = 1;
1363                }
1364            } else {
1365                stepSize = DEGREES_FOR_ONE_MINUTE;
1366                initialValue = getCurrentMinute();
1367
1368                maxValue = 55;
1369                minValue = 0;
1370            }
1371
1372            final int steppedValue = snapOnly30s(initialValue * stepSize, step) / stepSize;
1373            final int clampedValue = MathUtils.constrain(steppedValue, minValue, maxValue);
1374            if (mShowHours) {
1375                setCurrentHour(clampedValue);
1376            } else {
1377                setCurrentMinute(clampedValue);
1378            }
1379        }
1380
1381        @Override
1382        protected int getVirtualViewAt(float x, float y) {
1383            final int id;
1384            final int degrees = getDegreesFromXY(x, y);
1385            if (degrees != -1) {
1386                final int snapDegrees = snapOnly30s(degrees, 0) % 360;
1387                if (mShowHours) {
1388                    final int hour = getHourForDegrees(snapDegrees, mIsOnInnerCircle);
1389                    id = makeId(TYPE_HOUR, hour);
1390                } else {
1391                    final int current = getCurrentMinute();
1392                    final int touched = getMinuteForDegrees(degrees);
1393                    final int snapped = getMinuteForDegrees(snapDegrees);
1394
1395                    // If the touched minute is closer to the current minute
1396                    // than it is to the snapped minute, return current.
1397                    final int minute;
1398                    if (Math.abs(current - touched) < Math.abs(snapped - touched)) {
1399                        minute = current;
1400                    } else {
1401                        minute = snapped;
1402                    }
1403                    id = makeId(TYPE_MINUTE, minute);
1404                }
1405            } else {
1406                id = INVALID_ID;
1407            }
1408
1409            return id;
1410        }
1411
1412        @Override
1413        protected void getVisibleVirtualViews(IntArray virtualViewIds) {
1414            if (mShowHours) {
1415                final int min = mIs24HourMode ? 0 : 1;
1416                final int max = mIs24HourMode ? 23 : 12;
1417                for (int i = min; i <= max ; i++) {
1418                    virtualViewIds.add(makeId(TYPE_HOUR, i));
1419                }
1420            } else {
1421                final int current = getCurrentMinute();
1422                for (int i = 0; i < 60; i += MINUTE_INCREMENT) {
1423                    virtualViewIds.add(makeId(TYPE_MINUTE, i));
1424
1425                    // If the current minute falls between two increments,
1426                    // insert an extra node for it.
1427                    if (current > i && current < i + MINUTE_INCREMENT) {
1428                        virtualViewIds.add(makeId(TYPE_MINUTE, current));
1429                    }
1430                }
1431            }
1432        }
1433
1434        @Override
1435        protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
1436            event.setClassName(getClass().getName());
1437
1438            final int type = getTypeFromId(virtualViewId);
1439            final int value = getValueFromId(virtualViewId);
1440            final CharSequence description = getVirtualViewDescription(type, value);
1441            event.setContentDescription(description);
1442        }
1443
1444        @Override
1445        protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node) {
1446            node.setClassName(getClass().getName());
1447            node.addAction(AccessibilityAction.ACTION_CLICK);
1448
1449            final int type = getTypeFromId(virtualViewId);
1450            final int value = getValueFromId(virtualViewId);
1451            final CharSequence description = getVirtualViewDescription(type, value);
1452            node.setContentDescription(description);
1453
1454            getBoundsForVirtualView(virtualViewId, mTempRect);
1455            node.setBoundsInParent(mTempRect);
1456
1457            final boolean selected = isVirtualViewSelected(type, value);
1458            node.setSelected(selected);
1459
1460            final int nextId = getVirtualViewIdAfter(type, value);
1461            if (nextId != INVALID_ID) {
1462                node.setTraversalBefore(RadialTimePickerView.this, nextId);
1463            }
1464        }
1465
1466        private int getVirtualViewIdAfter(int type, int value) {
1467            if (type == TYPE_HOUR) {
1468                final int nextValue = value + 1;
1469                final int max = mIs24HourMode ? 23 : 12;
1470                if (nextValue <= max) {
1471                    return makeId(type, nextValue);
1472                }
1473            } else if (type == TYPE_MINUTE) {
1474                final int current = getCurrentMinute();
1475                final int snapValue = value - (value % MINUTE_INCREMENT);
1476                final int nextValue = snapValue + MINUTE_INCREMENT;
1477                if (value < current && nextValue > current) {
1478                    // The current value is between two snap values.
1479                    return makeId(type, current);
1480                } else if (nextValue < 60) {
1481                    return makeId(type, nextValue);
1482                }
1483            }
1484            return INVALID_ID;
1485        }
1486
1487        @Override
1488        protected boolean onPerformActionForVirtualView(int virtualViewId, int action,
1489                Bundle arguments) {
1490            if (action == AccessibilityNodeInfo.ACTION_CLICK) {
1491                final int type = getTypeFromId(virtualViewId);
1492                final int value = getValueFromId(virtualViewId);
1493                if (type == TYPE_HOUR) {
1494                    final int hour = mIs24HourMode ? value : hour12To24(value, mAmOrPm);
1495                    setCurrentHour(hour);
1496                    return true;
1497                } else if (type == TYPE_MINUTE) {
1498                    setCurrentMinute(value);
1499                    return true;
1500                }
1501            }
1502            return false;
1503        }
1504
1505        private int hour12To24(int hour12, int amOrPm) {
1506            int hour24 = hour12;
1507            if (hour12 == 12) {
1508                if (amOrPm == AM) {
1509                    hour24 = 0;
1510                }
1511            } else if (amOrPm == PM) {
1512                hour24 += 12;
1513            }
1514            return hour24;
1515        }
1516
1517        private void getBoundsForVirtualView(int virtualViewId, Rect bounds) {
1518            final float radius;
1519            final int type = getTypeFromId(virtualViewId);
1520            final int value = getValueFromId(virtualViewId);
1521            final float centerRadius;
1522            final float degrees;
1523            if (type == TYPE_HOUR) {
1524                final boolean innerCircle = mIs24HourMode && value > 0 && value <= 12;
1525                if (innerCircle) {
1526                    centerRadius = mCircleRadius[HOURS_INNER] * mNumbersRadiusMultiplier[HOURS_INNER];
1527                    radius = mSelectionRadius[HOURS_INNER];
1528                } else {
1529                    centerRadius = mCircleRadius[HOURS] * mNumbersRadiusMultiplier[HOURS];
1530                    radius = mSelectionRadius[HOURS];
1531                }
1532
1533                degrees = getDegreesForHour(value);
1534            } else if (type == TYPE_MINUTE) {
1535                centerRadius = mCircleRadius[MINUTES] * mNumbersRadiusMultiplier[MINUTES];
1536                degrees = getDegreesForMinute(value);
1537                radius = mSelectionRadius[MINUTES];
1538            } else {
1539                // This should never happen.
1540                centerRadius = 0;
1541                degrees = 0;
1542                radius = 0;
1543            }
1544
1545            final double radians = Math.toRadians(degrees);
1546            final float xCenter = mXCenter + centerRadius * (float) Math.sin(radians);
1547            final float yCenter = mYCenter - centerRadius * (float) Math.cos(radians);
1548
1549            bounds.set((int) (xCenter - radius), (int) (yCenter - radius),
1550                    (int) (xCenter + radius), (int) (yCenter + radius));
1551        }
1552
1553        private CharSequence getVirtualViewDescription(int type, int value) {
1554            final CharSequence description;
1555            if (type == TYPE_HOUR || type == TYPE_MINUTE) {
1556                description = Integer.toString(value);
1557            } else {
1558                description = null;
1559            }
1560            return description;
1561        }
1562
1563        private boolean isVirtualViewSelected(int type, int value) {
1564            final boolean selected;
1565            if (type == TYPE_HOUR) {
1566                selected = getCurrentHour() == value;
1567            } else if (type == TYPE_MINUTE) {
1568                selected = getCurrentMinute() == value;
1569            } else {
1570                selected = false;
1571            }
1572            return selected;
1573        }
1574
1575        private int makeId(int type, int value) {
1576            return type << SHIFT_TYPE | value << SHIFT_VALUE;
1577        }
1578
1579        private int getTypeFromId(int id) {
1580            return id >>> SHIFT_TYPE & MASK_TYPE;
1581        }
1582
1583        private int getValueFromId(int id) {
1584            return id >>> SHIFT_VALUE & MASK_VALUE;
1585        }
1586    }
1587
1588    private static class IntHolder {
1589        private int mValue;
1590
1591        public IntHolder(int value) {
1592            mValue = value;
1593        }
1594
1595        public void setValue(int value) {
1596            mValue = value;
1597        }
1598
1599        public int getValue() {
1600            return mValue;
1601        }
1602    }
1603}
1604