1/*
2 * Copyright (C) 2013 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.datetimepicker.time;
18
19import android.animation.Keyframe;
20import android.animation.ObjectAnimator;
21import android.animation.PropertyValuesHolder;
22import android.animation.ValueAnimator;
23import android.animation.ValueAnimator.AnimatorUpdateListener;
24import android.content.Context;
25import android.content.res.Resources;
26import android.graphics.Canvas;
27import android.graphics.Paint;
28import android.util.Log;
29import android.view.View;
30
31import com.android.datetimepicker.R;
32import com.android.datetimepicker.Utils;
33
34/**
35 * View to show what number is selected. This will draw a blue circle over the number, with a blue
36 * line coming from the center of the main circle to the edge of the blue selection.
37 */
38public class RadialSelectorView extends View {
39    private static final String TAG = "RadialSelectorView";
40
41    // Alpha level for selected circle.
42    private static final int SELECTED_ALPHA = Utils.SELECTED_ALPHA;
43    private static final int SELECTED_ALPHA_THEME_DARK = Utils.SELECTED_ALPHA_THEME_DARK;
44    // Alpha level for the line.
45    private static final int FULL_ALPHA = Utils.FULL_ALPHA;
46
47    private final Paint mPaint = new Paint();
48
49    private boolean mIsInitialized;
50    private boolean mDrawValuesReady;
51
52    private float mCircleRadiusMultiplier;
53    private float mAmPmCircleRadiusMultiplier;
54    private float mInnerNumbersRadiusMultiplier;
55    private float mOuterNumbersRadiusMultiplier;
56    private float mNumbersRadiusMultiplier;
57    private float mSelectionRadiusMultiplier;
58    private float mAnimationRadiusMultiplier;
59    private boolean mIs24HourMode;
60    private boolean mHasInnerCircle;
61    private int mSelectionAlpha;
62
63    private int mXCenter;
64    private int mYCenter;
65    private int mCircleRadius;
66    private float mTransitionMidRadiusMultiplier;
67    private float mTransitionEndRadiusMultiplier;
68    private int mLineLength;
69    private int mSelectionRadius;
70    private InvalidateUpdateListener mInvalidateUpdateListener;
71
72    private int mSelectionDegrees;
73    private double mSelectionRadians;
74    private boolean mForceDrawDot;
75
76    public RadialSelectorView(Context context) {
77        super(context);
78        mIsInitialized = false;
79    }
80
81    /**
82     * Initialize this selector with the state of the picker.
83     * @param context Current context.
84     * @param is24HourMode Whether the selector is in 24-hour mode, which will tell us
85     * whether the circle's center is moved up slightly to make room for the AM/PM circles.
86     * @param hasInnerCircle Whether we have both an inner and an outer circle of numbers
87     * that may be selected. Should be true for 24-hour mode in the hours circle.
88     * @param disappearsOut Whether the numbers' animation will have them disappearing out
89     * or disappearing in.
90     * @param selectionDegrees The initial degrees to be selected.
91     * @param isInnerCircle Whether the initial selection is in the inner or outer circle.
92     * Will be ignored when hasInnerCircle is false.
93     */
94    public void initialize(Context context, boolean is24HourMode, boolean hasInnerCircle,
95            boolean disappearsOut, int selectionDegrees, boolean isInnerCircle) {
96        if (mIsInitialized) {
97            Log.e(TAG, "This RadialSelectorView may only be initialized once.");
98            return;
99        }
100
101        Resources res = context.getResources();
102
103        int blue = res.getColor(R.color.blue);
104        mPaint.setColor(blue);
105        mPaint.setAntiAlias(true);
106        mSelectionAlpha = SELECTED_ALPHA;
107
108        // Calculate values for the circle radius size.
109        mIs24HourMode = is24HourMode;
110        if (is24HourMode) {
111            mCircleRadiusMultiplier = Float.parseFloat(
112                    res.getString(R.string.circle_radius_multiplier_24HourMode));
113        } else {
114            mCircleRadiusMultiplier = Float.parseFloat(
115                    res.getString(R.string.circle_radius_multiplier));
116            mAmPmCircleRadiusMultiplier =
117                    Float.parseFloat(res.getString(R.string.ampm_circle_radius_multiplier));
118        }
119
120        // Calculate values for the radius size(s) of the numbers circle(s).
121        mHasInnerCircle = hasInnerCircle;
122        if (hasInnerCircle) {
123            mInnerNumbersRadiusMultiplier =
124                    Float.parseFloat(res.getString(R.string.numbers_radius_multiplier_inner));
125            mOuterNumbersRadiusMultiplier =
126                    Float.parseFloat(res.getString(R.string.numbers_radius_multiplier_outer));
127        } else {
128            mNumbersRadiusMultiplier =
129                    Float.parseFloat(res.getString(R.string.numbers_radius_multiplier_normal));
130        }
131        mSelectionRadiusMultiplier =
132                Float.parseFloat(res.getString(R.string.selection_radius_multiplier));
133
134        // Calculate values for the transition mid-way states.
135        mAnimationRadiusMultiplier = 1;
136        mTransitionMidRadiusMultiplier = 1f + (0.05f * (disappearsOut? -1 : 1));
137        mTransitionEndRadiusMultiplier = 1f + (0.3f * (disappearsOut? 1 : -1));
138        mInvalidateUpdateListener = new InvalidateUpdateListener();
139
140        setSelection(selectionDegrees, isInnerCircle, false);
141        mIsInitialized = true;
142    }
143
144    /* package */ void setTheme(Context context, boolean themeDark) {
145        Resources res = context.getResources();
146        int color;
147        if (themeDark) {
148            color = res.getColor(R.color.red);
149            mSelectionAlpha = SELECTED_ALPHA_THEME_DARK;
150        } else {
151            color = res.getColor(R.color.blue);
152            mSelectionAlpha = SELECTED_ALPHA;
153        }
154        mPaint.setColor(color);
155    }
156
157    /**
158     * Set the selection.
159     * @param selectionDegrees The degrees to be selected.
160     * @param isInnerCircle Whether the selection should be in the inner circle or outer. Will be
161     * ignored if hasInnerCircle was initialized to false.
162     * @param forceDrawDot Whether to force the dot in the center of the selection circle to be
163     * drawn. If false, the dot will be drawn only when the degrees is not a multiple of 30, i.e.
164     * the selection is not on a visible number.
165     */
166    public void setSelection(int selectionDegrees, boolean isInnerCircle, boolean forceDrawDot) {
167        mSelectionDegrees = selectionDegrees;
168        mSelectionRadians = selectionDegrees * Math.PI / 180;
169        mForceDrawDot = forceDrawDot;
170
171        if (mHasInnerCircle) {
172            if (isInnerCircle) {
173                mNumbersRadiusMultiplier = mInnerNumbersRadiusMultiplier;
174            } else {
175                mNumbersRadiusMultiplier = mOuterNumbersRadiusMultiplier;
176            }
177        }
178    }
179
180    /**
181     * Allows for smoother animations.
182     */
183    @Override
184    public boolean hasOverlappingRendering() {
185        return false;
186    }
187
188    /**
189     * Set the multiplier for the radius. Will be used during animations to move in/out.
190     */
191    public void setAnimationRadiusMultiplier(float animationRadiusMultiplier) {
192        mAnimationRadiusMultiplier = animationRadiusMultiplier;
193    }
194
195    public int getDegreesFromCoords(float pointX, float pointY, boolean forceLegal,
196            final Boolean[] isInnerCircle) {
197        if (!mDrawValuesReady) {
198            return -1;
199        }
200
201        double hypotenuse = Math.sqrt(
202                (pointY - mYCenter)*(pointY - mYCenter) +
203                (pointX - mXCenter)*(pointX - mXCenter));
204        // Check if we're outside the range
205        if (mHasInnerCircle) {
206            if (forceLegal) {
207                // If we're told to force the coordinates to be legal, we'll set the isInnerCircle
208                // boolean based based off whichever number the coordinates are closer to.
209                int innerNumberRadius = (int) (mCircleRadius * mInnerNumbersRadiusMultiplier);
210                int distanceToInnerNumber = (int) Math.abs(hypotenuse - innerNumberRadius);
211                int outerNumberRadius = (int) (mCircleRadius * mOuterNumbersRadiusMultiplier);
212                int distanceToOuterNumber = (int) Math.abs(hypotenuse - outerNumberRadius);
213
214                isInnerCircle[0] = (distanceToInnerNumber <= distanceToOuterNumber);
215            } else {
216                // Otherwise, if we're close enough to either number (with the space between the
217                // two allotted equally), set the isInnerCircle boolean as the closer one.
218                // appropriately, but otherwise return -1.
219                int minAllowedHypotenuseForInnerNumber =
220                        (int) (mCircleRadius * mInnerNumbersRadiusMultiplier) - mSelectionRadius;
221                int maxAllowedHypotenuseForOuterNumber =
222                        (int) (mCircleRadius * mOuterNumbersRadiusMultiplier) + mSelectionRadius;
223                int halfwayHypotenusePoint = (int) (mCircleRadius *
224                        ((mOuterNumbersRadiusMultiplier + mInnerNumbersRadiusMultiplier) / 2));
225
226                if (hypotenuse >= minAllowedHypotenuseForInnerNumber &&
227                        hypotenuse <= halfwayHypotenusePoint) {
228                    isInnerCircle[0] = true;
229                } else if (hypotenuse <= maxAllowedHypotenuseForOuterNumber &&
230                        hypotenuse >= halfwayHypotenusePoint) {
231                    isInnerCircle[0] = false;
232                } else {
233                    return -1;
234                }
235            }
236        } else {
237            // If there's just one circle, we'll need to return -1 if:
238            // we're not told to force the coordinates to be legal, and
239            // the coordinates' distance to the number is within the allowed distance.
240            if (!forceLegal) {
241                int distanceToNumber = (int) Math.abs(hypotenuse - mLineLength);
242                // The max allowed distance will be defined as the distance from the center of the
243                // number to the edge of the circle.
244                int maxAllowedDistance = (int) (mCircleRadius * (1 - mNumbersRadiusMultiplier));
245                if (distanceToNumber > maxAllowedDistance) {
246                    return -1;
247                }
248            }
249        }
250
251
252        float opposite = Math.abs(pointY - mYCenter);
253        double radians = Math.asin(opposite / hypotenuse);
254        int degrees = (int) (radians * 180 / Math.PI);
255
256        // Now we have to translate to the correct quadrant.
257        boolean rightSide = (pointX > mXCenter);
258        boolean topSide = (pointY < mYCenter);
259        if (rightSide && topSide) {
260            degrees = 90 - degrees;
261        } else if (rightSide && !topSide) {
262            degrees = 90 + degrees;
263        } else if (!rightSide && !topSide) {
264            degrees = 270 - degrees;
265        } else if (!rightSide && topSide) {
266            degrees = 270 + degrees;
267        }
268        return degrees;
269    }
270
271    @Override
272    public void onDraw(Canvas canvas) {
273        int viewWidth = getWidth();
274        if (viewWidth == 0 || !mIsInitialized) {
275            return;
276        }
277
278        if (!mDrawValuesReady) {
279            mXCenter = getWidth() / 2;
280            mYCenter = getHeight() / 2;
281            mCircleRadius = (int) (Math.min(mXCenter, mYCenter) * mCircleRadiusMultiplier);
282
283            if (!mIs24HourMode) {
284                // We'll need to draw the AM/PM circles, so the main circle will need to have
285                // a slightly higher center. To keep the entire view centered vertically, we'll
286                // have to push it up by half the radius of the AM/PM circles.
287                int amPmCircleRadius = (int) (mCircleRadius * mAmPmCircleRadiusMultiplier);
288                mYCenter -= amPmCircleRadius / 2;
289            }
290
291            mSelectionRadius = (int) (mCircleRadius * mSelectionRadiusMultiplier);
292
293            mDrawValuesReady = true;
294        }
295
296        // Calculate the current radius at which to place the selection circle.
297        mLineLength = (int) (mCircleRadius * mNumbersRadiusMultiplier * mAnimationRadiusMultiplier);
298        int pointX = mXCenter + (int) (mLineLength * Math.sin(mSelectionRadians));
299        int pointY = mYCenter - (int) (mLineLength * Math.cos(mSelectionRadians));
300
301        // Draw the selection circle.
302        mPaint.setAlpha(mSelectionAlpha);
303        canvas.drawCircle(pointX, pointY, mSelectionRadius, mPaint);
304
305        if (mForceDrawDot | mSelectionDegrees % 30 != 0) {
306            // We're not on a direct tick (or we've been told to draw the dot anyway).
307            mPaint.setAlpha(FULL_ALPHA);
308            canvas.drawCircle(pointX, pointY, (mSelectionRadius * 2 / 7), mPaint);
309        } else {
310            // We're not drawing the dot, so shorten the line to only go as far as the edge of the
311            // selection circle.
312            int lineLength = mLineLength;
313            lineLength -= mSelectionRadius;
314            pointX = mXCenter + (int) (lineLength * Math.sin(mSelectionRadians));
315            pointY = mYCenter - (int) (lineLength * Math.cos(mSelectionRadians));
316        }
317
318        // Draw the line from the center of the circle.
319        mPaint.setAlpha(255);
320        mPaint.setStrokeWidth(1);
321        canvas.drawLine(mXCenter, mYCenter, pointX, pointY, mPaint);
322    }
323
324    public ObjectAnimator getDisappearAnimator() {
325        if (!mIsInitialized || !mDrawValuesReady) {
326            Log.e(TAG, "RadialSelectorView was not ready for animation.");
327            return null;
328        }
329
330        Keyframe kf0, kf1, kf2;
331        float midwayPoint = 0.2f;
332        int duration = 500;
333
334        kf0 = Keyframe.ofFloat(0f, 1);
335        kf1 = Keyframe.ofFloat(midwayPoint, mTransitionMidRadiusMultiplier);
336        kf2 = Keyframe.ofFloat(1f, mTransitionEndRadiusMultiplier);
337        PropertyValuesHolder radiusDisappear = PropertyValuesHolder.ofKeyframe(
338                "animationRadiusMultiplier", kf0, kf1, kf2);
339
340        kf0 = Keyframe.ofFloat(0f, 1f);
341        kf1 = Keyframe.ofFloat(1f, 0f);
342        PropertyValuesHolder fadeOut = PropertyValuesHolder.ofKeyframe("alpha", kf0, kf1);
343
344        ObjectAnimator disappearAnimator = ObjectAnimator.ofPropertyValuesHolder(
345                this, radiusDisappear, fadeOut).setDuration(duration);
346        disappearAnimator.addUpdateListener(mInvalidateUpdateListener);
347
348        return disappearAnimator;
349    }
350
351    public ObjectAnimator getReappearAnimator() {
352        if (!mIsInitialized || !mDrawValuesReady) {
353            Log.e(TAG, "RadialSelectorView was not ready for animation.");
354            return null;
355        }
356
357        Keyframe kf0, kf1, kf2, kf3;
358        float midwayPoint = 0.2f;
359        int duration = 500;
360
361        // The time points are half of what they would normally be, because this animation is
362        // staggered against the disappear so they happen seamlessly. The reappear starts
363        // halfway into the disappear.
364        float delayMultiplier = 0.25f;
365        float transitionDurationMultiplier = 1f;
366        float totalDurationMultiplier = transitionDurationMultiplier + delayMultiplier;
367        int totalDuration = (int) (duration * totalDurationMultiplier);
368        float delayPoint = (delayMultiplier * duration) / totalDuration;
369        midwayPoint = 1 - (midwayPoint * (1 - delayPoint));
370
371        kf0 = Keyframe.ofFloat(0f, mTransitionEndRadiusMultiplier);
372        kf1 = Keyframe.ofFloat(delayPoint, mTransitionEndRadiusMultiplier);
373        kf2 = Keyframe.ofFloat(midwayPoint, mTransitionMidRadiusMultiplier);
374        kf3 = Keyframe.ofFloat(1f, 1);
375        PropertyValuesHolder radiusReappear = PropertyValuesHolder.ofKeyframe(
376                "animationRadiusMultiplier", kf0, kf1, kf2, kf3);
377
378        kf0 = Keyframe.ofFloat(0f, 0f);
379        kf1 = Keyframe.ofFloat(delayPoint, 0f);
380        kf2 = Keyframe.ofFloat(1f, 1f);
381        PropertyValuesHolder fadeIn = PropertyValuesHolder.ofKeyframe("alpha", kf0, kf1, kf2);
382
383        ObjectAnimator reappearAnimator = ObjectAnimator.ofPropertyValuesHolder(
384                this, radiusReappear, fadeIn).setDuration(totalDuration);
385        reappearAnimator.addUpdateListener(mInvalidateUpdateListener);
386        return reappearAnimator;
387    }
388
389    /**
390     * We'll need to invalidate during the animation.
391     */
392    private class InvalidateUpdateListener implements AnimatorUpdateListener {
393        @Override
394        public void onAnimationUpdate(ValueAnimator animation) {
395            RadialSelectorView.this.invalidate();
396        }
397    }
398}
399