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