/* * Copyright (C) 2009 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.internal.widget; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Matrix; import android.media.AudioAttributes; import android.os.UserHandle; import android.os.Vibrator; import android.provider.Settings; import android.util.AttributeSet; import android.util.Log; import android.view.MotionEvent; import android.view.View; import android.view.VelocityTracker; import android.view.ViewConfiguration; import android.view.animation.DecelerateInterpolator; import static android.view.animation.AnimationUtils.currentAnimationTimeMillis; import com.android.internal.R; /** * Custom view that presents up to two items that are selectable by rotating a semi-circle from * left to right, or right to left. Used by incoming call screen, and the lock screen when no * security pattern is set. */ public class RotarySelector extends View { public static final int HORIZONTAL = 0; public static final int VERTICAL = 1; private static final String LOG_TAG = "RotarySelector"; private static final boolean DBG = false; private static final boolean VISUAL_DEBUG = false; private static final AudioAttributes VIBRATION_ATTRIBUTES = new AudioAttributes.Builder() .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) .build(); // Listener for onDialTrigger() callbacks. private OnDialTriggerListener mOnDialTriggerListener; private float mDensity; // UI elements private Bitmap mBackground; private Bitmap mDimple; private Bitmap mDimpleDim; private Bitmap mLeftHandleIcon; private Bitmap mRightHandleIcon; private Bitmap mArrowShortLeftAndRight; private Bitmap mArrowLongLeft; // Long arrow starting on the left, pointing clockwise private Bitmap mArrowLongRight; // Long arrow starting on the right, pointing CCW // positions of the left and right handle private int mLeftHandleX; private int mRightHandleX; // current offset of rotary widget along the x axis private int mRotaryOffsetX = 0; // state of the animation used to bring the handle back to its start position when // the user lets go before triggering an action private boolean mAnimating = false; private long mAnimationStartTime; private long mAnimationDuration; private int mAnimatingDeltaXStart; // the animation will interpolate from this delta to zero private int mAnimatingDeltaXEnd; private DecelerateInterpolator mInterpolator; private Paint mPaint = new Paint(); // used to rotate the background and arrow assets depending on orientation final Matrix mBgMatrix = new Matrix(); final Matrix mArrowMatrix = new Matrix(); /** * If the user is currently dragging something. */ private int mGrabbedState = NOTHING_GRABBED; public static final int NOTHING_GRABBED = 0; public static final int LEFT_HANDLE_GRABBED = 1; public static final int RIGHT_HANDLE_GRABBED = 2; /** * Whether the user has triggered something (e.g dragging the left handle all the way over to * the right). */ private boolean mTriggered = false; // Vibration (haptic feedback) private Vibrator mVibrator; private static final long VIBRATE_SHORT = 20; // msec private static final long VIBRATE_LONG = 20; // msec /** * The drawable for the arrows need to be scrunched this many dips towards the rotary bg below * it. */ private static final int ARROW_SCRUNCH_DIP = 6; /** * How far inset the left and right circles should be */ private static final int EDGE_PADDING_DIP = 9; /** * How far from the edge of the screen the user must drag to trigger the event. */ private static final int EDGE_TRIGGER_DIP = 100; /** * Dimensions of arc in background drawable. */ static final int OUTER_ROTARY_RADIUS_DIP = 390; static final int ROTARY_STROKE_WIDTH_DIP = 83; static final int SNAP_BACK_ANIMATION_DURATION_MILLIS = 300; static final int SPIN_ANIMATION_DURATION_MILLIS = 800; private int mEdgeTriggerThresh; private int mDimpleWidth; private int mBackgroundWidth; private int mBackgroundHeight; private final int mOuterRadius; private final int mInnerRadius; private int mDimpleSpacing; private VelocityTracker mVelocityTracker; private int mMinimumVelocity; private int mMaximumVelocity; /** * The number of dimples we are flinging when we do the "spin" animation. Used to know when to * wrap the icons back around so they "rotate back" onto the screen. * @see #updateAnimation() */ private int mDimplesOfFling = 0; /** * Either {@link #HORIZONTAL} or {@link #VERTICAL}. */ private int mOrientation; public RotarySelector(Context context) { this(context, null); } /** * Constructor used when this widget is created from a layout file. */ public RotarySelector(Context context, AttributeSet attrs) { super(context, attrs); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RotarySelector); mOrientation = a.getInt(R.styleable.RotarySelector_orientation, HORIZONTAL); a.recycle(); Resources r = getResources(); mDensity = r.getDisplayMetrics().density; if (DBG) log("- Density: " + mDensity); // Assets (all are BitmapDrawables). mBackground = getBitmapFor(R.drawable.jog_dial_bg); mDimple = getBitmapFor(R.drawable.jog_dial_dimple); mDimpleDim = getBitmapFor(R.drawable.jog_dial_dimple_dim); mArrowLongLeft = getBitmapFor(R.drawable.jog_dial_arrow_long_left_green); mArrowLongRight = getBitmapFor(R.drawable.jog_dial_arrow_long_right_red); mArrowShortLeftAndRight = getBitmapFor(R.drawable.jog_dial_arrow_short_left_and_right); mInterpolator = new DecelerateInterpolator(1f); mEdgeTriggerThresh = (int) (mDensity * EDGE_TRIGGER_DIP); mDimpleWidth = mDimple.getWidth(); mBackgroundWidth = mBackground.getWidth(); mBackgroundHeight = mBackground.getHeight(); mOuterRadius = (int) (mDensity * OUTER_ROTARY_RADIUS_DIP); mInnerRadius = (int) ((OUTER_ROTARY_RADIUS_DIP - ROTARY_STROKE_WIDTH_DIP) * mDensity); final ViewConfiguration configuration = ViewConfiguration.get(mContext); mMinimumVelocity = configuration.getScaledMinimumFlingVelocity() * 2; mMaximumVelocity = configuration.getScaledMaximumFlingVelocity(); } private Bitmap getBitmapFor(int resId) { return BitmapFactory.decodeResource(getContext().getResources(), resId); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); final int edgePadding = (int) (EDGE_PADDING_DIP * mDensity); mLeftHandleX = edgePadding + mDimpleWidth / 2; final int length = isHoriz() ? w : h; mRightHandleX = length - edgePadding - mDimpleWidth / 2; mDimpleSpacing = (length / 2) - mLeftHandleX; // bg matrix only needs to be calculated once mBgMatrix.setTranslate(0, 0); if (!isHoriz()) { // set up matrix for translating drawing of background and arrow assets final int left = w - mBackgroundHeight; mBgMatrix.preRotate(-90, 0, 0); mBgMatrix.postTranslate(left, h); } else { mBgMatrix.postTranslate(0, h - mBackgroundHeight); } } private boolean isHoriz() { return mOrientation == HORIZONTAL; } /** * Sets the left handle icon to a given resource. * * The resource should refer to a Drawable object, or use 0 to remove * the icon. * * @param resId the resource ID. */ public void setLeftHandleResource(int resId) { if (resId != 0) { mLeftHandleIcon = getBitmapFor(resId); } invalidate(); } /** * Sets the right handle icon to a given resource. * * The resource should refer to a Drawable object, or use 0 to remove * the icon. * * @param resId the resource ID. */ public void setRightHandleResource(int resId) { if (resId != 0) { mRightHandleIcon = getBitmapFor(resId); } invalidate(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { final int length = isHoriz() ? MeasureSpec.getSize(widthMeasureSpec) : MeasureSpec.getSize(heightMeasureSpec); final int arrowScrunch = (int) (ARROW_SCRUNCH_DIP * mDensity); final int arrowH = mArrowShortLeftAndRight.getHeight(); // by making the height less than arrow + bg, arrow and bg will be scrunched together, // overlaying somewhat (though on transparent portions of the drawable). // this works because the arrows are drawn from the top, and the rotary bg is drawn // from the bottom. final int height = mBackgroundHeight + arrowH - arrowScrunch; if (isHoriz()) { setMeasuredDimension(length, height); } else { setMeasuredDimension(height, length); } } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); final int width = getWidth(); if (VISUAL_DEBUG) { // draw bounding box around widget mPaint.setColor(0xffff0000); mPaint.setStyle(Paint.Style.STROKE); canvas.drawRect(0, 0, width, getHeight(), mPaint); } final int height = getHeight(); // update animating state before we draw anything if (mAnimating) { updateAnimation(); } // Background: canvas.drawBitmap(mBackground, mBgMatrix, mPaint); // Draw the correct arrow(s) depending on the current state: mArrowMatrix.reset(); switch (mGrabbedState) { case NOTHING_GRABBED: //mArrowShortLeftAndRight; break; case LEFT_HANDLE_GRABBED: mArrowMatrix.setTranslate(0, 0); if (!isHoriz()) { mArrowMatrix.preRotate(-90, 0, 0); mArrowMatrix.postTranslate(0, height); } canvas.drawBitmap(mArrowLongLeft, mArrowMatrix, mPaint); break; case RIGHT_HANDLE_GRABBED: mArrowMatrix.setTranslate(0, 0); if (!isHoriz()) { mArrowMatrix.preRotate(-90, 0, 0); // since bg width is > height of screen in landscape mode... mArrowMatrix.postTranslate(0, height + (mBackgroundWidth - height)); } canvas.drawBitmap(mArrowLongRight, mArrowMatrix, mPaint); break; default: throw new IllegalStateException("invalid mGrabbedState: " + mGrabbedState); } final int bgHeight = mBackgroundHeight; final int bgTop = isHoriz() ? height - bgHeight: width - bgHeight; if (VISUAL_DEBUG) { // draw circle bounding arc drawable: good sanity check we're doing the math correctly float or = OUTER_ROTARY_RADIUS_DIP * mDensity; final int vOffset = mBackgroundWidth - height; final int midX = isHoriz() ? width / 2 : mBackgroundWidth / 2 - vOffset; if (isHoriz()) { canvas.drawCircle(midX, or + bgTop, or, mPaint); } else { canvas.drawCircle(or + bgTop, midX, or, mPaint); } } // left dimple / icon { final int xOffset = mLeftHandleX + mRotaryOffsetX; final int drawableY = getYOnArc( mBackgroundWidth, mInnerRadius, mOuterRadius, xOffset); final int x = isHoriz() ? xOffset : drawableY + bgTop; final int y = isHoriz() ? drawableY + bgTop : height - xOffset; if (mGrabbedState != RIGHT_HANDLE_GRABBED) { drawCentered(mDimple, canvas, x, y); drawCentered(mLeftHandleIcon, canvas, x, y); } else { drawCentered(mDimpleDim, canvas, x, y); } } // center dimple { final int xOffset = isHoriz() ? width / 2 + mRotaryOffsetX: height / 2 + mRotaryOffsetX; final int drawableY = getYOnArc( mBackgroundWidth, mInnerRadius, mOuterRadius, xOffset); if (isHoriz()) { drawCentered(mDimpleDim, canvas, xOffset, drawableY + bgTop); } else { // vertical drawCentered(mDimpleDim, canvas, drawableY + bgTop, height - xOffset); } } // right dimple / icon { final int xOffset = mRightHandleX + mRotaryOffsetX; final int drawableY = getYOnArc( mBackgroundWidth, mInnerRadius, mOuterRadius, xOffset); final int x = isHoriz() ? xOffset : drawableY + bgTop; final int y = isHoriz() ? drawableY + bgTop : height - xOffset; if (mGrabbedState != LEFT_HANDLE_GRABBED) { drawCentered(mDimple, canvas, x, y); drawCentered(mRightHandleIcon, canvas, x, y); } else { drawCentered(mDimpleDim, canvas, x, y); } } // draw extra left hand dimples int dimpleLeft = mRotaryOffsetX + mLeftHandleX - mDimpleSpacing; final int halfdimple = mDimpleWidth / 2; while (dimpleLeft > -halfdimple) { final int drawableY = getYOnArc( mBackgroundWidth, mInnerRadius, mOuterRadius, dimpleLeft); if (isHoriz()) { drawCentered(mDimpleDim, canvas, dimpleLeft, drawableY + bgTop); } else { drawCentered(mDimpleDim, canvas, drawableY + bgTop, height - dimpleLeft); } dimpleLeft -= mDimpleSpacing; } // draw extra right hand dimples int dimpleRight = mRotaryOffsetX + mRightHandleX + mDimpleSpacing; final int rightThresh = mRight + halfdimple; while (dimpleRight < rightThresh) { final int drawableY = getYOnArc( mBackgroundWidth, mInnerRadius, mOuterRadius, dimpleRight); if (isHoriz()) { drawCentered(mDimpleDim, canvas, dimpleRight, drawableY + bgTop); } else { drawCentered(mDimpleDim, canvas, drawableY + bgTop, height - dimpleRight); } dimpleRight += mDimpleSpacing; } } /** * Assuming bitmap is a bounding box around a piece of an arc drawn by two concentric circles * (as the background drawable for the rotary widget is), and given an x coordinate along the * drawable, return the y coordinate of a point on the arc that is between the two concentric * circles. The resulting y combined with the incoming x is a point along the circle in * between the two concentric circles. * * @param backgroundWidth The width of the asset (the bottom of the box surrounding the arc). * @param innerRadius The radius of the circle that intersects the drawable at the bottom two * corders of the drawable (top two corners in terms of drawing coordinates). * @param outerRadius The radius of the circle who's top most point is the top center of the * drawable (bottom center in terms of drawing coordinates). * @param x The distance along the x axis of the desired point. @return The y coordinate, in drawing coordinates, that will place (x, y) along the circle * in between the two concentric circles. */ private int getYOnArc(int backgroundWidth, int innerRadius, int outerRadius, int x) { // the hypotenuse final int halfWidth = (outerRadius - innerRadius) / 2; final int middleRadius = innerRadius + halfWidth; // the bottom leg of the triangle final int triangleBottom = (backgroundWidth / 2) - x; // "Our offense is like the pythagorean theorem: There is no answer!" - Shaquille O'Neal final int triangleY = (int) Math.sqrt(middleRadius * middleRadius - triangleBottom * triangleBottom); // convert to drawing coordinates: // middleRadius - triangleY = // the vertical distance from the outer edge of the circle to the desired point // from there we add the distance from the top of the drawable to the middle circle return middleRadius - triangleY + halfWidth; } /** * Handle touch screen events. * * @param event The motion event. * @return True if the event was handled, false otherwise. */ @Override public boolean onTouchEvent(MotionEvent event) { if (mAnimating) { return true; } if (mVelocityTracker == null) { mVelocityTracker = VelocityTracker.obtain(); } mVelocityTracker.addMovement(event); final int height = getHeight(); final int eventX = isHoriz() ? (int) event.getX(): height - ((int) event.getY()); final int hitWindow = mDimpleWidth; final int action = event.getAction(); switch (action) { case MotionEvent.ACTION_DOWN: if (DBG) log("touch-down"); mTriggered = false; if (mGrabbedState != NOTHING_GRABBED) { reset(); invalidate(); } if (eventX < mLeftHandleX + hitWindow) { mRotaryOffsetX = eventX - mLeftHandleX; setGrabbedState(LEFT_HANDLE_GRABBED); invalidate(); vibrate(VIBRATE_SHORT); } else if (eventX > mRightHandleX - hitWindow) { mRotaryOffsetX = eventX - mRightHandleX; setGrabbedState(RIGHT_HANDLE_GRABBED); invalidate(); vibrate(VIBRATE_SHORT); } break; case MotionEvent.ACTION_MOVE: if (DBG) log("touch-move"); if (mGrabbedState == LEFT_HANDLE_GRABBED) { mRotaryOffsetX = eventX - mLeftHandleX; invalidate(); final int rightThresh = isHoriz() ? getRight() : height; if (eventX >= rightThresh - mEdgeTriggerThresh && !mTriggered) { mTriggered = true; dispatchTriggerEvent(OnDialTriggerListener.LEFT_HANDLE); final VelocityTracker velocityTracker = mVelocityTracker; velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); final int rawVelocity = isHoriz() ? (int) velocityTracker.getXVelocity(): -(int) velocityTracker.getYVelocity(); final int velocity = Math.max(mMinimumVelocity, rawVelocity); mDimplesOfFling = Math.max( 8, Math.abs(velocity / mDimpleSpacing)); startAnimationWithVelocity( eventX - mLeftHandleX, mDimplesOfFling * mDimpleSpacing, velocity); } } else if (mGrabbedState == RIGHT_HANDLE_GRABBED) { mRotaryOffsetX = eventX - mRightHandleX; invalidate(); if (eventX <= mEdgeTriggerThresh && !mTriggered) { mTriggered = true; dispatchTriggerEvent(OnDialTriggerListener.RIGHT_HANDLE); final VelocityTracker velocityTracker = mVelocityTracker; velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); final int rawVelocity = isHoriz() ? (int) velocityTracker.getXVelocity(): - (int) velocityTracker.getYVelocity(); final int velocity = Math.min(-mMinimumVelocity, rawVelocity); mDimplesOfFling = Math.max( 8, Math.abs(velocity / mDimpleSpacing)); startAnimationWithVelocity( eventX - mRightHandleX, -(mDimplesOfFling * mDimpleSpacing), velocity); } } break; case MotionEvent.ACTION_UP: if (DBG) log("touch-up"); // handle animating back to start if they didn't trigger if (mGrabbedState == LEFT_HANDLE_GRABBED && Math.abs(eventX - mLeftHandleX) > 5) { // set up "snap back" animation startAnimation(eventX - mLeftHandleX, 0, SNAP_BACK_ANIMATION_DURATION_MILLIS); } else if (mGrabbedState == RIGHT_HANDLE_GRABBED && Math.abs(eventX - mRightHandleX) > 5) { // set up "snap back" animation startAnimation(eventX - mRightHandleX, 0, SNAP_BACK_ANIMATION_DURATION_MILLIS); } mRotaryOffsetX = 0; setGrabbedState(NOTHING_GRABBED); invalidate(); if (mVelocityTracker != null) { mVelocityTracker.recycle(); // wishin' we had generational GC mVelocityTracker = null; } break; case MotionEvent.ACTION_CANCEL: if (DBG) log("touch-cancel"); reset(); invalidate(); if (mVelocityTracker != null) { mVelocityTracker.recycle(); mVelocityTracker = null; } break; } return true; } private void startAnimation(int startX, int endX, int duration) { mAnimating = true; mAnimationStartTime = currentAnimationTimeMillis(); mAnimationDuration = duration; mAnimatingDeltaXStart = startX; mAnimatingDeltaXEnd = endX; setGrabbedState(NOTHING_GRABBED); mDimplesOfFling = 0; invalidate(); } private void startAnimationWithVelocity(int startX, int endX, int pixelsPerSecond) { mAnimating = true; mAnimationStartTime = currentAnimationTimeMillis(); mAnimationDuration = 1000 * (endX - startX) / pixelsPerSecond; mAnimatingDeltaXStart = startX; mAnimatingDeltaXEnd = endX; setGrabbedState(NOTHING_GRABBED); invalidate(); } private void updateAnimation() { final long millisSoFar = currentAnimationTimeMillis() - mAnimationStartTime; final long millisLeft = mAnimationDuration - millisSoFar; final int totalDeltaX = mAnimatingDeltaXStart - mAnimatingDeltaXEnd; final boolean goingRight = totalDeltaX < 0; if (DBG) log("millisleft for animating: " + millisLeft); if (millisLeft <= 0) { reset(); return; } // from 0 to 1 as animation progresses float interpolation = mInterpolator.getInterpolation((float) millisSoFar / mAnimationDuration); final int dx = (int) (totalDeltaX * (1 - interpolation)); mRotaryOffsetX = mAnimatingDeltaXEnd + dx; // once we have gone far enough to animate the current buttons off screen, we start // wrapping the offset back to the other side so that when the animation is finished, // the buttons will come back into their original places. if (mDimplesOfFling > 0) { if (!goingRight && mRotaryOffsetX < -3 * mDimpleSpacing) { // wrap around on fling left mRotaryOffsetX += mDimplesOfFling * mDimpleSpacing; } else if (goingRight && mRotaryOffsetX > 3 * mDimpleSpacing) { // wrap around on fling right mRotaryOffsetX -= mDimplesOfFling * mDimpleSpacing; } } invalidate(); } private void reset() { mAnimating = false; mRotaryOffsetX = 0; mDimplesOfFling = 0; setGrabbedState(NOTHING_GRABBED); mTriggered = false; } /** * Triggers haptic feedback. */ private synchronized void vibrate(long duration) { final boolean hapticEnabled = Settings.System.getIntForUser( mContext.getContentResolver(), Settings.System.HAPTIC_FEEDBACK_ENABLED, 1, UserHandle.USER_CURRENT) != 0; if (hapticEnabled) { if (mVibrator == null) { mVibrator = (android.os.Vibrator) getContext() .getSystemService(Context.VIBRATOR_SERVICE); } mVibrator.vibrate(duration, VIBRATION_ATTRIBUTES); } } /** * Draw the bitmap so that it's centered * on the point (x,y), then draws it using specified canvas. * TODO: is there already a utility method somewhere for this? */ private void drawCentered(Bitmap d, Canvas c, int x, int y) { int w = d.getWidth(); int h = d.getHeight(); c.drawBitmap(d, x - (w / 2), y - (h / 2), mPaint); } /** * Registers a callback to be invoked when the dial * is "triggered" by rotating it one way or the other. * * @param l the OnDialTriggerListener to attach to this view */ public void setOnDialTriggerListener(OnDialTriggerListener l) { mOnDialTriggerListener = l; } /** * Dispatches a trigger event to our listener. */ private void dispatchTriggerEvent(int whichHandle) { vibrate(VIBRATE_LONG); if (mOnDialTriggerListener != null) { mOnDialTriggerListener.onDialTrigger(this, whichHandle); } } /** * Sets the current grabbed state, and dispatches a grabbed state change * event to our listener. */ private void setGrabbedState(int newState) { if (newState != mGrabbedState) { mGrabbedState = newState; if (mOnDialTriggerListener != null) { mOnDialTriggerListener.onGrabbedStateChange(this, mGrabbedState); } } } /** * Interface definition for a callback to be invoked when the dial * is "triggered" by rotating it one way or the other. */ public interface OnDialTriggerListener { /** * The dial was triggered because the user grabbed the left handle, * and rotated the dial clockwise. */ public static final int LEFT_HANDLE = 1; /** * The dial was triggered because the user grabbed the right handle, * and rotated the dial counterclockwise. */ public static final int RIGHT_HANDLE = 2; /** * Called when the dial is triggered. * * @param v The view that was triggered * @param whichHandle Which "dial handle" the user grabbed, * either {@link #LEFT_HANDLE}, {@link #RIGHT_HANDLE}. */ void onDialTrigger(View v, int whichHandle); /** * Called when the "grabbed state" changes (i.e. when * the user either grabs or releases one of the handles.) * * @param v the view that was triggered * @param grabbedState the new state: either {@link #NOTHING_GRABBED}, * {@link #LEFT_HANDLE_GRABBED}, or {@link #RIGHT_HANDLE_GRABBED}. */ void onGrabbedStateChange(View v, int grabbedState); } // Debugging / testing code private void log(String msg) { Log.d(LOG_TAG, msg); } }