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