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