RotarySelector.java revision 5fef93b2a827cfafee04d7cfb827262c9b75fd91
1/* 2 * Copyright (C) 2009 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.internal.widget; 18 19import android.content.Context; 20import android.content.res.Resources; 21import android.graphics.Canvas; 22import android.graphics.drawable.Drawable; 23import android.os.Vibrator; 24import android.util.AttributeSet; 25import android.util.Log; 26import android.view.MotionEvent; 27import android.view.View; 28import android.view.animation.AccelerateInterpolator; 29import static android.view.animation.AnimationUtils.currentAnimationTimeMillis; 30import com.android.internal.R; 31 32 33/** 34 * Custom view that presents up to two items that are selectable by rotating a semi-circle from 35 * left to right, or right to left. Used by incoming call screen, and the lock screen when no 36 * security pattern is set. 37 */ 38public class RotarySelector extends View { 39 private static final String LOG_TAG = "RotarySelector"; 40 private static final boolean DBG = false; 41 42 // Listener for onDialTrigger() callbacks. 43 private OnDialTriggerListener mOnDialTriggerListener; 44 45 private float mDensity; 46 47 // UI elements 48 private Drawable mBackground; 49 private Drawable mDimple; 50 51 private Drawable mLeftHandleIcon; 52 private Drawable mRightHandleIcon; 53 54 private Drawable mArrowShortLeftAndRight; 55 private Drawable mArrowLongLeft; // Long arrow starting on the left, pointing clockwise 56 private Drawable mArrowLongRight; // Long arrow starting on the right, pointing CCW 57 58 // positions of the left and right handle 59 private int mLeftHandleX; 60 private int mRightHandleX; 61 62 // current offset of user's dragging 63 private int mTouchDragOffset = 0; 64 65 // state of the animation used to bring the handle back to its start position when 66 // the user lets go before triggering an action 67 private boolean mAnimating = false; 68 private long mAnimationEndTime; 69 private int mAnimatingDelta; 70 private AccelerateInterpolator mInterpolator; 71 72 /** 73 * True after triggering an action if the user of {@link OnDialTriggerListener} wants to 74 * freeze the UI (until they transition to another screen). 75 */ 76 private boolean mFrozen = false; 77 78 /** 79 * If the user is currently dragging something. 80 */ 81 private int mGrabbedState = NOTHING_GRABBED; 82 private static final int NOTHING_GRABBED = 0; 83 private static final int LEFT_HANDLE_GRABBED = 1; 84 private static final int RIGHT_HANDLE_GRABBED = 2; 85 86 /** 87 * Whether the user has triggered something (e.g dragging the left handle all the way over to 88 * the right). 89 */ 90 private boolean mTriggered = false; 91 92 // Vibration (haptic feedback) 93 private Vibrator mVibrator; 94 private static final long VIBRATE_SHORT = 60; // msec 95 private static final long VIBRATE_LONG = 100; // msec 96 97 /** 98 * The drawable for the arrows need to be scrunched this many dips towards the rotary bg below 99 * it. 100 */ 101 private static final int ARROW_SCRUNCH_DIP = 6; 102 103 /** 104 * How far inset the left and right circles should be 105 */ 106 private static final int EDGE_PADDING_DIP = 9; 107 108 /** 109 * How far from the edge of the screen the user must drag to trigger the event. 110 */ 111 private static final int EDGE_TRIGGER_DIP = 65; 112 113 /** 114 * Dimensions of arc in background drawable. 115 */ 116 static final int OUTER_ROTARY_RADIUS_DIP = 390; 117 static final int ROTARY_STROKE_WIDTH_DIP = 83; 118 private static final int ANIMATION_DURATION_MILLIS = 300; 119 120 private static final boolean DRAW_CENTER_DIMPLE = false; 121 private int mEdgeTriggerThresh; 122 123 public RotarySelector(Context context) { 124 this(context, null); 125 } 126 127 /** 128 * Constructor used when this widget is created from a layout file. 129 */ 130 public RotarySelector(Context context, AttributeSet attrs) { 131 super(context, attrs); 132 if (DBG) log("IncomingCallDialWidget constructor..."); 133 134 Resources r = getResources(); 135 mDensity = r.getDisplayMetrics().density; 136 if (DBG) log("- Density: " + mDensity); 137 138 // Assets (all are BitmapDrawables). 139 mBackground = r.getDrawable(R.drawable.jog_dial_bg_cropped); 140 mDimple = r.getDrawable(R.drawable.jog_dial_dimple); 141 142 mArrowLongLeft = r.getDrawable(R.drawable.jog_dial_arrow_long_left_green); 143 mArrowLongRight = r.getDrawable(R.drawable.jog_dial_arrow_long_right_red); 144 mArrowShortLeftAndRight = r.getDrawable(R.drawable.jog_dial_arrow_short_left_and_right); 145 146 // Arrows: 147 // All arrow assets are the same size (they're the full width of 148 // the screen) regardless of which arrows are actually visible. 149 int arrowW = mArrowShortLeftAndRight.getIntrinsicWidth(); 150 int arrowH = mArrowShortLeftAndRight.getIntrinsicHeight(); 151 mArrowShortLeftAndRight.setBounds(0, 0, arrowW, arrowH); 152 mArrowLongLeft.setBounds(0, 0, arrowW, arrowH); 153 mArrowLongRight.setBounds(0, 0, arrowW, arrowH); 154 155 mInterpolator = new AccelerateInterpolator(); 156 157 mEdgeTriggerThresh = (int) (mDensity * EDGE_TRIGGER_DIP); 158 } 159 160 /** 161 * Sets the left handle icon to a given resource. 162 * 163 * The resource should refer to a Drawable object, or use 0 to remove 164 * the icon. 165 * 166 * @param resId the resource ID. 167 */ 168 public void setLeftHandleResource(int resId) { 169 Drawable d = null; 170 if (resId != 0) { 171 d = getResources().getDrawable(resId); 172 } 173 setLeftHandleDrawable(d); 174 } 175 176 /** 177 * Sets the left handle icon to a given Drawable. 178 * 179 * @param d the Drawable to use as the icon, or null to remove the icon. 180 */ 181 public void setLeftHandleDrawable(Drawable d) { 182 mLeftHandleIcon = d; 183 invalidate(); 184 } 185 186 /** 187 * Sets the right handle icon to a given resource. 188 * 189 * The resource should refer to a Drawable object, or use 0 to remove 190 * the icon. 191 * 192 * @param resId the resource ID. 193 */ 194 public void setRightHandleResource(int resId) { 195 Drawable d = null; 196 if (resId != 0) { 197 d = getResources().getDrawable(resId); 198 } 199 setRightHandleDrawable(d); 200 } 201 202 /** 203 * Sets the right handle icon to a given Drawable. 204 * 205 * @param d the Drawable to use as the icon, or null to remove the icon. 206 */ 207 public void setRightHandleDrawable(Drawable d) { 208 mRightHandleIcon = d; 209 invalidate(); 210 } 211 212 @Override 213 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 214 final int width = MeasureSpec.getSize(widthMeasureSpec); // screen width 215 216 final int arrowH = mArrowShortLeftAndRight.getIntrinsicHeight(); 217 final int backgroundH = mBackground.getIntrinsicHeight(); 218 219 // by making the height less than arrow + bg, arrow and bg will be scrunched together, 220 // overlaying somewhat (though on transparent portions of the drawable). 221 // this works because the arrows are drawn from the top, and the rotary bg is drawn 222 // from the bottom. 223 final int arrowScrunch = (int) (ARROW_SCRUNCH_DIP * mDensity); 224 setMeasuredDimension(width, backgroundH + arrowH - arrowScrunch); 225 } 226 227 @Override 228 protected void onSizeChanged(int w, int h, int oldw, int oldh) { 229 super.onSizeChanged(w, h, oldw, oldh); 230 231 mLeftHandleX = (int) (EDGE_PADDING_DIP * mDensity) + mDimple.getIntrinsicWidth() / 2; 232 mRightHandleX = 233 getWidth() - (int) (EDGE_PADDING_DIP * mDensity) - mDimple.getIntrinsicWidth() / 2; 234 } 235 236// private Paint mPaint = new Paint(); 237 238 @Override 239 protected void onDraw(Canvas canvas) { 240 super.onDraw(canvas); 241 if (DBG) { 242 log(String.format("onDraw: mAnimating=%s, mTouchDragOffset=%d, mGrabbedState=%d," + 243 "mFrozen=%s", 244 mAnimating, mTouchDragOffset, mGrabbedState, mFrozen)); 245 } 246 247 final int height = getHeight(); 248 249 // update animating state before we draw anything 250 if (mAnimating && !mFrozen) { 251 long millisLeft = mAnimationEndTime - currentAnimationTimeMillis(); 252 if (DBG) log("millisleft for animating: " + millisLeft); 253 if (millisLeft <= 0) { 254 reset(); 255 } else { 256 float interpolation = mInterpolator.getInterpolation( 257 (float) millisLeft / ANIMATION_DURATION_MILLIS); 258 mTouchDragOffset = (int) (mAnimatingDelta * interpolation); 259 } 260 } 261 262 // Background: 263 final int backgroundW = mBackground.getIntrinsicWidth(); 264 final int backgroundH = mBackground.getIntrinsicHeight(); 265 final int backgroundY = height - backgroundH; 266 if (DBG) log("- Background INTRINSIC: " + backgroundW + " x " + backgroundH); 267 mBackground.setBounds(0, backgroundY, 268 backgroundW, backgroundY + backgroundH); 269 if (DBG) log(" Background BOUNDS: " + mBackground.getBounds()); 270 mBackground.draw(canvas); 271 272 273 // Draw the correct arrow(s) depending on the current state: 274 Drawable currentArrow; 275 switch (mGrabbedState) { 276 case NOTHING_GRABBED: 277 currentArrow = null; //mArrowShortLeftAndRight; 278 break; 279 case LEFT_HANDLE_GRABBED: 280 currentArrow = mArrowLongLeft; 281 break; 282 case RIGHT_HANDLE_GRABBED: 283 currentArrow = mArrowLongRight; 284 break; 285 default: 286 throw new IllegalStateException("invalid mGrabbedState: " + mGrabbedState); 287 } 288 if (currentArrow != null) currentArrow.draw(canvas); 289 290 // debug: draw circle that should match the outer arc (good sanity check) 291// mPaint.setColor(Color.RED); 292// mPaint.setStyle(Paint.Style.STROKE); 293// float or = OUTER_ROTARY_RADIUS_DIP * mDensity; 294// canvas.drawCircle(getWidth() / 2, or + mBackground.getBounds().top, or, mPaint); 295 296 final int outerRadius = (int) (mDensity * OUTER_ROTARY_RADIUS_DIP); 297 final int innerRadius = 298 (int) ((OUTER_ROTARY_RADIUS_DIP - ROTARY_STROKE_WIDTH_DIP) * mDensity); 299 final int bgTop = mBackground.getBounds().top; 300 { 301 final int xOffset = mLeftHandleX + mTouchDragOffset; 302 final int drawableY = getYOnArc( 303 mBackground, 304 innerRadius, 305 outerRadius, 306 xOffset); 307 308 drawCentered(mDimple, canvas, xOffset, drawableY + bgTop); 309 if (mGrabbedState != RIGHT_HANDLE_GRABBED) { 310 drawCentered(mLeftHandleIcon, canvas, xOffset, drawableY + bgTop); 311 } 312 } 313 314 if (DRAW_CENTER_DIMPLE) { 315 final int xOffset = getWidth() / 2 + mTouchDragOffset; 316 final int drawableY = getYOnArc( 317 mBackground, 318 innerRadius, 319 outerRadius, 320 xOffset); 321 322 drawCentered(mDimple, canvas, xOffset, drawableY + bgTop); 323 } 324 325 { 326 final int xOffset = mRightHandleX + mTouchDragOffset; 327 final int drawableY = getYOnArc( 328 mBackground, 329 innerRadius, 330 outerRadius, 331 xOffset); 332 333 drawCentered(mDimple, canvas, xOffset, drawableY + bgTop); 334 if (mGrabbedState != LEFT_HANDLE_GRABBED) { 335 drawCentered(mRightHandleIcon, canvas, xOffset, drawableY + bgTop); 336 } 337 } 338 339 if (mAnimating) invalidate(); 340 } 341 342 /** 343 * Assuming drawable is a bounding box around a piece of an arc drawn by two concentric circles 344 * (as the background drawable for the rotary widget is), and given an x coordinate along the 345 * drawable, return the y coordinate of a point on the arc that is between the two concentric 346 * circles. The resulting y combined with the incoming x is a point along the circle in 347 * between the two concentric circles. 348 * 349 * @param drawable The drawable. 350 * @param innerRadius The radius of the circle that intersects the drawable at the bottom two 351 * corders of the drawable (top two corners in terms of drawing coordinates). 352 * @param outerRadius The radius of the circle who's top most point is the top center of the 353 * drawable (bottom center in terms of drawing coordinates). 354 * @param x The distance along the x axis of the desired point. 355 * @return The y coordinate, in drawing coordinates, that will place (x, y) along the circle 356 * in between the two concentric circles. 357 */ 358 private int getYOnArc(Drawable drawable, int innerRadius, int outerRadius, int x) { 359 360 // the hypotenuse 361 final int halfWidth = (outerRadius - innerRadius) / 2; 362 final int middleRadius = innerRadius + halfWidth; 363 364 // the bottom leg of the triangle 365 final int triangleBottom = (drawable.getIntrinsicWidth() / 2) - x; 366 367 // "Our offense is like the pythagorean theorem: There is no answer!" - Shaquille O'Neal 368 final int triangleY = 369 (int) Math.sqrt(middleRadius * middleRadius - triangleBottom * triangleBottom); 370 371 // convert to drawing coordinates: 372 // middleRadius - triangleY = 373 // the vertical distance from the outer edge of the circle to the desired point 374 // from there we add the distance from the top of the drawable to the middle circle 375 return middleRadius - triangleY + halfWidth; 376 } 377 378 /** 379 * Handle touch screen events. 380 * 381 * @param event The motion event. 382 * @return True if the event was handled, false otherwise. 383 */ 384 @Override 385 public boolean onTouchEvent(MotionEvent event) { 386 if (mAnimating || mFrozen) { 387 return true; 388 } 389 390 final int eventX = (int) event.getX(); 391 final int hitWindow = mDimple.getIntrinsicWidth(); 392 393 final int action = event.getAction(); 394 switch (action) { 395 case MotionEvent.ACTION_DOWN: 396 if (DBG) log("touch-down"); 397 mTriggered = false; 398 if (mGrabbedState != NOTHING_GRABBED) { 399 reset(); 400 invalidate(); 401 } 402 if (eventX < mLeftHandleX + hitWindow) { 403 mTouchDragOffset = eventX - mLeftHandleX; 404 mGrabbedState = LEFT_HANDLE_GRABBED; 405 invalidate(); 406 vibrate(VIBRATE_SHORT); 407 } else if (eventX > mRightHandleX - hitWindow) { 408 mTouchDragOffset = eventX - mRightHandleX; 409 mGrabbedState = RIGHT_HANDLE_GRABBED; 410 invalidate(); 411 vibrate(VIBRATE_SHORT); 412 } 413 break; 414 415 case MotionEvent.ACTION_MOVE: 416 if (DBG) log("touch-move"); 417 if (mGrabbedState == LEFT_HANDLE_GRABBED) { 418 mTouchDragOffset = eventX - mLeftHandleX; 419 invalidate(); 420 if (eventX >= getRight() - mEdgeTriggerThresh && !mTriggered) { 421 mTriggered = true; 422 mFrozen = dispatchTriggerEvent(OnDialTriggerListener.LEFT_HANDLE); 423 } 424 } else if (mGrabbedState == RIGHT_HANDLE_GRABBED) { 425 mTouchDragOffset = eventX - mRightHandleX; 426 invalidate(); 427 if (eventX <= mEdgeTriggerThresh && !mTriggered) { 428 mTriggered = true; 429 mFrozen = dispatchTriggerEvent(OnDialTriggerListener.RIGHT_HANDLE); 430 } 431 } 432 break; 433 case MotionEvent.ACTION_UP: 434 if (DBG) log("touch-up"); 435 // handle animating back to start if they didn't trigger 436 if (mGrabbedState == LEFT_HANDLE_GRABBED 437 && Math.abs(eventX - mLeftHandleX) > 5) { 438 mAnimating = true; 439 mAnimationEndTime = currentAnimationTimeMillis() + ANIMATION_DURATION_MILLIS; 440 mAnimatingDelta = eventX - mLeftHandleX; 441 } else if (mGrabbedState == RIGHT_HANDLE_GRABBED 442 && Math.abs(eventX - mRightHandleX) > 5) { 443 mAnimating = true; 444 mAnimationEndTime = currentAnimationTimeMillis() + ANIMATION_DURATION_MILLIS; 445 mAnimatingDelta = eventX - mRightHandleX; 446 } 447 448 mTouchDragOffset = 0; 449 mGrabbedState = NOTHING_GRABBED; 450 invalidate(); 451 break; 452 case MotionEvent.ACTION_CANCEL: 453 if (DBG) log("touch-cancel"); 454 reset(); 455 invalidate(); 456 break; 457 } 458 return true; 459 } 460 461 private void reset() { 462 mAnimating = false; 463 mTouchDragOffset = 0; 464 mGrabbedState = NOTHING_GRABBED; 465 mTriggered = false; 466 } 467 468 /** 469 * Triggers haptic feedback. 470 */ 471 private synchronized void vibrate(long duration) { 472 if (mVibrator == null) { 473 mVibrator = (android.os.Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE); 474 } 475 mVibrator.vibrate(duration); 476 } 477 478 /** 479 * Sets the bounds of the specified Drawable so that it's centered 480 * on the point (x,y), then draws it onto the specified canvas. 481 * TODO: is there already a utility method somewhere for this? 482 */ 483 private static void drawCentered(Drawable d, Canvas c, int x, int y) { 484 int w = d.getIntrinsicWidth(); 485 int h = d.getIntrinsicHeight(); 486 487 // if (DBG) log("--> drawCentered: " + x + " , " + y + "; intrinsic " + w + " x " + h); 488 d.setBounds(x - (w / 2), y - (h / 2), 489 x + (w / 2), y + (h / 2)); 490 d.draw(c); 491 } 492 493 494 /** 495 * Registers a callback to be invoked when the dial 496 * is "triggered" by rotating it one way or the other. 497 * 498 * @param l the OnDialTriggerListener to attach to this view 499 */ 500 public void setOnDialTriggerListener(OnDialTriggerListener l) { 501 mOnDialTriggerListener = l; 502 } 503 504 /** 505 * Dispatches a trigger event to our listener. 506 */ 507 private boolean dispatchTriggerEvent(int whichHandle) { 508 vibrate(VIBRATE_LONG); 509 if (mOnDialTriggerListener != null) { 510 return mOnDialTriggerListener.onDialTrigger(this, whichHandle); 511 } 512 return false; 513 } 514 515 /** 516 * Interface definition for a callback to be invoked when the dial 517 * is "triggered" by rotating it one way or the other. 518 */ 519 public interface OnDialTriggerListener { 520 /** 521 * The dial was triggered because the user grabbed the left handle, 522 * and rotated the dial clockwise. 523 */ 524 public static final int LEFT_HANDLE = 1; 525 526 /** 527 * The dial was triggered because the user grabbed the right handle, 528 * and rotated the dial counterclockwise. 529 */ 530 public static final int RIGHT_HANDLE = 2; 531 532 /** 533 * @hide 534 * The center handle is currently unused. 535 */ 536 public static final int CENTER_HANDLE = 3; 537 538 /** 539 * Called when the dial is triggered. 540 * 541 * @param v The view that was triggered 542 * @param whichHandle Which "dial handle" the user grabbed, 543 * either {@link #LEFT_HANDLE}, {@link #RIGHT_HANDLE}, or 544 * {@link #CENTER_HANDLE}. 545 * @return Whether the widget should freeze (e.g when the action goes to another screen, 546 * you want the UI to stay put until the transition occurs). 547 */ 548 boolean onDialTrigger(View v, int whichHandle); 549 } 550 551 552 // Debugging / testing code 553 554 private void log(String msg) { 555 Log.d(LOG_TAG, msg); 556 } 557} 558