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.graphics.Typeface;
29import android.graphics.Paint.Align;
30import android.util.Log;
31import android.view.View;
32
33import com.android.datetimepicker.R;
34
35/**
36 * A view to show a series of numbers in a circular pattern.
37 */
38public 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
77    public RadialTextsView(Context context) {
78        super(context);
79        mIsInitialized = false;
80    }
81
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
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
161    public boolean hasOverlappingRendering() {
162        return false;
163    }
164
165    /**
166     * Used by the animation to move the numbers in and out.
167     */
168    public void setAnimationRadiusMultiplier(float animationRadiusMultiplier) {
169        mAnimationRadiusMultiplier = animationRadiusMultiplier;
170        mTextGridValuesDirty = true;
171    }
172
173    @Override
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     */
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     */
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     */
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
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
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
355        public void onAnimationUpdate(ValueAnimator animation) {
356            RadialTextsView.this.invalidate();
357        }
358    }
359}
360