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