Home | History | Annotate | Download | only in time
      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 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