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.graphics.Typeface;
29 import android.graphics.Paint.Align;
30 import android.util.Log;
31 import android.view.View;
32 
33 import com.android.datetimepicker.R;
34 
35 /**
36  * A view to show a series of numbers in a circular pattern.
37  */
38 public class RadialTextsView extends View {
39     private final static String TAG = "RadialTextsView";
40 
41     private final Paint mPaint = new Paint();
42 
43     private boolean mDrawValuesReady;
44     private boolean mIsInitialized;
45 
46     private Typeface mTypefaceLight;
47     private Typeface mTypefaceRegular;
48     private String[] mTexts;
49     private String[] mInnerTexts;
50     private boolean mIs24HourMode;
51     private boolean mHasInnerCircle;
52     private float mCircleRadiusMultiplier;
53     private float mAmPmCircleRadiusMultiplier;
54     private float mNumbersRadiusMultiplier;
55     private float mInnerNumbersRadiusMultiplier;
56     private float mTextSizeMultiplier;
57     private float mInnerTextSizeMultiplier;
58 
59     private int mXCenter;
60     private int mYCenter;
61     private float mCircleRadius;
62     private boolean mTextGridValuesDirty;
63     private float mTextSize;
64     private float mInnerTextSize;
65     private float[] mTextGridHeights;
66     private float[] mTextGridWidths;
67     private float[] mInnerTextGridHeights;
68     private float[] mInnerTextGridWidths;
69 
70     private float mAnimationRadiusMultiplier;
71     private float mTransitionMidRadiusMultiplier;
72     private float mTransitionEndRadiusMultiplier;
73     ObjectAnimator mDisappearAnimator;
74     ObjectAnimator mReappearAnimator;
75     private InvalidateUpdateListener mInvalidateUpdateListener;
76 
RadialTextsView(Context context)77     public RadialTextsView(Context context) {
78         super(context);
79         mIsInitialized = false;
80     }
81 
initialize(Resources res, String[] texts, String[] innerTexts, boolean is24HourMode, boolean disappearsOut)82     public void initialize(Resources res, String[] texts, String[] innerTexts,
83             boolean is24HourMode, boolean disappearsOut) {
84         if (mIsInitialized) {
85             Log.e(TAG, "This RadialTextsView may only be initialized once.");
86             return;
87         }
88 
89         // Set up the paint.
90         int numbersTextColor = res.getColor(R.color.numbers_text_color);
91         mPaint.setColor(numbersTextColor);
92         String typefaceFamily = res.getString(R.string.radial_numbers_typeface);
93         mTypefaceLight = Typeface.create(typefaceFamily, Typeface.NORMAL);
94         String typefaceFamilyRegular = res.getString(R.string.sans_serif);
95         mTypefaceRegular = Typeface.create(typefaceFamilyRegular, Typeface.NORMAL);
96         mPaint.setAntiAlias(true);
97         mPaint.setTextAlign(Align.CENTER);
98 
99         mTexts = texts;
100         mInnerTexts = innerTexts;
101         mIs24HourMode = is24HourMode;
102         mHasInnerCircle = (innerTexts != null);
103 
104         // Calculate the radius for the main circle.
105         if (is24HourMode) {
106             mCircleRadiusMultiplier = Float.parseFloat(
107                     res.getString(R.string.circle_radius_multiplier_24HourMode));
108         } else {
109             mCircleRadiusMultiplier = Float.parseFloat(
110                     res.getString(R.string.circle_radius_multiplier));
111             mAmPmCircleRadiusMultiplier =
112                     Float.parseFloat(res.getString(R.string.ampm_circle_radius_multiplier));
113         }
114 
115         // Initialize the widths and heights of the grid, and calculate the values for the numbers.
116         mTextGridHeights = new float[7];
117         mTextGridWidths = new float[7];
118         if (mHasInnerCircle) {
119             mNumbersRadiusMultiplier = Float.parseFloat(
120                     res.getString(R.string.numbers_radius_multiplier_outer));
121             mTextSizeMultiplier = Float.parseFloat(
122                     res.getString(R.string.text_size_multiplier_outer));
123             mInnerNumbersRadiusMultiplier = Float.parseFloat(
124                     res.getString(R.string.numbers_radius_multiplier_inner));
125             mInnerTextSizeMultiplier = Float.parseFloat(
126                     res.getString(R.string.text_size_multiplier_inner));
127 
128             mInnerTextGridHeights = new float[7];
129             mInnerTextGridWidths = new float[7];
130         } else {
131             mNumbersRadiusMultiplier = Float.parseFloat(
132                     res.getString(R.string.numbers_radius_multiplier_normal));
133             mTextSizeMultiplier = Float.parseFloat(
134                     res.getString(R.string.text_size_multiplier_normal));
135         }
136 
137         mAnimationRadiusMultiplier = 1;
138         mTransitionMidRadiusMultiplier = 1f + (0.05f * (disappearsOut? -1 : 1));
139         mTransitionEndRadiusMultiplier = 1f + (0.3f * (disappearsOut? 1 : -1));
140         mInvalidateUpdateListener = new InvalidateUpdateListener();
141 
142         mTextGridValuesDirty = true;
143         mIsInitialized = true;
144     }
145 
setTheme(Context context, boolean themeDark)146     /* package */ void setTheme(Context context, boolean themeDark) {
147         Resources res = context.getResources();
148         int textColor;
149         if (themeDark) {
150             textColor = res.getColor(android.R.color.white);
151         } else {
152             textColor = res.getColor(R.color.numbers_text_color);
153         }
154         mPaint.setColor(textColor);
155     }
156 
157     /**
158      * Allows for smoother animation.
159      */
160     @Override
hasOverlappingRendering()161     public boolean hasOverlappingRendering() {
162         return false;
163     }
164 
165     /**
166      * Used by the animation to move the numbers in and out.
167      */
setAnimationRadiusMultiplier(float animationRadiusMultiplier)168     public void setAnimationRadiusMultiplier(float animationRadiusMultiplier) {
169         mAnimationRadiusMultiplier = animationRadiusMultiplier;
170         mTextGridValuesDirty = true;
171     }
172 
173     @Override
onDraw(Canvas canvas)174     public void onDraw(Canvas canvas) {
175         int viewWidth = getWidth();
176         if (viewWidth == 0 || !mIsInitialized) {
177             return;
178         }
179 
180         if (!mDrawValuesReady) {
181             mXCenter = getWidth() / 2;
182             mYCenter = getHeight() / 2;
183             mCircleRadius = Math.min(mXCenter, mYCenter) * mCircleRadiusMultiplier;
184             if (!mIs24HourMode) {
185                 // We'll need to draw the AM/PM circles, so the main circle will need to have
186                 // a slightly higher center. To keep the entire view centered vertically, we'll
187                 // have to push it up by half the radius of the AM/PM circles.
188                 float amPmCircleRadius = mCircleRadius * mAmPmCircleRadiusMultiplier;
189                 mYCenter -= amPmCircleRadius / 2;
190             }
191 
192             mTextSize = mCircleRadius * mTextSizeMultiplier;
193             if (mHasInnerCircle) {
194                 mInnerTextSize = mCircleRadius * mInnerTextSizeMultiplier;
195             }
196 
197             // Because the text positions will be static, pre-render the animations.
198             renderAnimations();
199 
200             mTextGridValuesDirty = true;
201             mDrawValuesReady = true;
202         }
203 
204         // Calculate the text positions, but only if they've changed since the last onDraw.
205         if (mTextGridValuesDirty) {
206             float numbersRadius =
207                     mCircleRadius * mNumbersRadiusMultiplier * mAnimationRadiusMultiplier;
208 
209             // Calculate the positions for the 12 numbers in the main circle.
210             calculateGridSizes(numbersRadius, mXCenter, mYCenter,
211                     mTextSize, mTextGridHeights, mTextGridWidths);
212             if (mHasInnerCircle) {
213                 // If we have an inner circle, calculate those positions too.
214                 float innerNumbersRadius =
215                         mCircleRadius * mInnerNumbersRadiusMultiplier * mAnimationRadiusMultiplier;
216                 calculateGridSizes(innerNumbersRadius, mXCenter, mYCenter,
217                         mInnerTextSize, mInnerTextGridHeights, mInnerTextGridWidths);
218             }
219             mTextGridValuesDirty = false;
220         }
221 
222         // Draw the texts in the pre-calculated positions.
223         drawTexts(canvas, mTextSize, mTypefaceLight, mTexts, mTextGridWidths, mTextGridHeights);
224         if (mHasInnerCircle) {
225             drawTexts(canvas, mInnerTextSize, mTypefaceRegular, mInnerTexts,
226                     mInnerTextGridWidths, mInnerTextGridHeights);
227         }
228     }
229 
230     /**
231      * Using the trigonometric Unit Circle, calculate the positions that the text will need to be
232      * drawn at based on the specified circle radius. Place the values in the textGridHeights and
233      * textGridWidths parameters.
234      */
calculateGridSizes(float numbersRadius, float xCenter, float yCenter, float textSize, float[] textGridHeights, float[] textGridWidths)235     private void calculateGridSizes(float numbersRadius, float xCenter, float yCenter,
236             float textSize, float[] textGridHeights, float[] textGridWidths) {
237         /*
238          * The numbers need to be drawn in a 7x7 grid, representing the points on the Unit Circle.
239          */
240         float offset1 = numbersRadius;
241         // cos(30) = a / r => r * cos(30) = a => r * √3/2 = a
242         float offset2 = numbersRadius * ((float) Math.sqrt(3)) / 2f;
243         // sin(30) = o / r => r * sin(30) = o => r / 2 = a
244         float offset3 = numbersRadius / 2f;
245         mPaint.setTextSize(textSize);
246         // We'll need yTextBase to be slightly lower to account for the text's baseline.
247         yCenter -= (mPaint.descent() + mPaint.ascent()) / 2;
248 
249         textGridHeights[0] = yCenter - offset1;
250         textGridWidths[0] = xCenter - offset1;
251         textGridHeights[1] = yCenter - offset2;
252         textGridWidths[1] = xCenter - offset2;
253         textGridHeights[2] = yCenter - offset3;
254         textGridWidths[2] = xCenter - offset3;
255         textGridHeights[3] = yCenter;
256         textGridWidths[3] = xCenter;
257         textGridHeights[4] = yCenter + offset3;
258         textGridWidths[4] = xCenter + offset3;
259         textGridHeights[5] = yCenter + offset2;
260         textGridWidths[5] = xCenter + offset2;
261         textGridHeights[6] = yCenter + offset1;
262         textGridWidths[6] = xCenter + offset1;
263     }
264 
265     /**
266      * Draw the 12 text values at the positions specified by the textGrid parameters.
267      */
drawTexts(Canvas canvas, float textSize, Typeface typeface, String[] texts, float[] textGridWidths, float[] textGridHeights)268     private void drawTexts(Canvas canvas, float textSize, Typeface typeface, String[] texts,
269             float[] textGridWidths, float[] textGridHeights) {
270         mPaint.setTextSize(textSize);
271         mPaint.setTypeface(typeface);
272         canvas.drawText(texts[0], textGridWidths[3], textGridHeights[0], mPaint);
273         canvas.drawText(texts[1], textGridWidths[4], textGridHeights[1], mPaint);
274         canvas.drawText(texts[2], textGridWidths[5], textGridHeights[2], mPaint);
275         canvas.drawText(texts[3], textGridWidths[6], textGridHeights[3], mPaint);
276         canvas.drawText(texts[4], textGridWidths[5], textGridHeights[4], mPaint);
277         canvas.drawText(texts[5], textGridWidths[4], textGridHeights[5], mPaint);
278         canvas.drawText(texts[6], textGridWidths[3], textGridHeights[6], mPaint);
279         canvas.drawText(texts[7], textGridWidths[2], textGridHeights[5], mPaint);
280         canvas.drawText(texts[8], textGridWidths[1], textGridHeights[4], mPaint);
281         canvas.drawText(texts[9], textGridWidths[0], textGridHeights[3], mPaint);
282         canvas.drawText(texts[10], textGridWidths[1], textGridHeights[2], mPaint);
283         canvas.drawText(texts[11], textGridWidths[2], textGridHeights[1], mPaint);
284     }
285 
286     /**
287      * Render the animations for appearing and disappearing.
288      */
renderAnimations()289     private void renderAnimations() {
290         Keyframe kf0, kf1, kf2, kf3;
291         float midwayPoint = 0.2f;
292         int duration = 500;
293 
294         // Set up animator for disappearing.
295         kf0 = Keyframe.ofFloat(0f, 1);
296         kf1 = Keyframe.ofFloat(midwayPoint, mTransitionMidRadiusMultiplier);
297         kf2 = Keyframe.ofFloat(1f, mTransitionEndRadiusMultiplier);
298         PropertyValuesHolder radiusDisappear = PropertyValuesHolder.ofKeyframe(
299                 "animationRadiusMultiplier", kf0, kf1, kf2);
300 
301         kf0 = Keyframe.ofFloat(0f, 1f);
302         kf1 = Keyframe.ofFloat(1f, 0f);
303         PropertyValuesHolder fadeOut = PropertyValuesHolder.ofKeyframe("alpha", kf0, kf1);
304 
305         mDisappearAnimator = ObjectAnimator.ofPropertyValuesHolder(
306                 this, radiusDisappear, fadeOut).setDuration(duration);
307         mDisappearAnimator.addUpdateListener(mInvalidateUpdateListener);
308 
309 
310         // Set up animator for reappearing.
311         float delayMultiplier = 0.25f;
312         float transitionDurationMultiplier = 1f;
313         float totalDurationMultiplier = transitionDurationMultiplier + delayMultiplier;
314         int totalDuration = (int) (duration * totalDurationMultiplier);
315         float delayPoint = (delayMultiplier * duration) / totalDuration;
316         midwayPoint = 1 - (midwayPoint * (1 - delayPoint));
317 
318         kf0 = Keyframe.ofFloat(0f, mTransitionEndRadiusMultiplier);
319         kf1 = Keyframe.ofFloat(delayPoint, mTransitionEndRadiusMultiplier);
320         kf2 = Keyframe.ofFloat(midwayPoint, mTransitionMidRadiusMultiplier);
321         kf3 = Keyframe.ofFloat(1f, 1);
322         PropertyValuesHolder radiusReappear = PropertyValuesHolder.ofKeyframe(
323                 "animationRadiusMultiplier", kf0, kf1, kf2, kf3);
324 
325         kf0 = Keyframe.ofFloat(0f, 0f);
326         kf1 = Keyframe.ofFloat(delayPoint, 0f);
327         kf2 = Keyframe.ofFloat(1f, 1f);
328         PropertyValuesHolder fadeIn = PropertyValuesHolder.ofKeyframe("alpha", kf0, kf1, kf2);
329 
330         mReappearAnimator = ObjectAnimator.ofPropertyValuesHolder(
331                 this, radiusReappear, fadeIn).setDuration(totalDuration);
332         mReappearAnimator.addUpdateListener(mInvalidateUpdateListener);
333     }
334 
getDisappearAnimator()335     public ObjectAnimator getDisappearAnimator() {
336         if (!mIsInitialized || !mDrawValuesReady || mDisappearAnimator == null) {
337             Log.e(TAG, "RadialTextView was not ready for animation.");
338             return null;
339         }
340 
341         return mDisappearAnimator;
342     }
343 
getReappearAnimator()344     public ObjectAnimator getReappearAnimator() {
345         if (!mIsInitialized || !mDrawValuesReady || mReappearAnimator == null) {
346             Log.e(TAG, "RadialTextView was not ready for animation.");
347             return null;
348         }
349 
350         return mReappearAnimator;
351     }
352 
353     private class InvalidateUpdateListener implements AnimatorUpdateListener {
354         @Override
onAnimationUpdate(ValueAnimator animation)355         public void onAnimationUpdate(ValueAnimator animation) {
356             RadialTextsView.this.invalidate();
357         }
358     }
359 }
360