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 
17 package com.android.datetimepicker.time;
18 
19 import android.animation.Keyframe;
20 import android.animation.ObjectAnimator;
21 import android.animation.PropertyValuesHolder;
22 import android.animation.ValueAnimator;
23 import android.animation.ValueAnimator.AnimatorUpdateListener;
24 import android.content.Context;
25 import android.content.res.Resources;
26 import android.graphics.Canvas;
27 import android.graphics.Paint;
28 import android.util.Log;
29 import android.view.View;
30 
31 import com.android.datetimepicker.R;
32 import 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  */
38 public 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 
RadialSelectorView(Context context)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      */
initialize(Context context, boolean is24HourMode, boolean hasInnerCircle, boolean disappearsOut, int selectionDegrees, boolean isInnerCircle)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 
setTheme(Context context, boolean themeDark)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      */
setSelection(int selectionDegrees, boolean isInnerCircle, boolean forceDrawDot)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
hasOverlappingRendering()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      */
setAnimationRadiusMultiplier(float animationRadiusMultiplier)191     public void setAnimationRadiusMultiplier(float animationRadiusMultiplier) {
192         mAnimationRadiusMultiplier = animationRadiusMultiplier;
193     }
194 
getDegreesFromCoords(float pointX, float pointY, boolean forceLegal, final Boolean[] isInnerCircle)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
onDraw(Canvas canvas)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 
getDisappearAnimator()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 
getReappearAnimator()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
onAnimationUpdate(ValueAnimator animation)394         public void onAnimationUpdate(ValueAnimator animation) {
395             RadialSelectorView.this.invalidate();
396         }
397     }
398 }
399