RadialSelectorView.java revision f3b38bd61d583d31200c501f5a74392aac510657
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(60); 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