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.content.res.TypedArray;
22import android.graphics.Canvas;
23import android.graphics.Paint;
24import android.graphics.Bitmap;
25import android.graphics.BitmapFactory;
26import android.graphics.Matrix;
27import android.graphics.drawable.Drawable;
28import android.os.UserHandle;
29import android.os.Vibrator;
30import android.provider.Settings;
31import android.util.AttributeSet;
32import android.util.Log;
33import android.view.MotionEvent;
34import android.view.View;
35import android.view.VelocityTracker;
36import android.view.ViewConfiguration;
37import android.view.animation.DecelerateInterpolator;
38import static android.view.animation.AnimationUtils.currentAnimationTimeMillis;
39import com.android.internal.R;
40
41
42/**
43 * Custom view that presents up to two items that are selectable by rotating a semi-circle from
44 * left to right, or right to left.  Used by incoming call screen, and the lock screen when no
45 * security pattern is set.
46 */
47public class RotarySelector extends View {
48    public static final int HORIZONTAL = 0;
49    public static final int VERTICAL = 1;
50
51    private static final String LOG_TAG = "RotarySelector";
52    private static final boolean DBG = false;
53    private static final boolean VISUAL_DEBUG = false;
54
55    // Listener for onDialTrigger() callbacks.
56    private OnDialTriggerListener mOnDialTriggerListener;
57
58    private float mDensity;
59
60    // UI elements
61    private Bitmap mBackground;
62    private Bitmap mDimple;
63    private Bitmap mDimpleDim;
64
65    private Bitmap mLeftHandleIcon;
66    private Bitmap mRightHandleIcon;
67
68    private Bitmap mArrowShortLeftAndRight;
69    private Bitmap mArrowLongLeft;  // Long arrow starting on the left, pointing clockwise
70    private Bitmap mArrowLongRight;  // Long arrow starting on the right, pointing CCW
71
72    // positions of the left and right handle
73    private int mLeftHandleX;
74    private int mRightHandleX;
75
76    // current offset of rotary widget along the x axis
77    private int mRotaryOffsetX = 0;
78
79    // state of the animation used to bring the handle back to its start position when
80    // the user lets go before triggering an action
81    private boolean mAnimating = false;
82    private long mAnimationStartTime;
83    private long mAnimationDuration;
84    private int mAnimatingDeltaXStart;   // the animation will interpolate from this delta to zero
85    private int mAnimatingDeltaXEnd;
86
87    private DecelerateInterpolator mInterpolator;
88
89    private Paint mPaint = new Paint();
90
91    // used to rotate the background and arrow assets depending on orientation
92    final Matrix mBgMatrix = new Matrix();
93    final Matrix mArrowMatrix = new Matrix();
94
95    /**
96     * If the user is currently dragging something.
97     */
98    private int mGrabbedState = NOTHING_GRABBED;
99    public static final int NOTHING_GRABBED = 0;
100    public static final int LEFT_HANDLE_GRABBED = 1;
101    public static final int RIGHT_HANDLE_GRABBED = 2;
102
103    /**
104     * Whether the user has triggered something (e.g dragging the left handle all the way over to
105     * the right).
106     */
107    private boolean mTriggered = false;
108
109    // Vibration (haptic feedback)
110    private Vibrator mVibrator;
111    private static final long VIBRATE_SHORT = 20;  // msec
112    private static final long VIBRATE_LONG = 20;  // msec
113
114    /**
115     * The drawable for the arrows need to be scrunched this many dips towards the rotary bg below
116     * it.
117     */
118    private static final int ARROW_SCRUNCH_DIP = 6;
119
120    /**
121     * How far inset the left and right circles should be
122     */
123    private static final int EDGE_PADDING_DIP = 9;
124
125    /**
126     * How far from the edge of the screen the user must drag to trigger the event.
127     */
128    private static final int EDGE_TRIGGER_DIP = 100;
129
130    /**
131     * Dimensions of arc in background drawable.
132     */
133    static final int OUTER_ROTARY_RADIUS_DIP = 390;
134    static final int ROTARY_STROKE_WIDTH_DIP = 83;
135    static final int SNAP_BACK_ANIMATION_DURATION_MILLIS = 300;
136    static final int SPIN_ANIMATION_DURATION_MILLIS = 800;
137
138    private int mEdgeTriggerThresh;
139    private int mDimpleWidth;
140    private int mBackgroundWidth;
141    private int mBackgroundHeight;
142    private final int mOuterRadius;
143    private final int mInnerRadius;
144    private int mDimpleSpacing;
145
146    private VelocityTracker mVelocityTracker;
147    private int mMinimumVelocity;
148    private int mMaximumVelocity;
149
150    /**
151     * The number of dimples we are flinging when we do the "spin" animation.  Used to know when to
152     * wrap the icons back around so they "rotate back" onto the screen.
153     * @see #updateAnimation()
154     */
155    private int mDimplesOfFling = 0;
156
157    /**
158     * Either {@link #HORIZONTAL} or {@link #VERTICAL}.
159     */
160    private int mOrientation;
161
162
163    public RotarySelector(Context context) {
164        this(context, null);
165    }
166
167    /**
168     * Constructor used when this widget is created from a layout file.
169     */
170    public RotarySelector(Context context, AttributeSet attrs) {
171        super(context, attrs);
172
173        TypedArray a =
174            context.obtainStyledAttributes(attrs, R.styleable.RotarySelector);
175        mOrientation = a.getInt(R.styleable.RotarySelector_orientation, HORIZONTAL);
176        a.recycle();
177
178        Resources r = getResources();
179        mDensity = r.getDisplayMetrics().density;
180        if (DBG) log("- Density: " + mDensity);
181
182        // Assets (all are BitmapDrawables).
183        mBackground = getBitmapFor(R.drawable.jog_dial_bg);
184        mDimple = getBitmapFor(R.drawable.jog_dial_dimple);
185        mDimpleDim = getBitmapFor(R.drawable.jog_dial_dimple_dim);
186
187        mArrowLongLeft = getBitmapFor(R.drawable.jog_dial_arrow_long_left_green);
188        mArrowLongRight = getBitmapFor(R.drawable.jog_dial_arrow_long_right_red);
189        mArrowShortLeftAndRight = getBitmapFor(R.drawable.jog_dial_arrow_short_left_and_right);
190
191        mInterpolator = new DecelerateInterpolator(1f);
192
193        mEdgeTriggerThresh = (int) (mDensity * EDGE_TRIGGER_DIP);
194
195        mDimpleWidth = mDimple.getWidth();
196
197        mBackgroundWidth = mBackground.getWidth();
198        mBackgroundHeight = mBackground.getHeight();
199        mOuterRadius = (int) (mDensity * OUTER_ROTARY_RADIUS_DIP);
200        mInnerRadius = (int) ((OUTER_ROTARY_RADIUS_DIP - ROTARY_STROKE_WIDTH_DIP) * mDensity);
201
202        final ViewConfiguration configuration = ViewConfiguration.get(mContext);
203        mMinimumVelocity = configuration.getScaledMinimumFlingVelocity() * 2;
204        mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
205    }
206
207    private Bitmap getBitmapFor(int resId) {
208        return BitmapFactory.decodeResource(getContext().getResources(), resId);
209    }
210
211    @Override
212    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
213        super.onSizeChanged(w, h, oldw, oldh);
214
215        final int edgePadding = (int) (EDGE_PADDING_DIP * mDensity);
216        mLeftHandleX = edgePadding + mDimpleWidth / 2;
217        final int length = isHoriz() ? w : h;
218        mRightHandleX = length - edgePadding - mDimpleWidth / 2;
219        mDimpleSpacing = (length / 2) - mLeftHandleX;
220
221        // bg matrix only needs to be calculated once
222        mBgMatrix.setTranslate(0, 0);
223        if (!isHoriz()) {
224            // set up matrix for translating drawing of background and arrow assets
225            final int left = w - mBackgroundHeight;
226            mBgMatrix.preRotate(-90, 0, 0);
227            mBgMatrix.postTranslate(left, h);
228
229        } else {
230            mBgMatrix.postTranslate(0, h - mBackgroundHeight);
231        }
232    }
233
234    private boolean isHoriz() {
235        return mOrientation == HORIZONTAL;
236    }
237
238    /**
239     * Sets the left handle icon to a given resource.
240     *
241     * The resource should refer to a Drawable object, or use 0 to remove
242     * the icon.
243     *
244     * @param resId the resource ID.
245     */
246    public void setLeftHandleResource(int resId) {
247        if (resId != 0) {
248            mLeftHandleIcon = getBitmapFor(resId);
249        }
250        invalidate();
251    }
252
253    /**
254     * Sets the right handle icon to a given resource.
255     *
256     * The resource should refer to a Drawable object, or use 0 to remove
257     * the icon.
258     *
259     * @param resId the resource ID.
260     */
261    public void setRightHandleResource(int resId) {
262        if (resId != 0) {
263            mRightHandleIcon = getBitmapFor(resId);
264        }
265        invalidate();
266    }
267
268
269    @Override
270    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
271        final int length = isHoriz() ?
272                MeasureSpec.getSize(widthMeasureSpec) :
273                MeasureSpec.getSize(heightMeasureSpec);
274        final int arrowScrunch = (int) (ARROW_SCRUNCH_DIP * mDensity);
275        final int arrowH = mArrowShortLeftAndRight.getHeight();
276
277        // by making the height less than arrow + bg, arrow and bg will be scrunched together,
278        // overlaying somewhat (though on transparent portions of the drawable).
279        // this works because the arrows are drawn from the top, and the rotary bg is drawn
280        // from the bottom.
281        final int height = mBackgroundHeight + arrowH - arrowScrunch;
282
283        if (isHoriz()) {
284            setMeasuredDimension(length, height);
285        } else {
286            setMeasuredDimension(height, length);
287        }
288    }
289
290    @Override
291    protected void onDraw(Canvas canvas) {
292        super.onDraw(canvas);
293
294        final int width = getWidth();
295
296        if (VISUAL_DEBUG) {
297            // draw bounding box around widget
298            mPaint.setColor(0xffff0000);
299            mPaint.setStyle(Paint.Style.STROKE);
300            canvas.drawRect(0, 0, width, getHeight(), mPaint);
301        }
302
303        final int height = getHeight();
304
305        // update animating state before we draw anything
306        if (mAnimating) {
307            updateAnimation();
308        }
309
310        // Background:
311        canvas.drawBitmap(mBackground, mBgMatrix, mPaint);
312
313        // Draw the correct arrow(s) depending on the current state:
314        mArrowMatrix.reset();
315        switch (mGrabbedState) {
316            case NOTHING_GRABBED:
317                //mArrowShortLeftAndRight;
318                break;
319            case LEFT_HANDLE_GRABBED:
320                mArrowMatrix.setTranslate(0, 0);
321                if (!isHoriz()) {
322                    mArrowMatrix.preRotate(-90, 0, 0);
323                    mArrowMatrix.postTranslate(0, height);
324                }
325                canvas.drawBitmap(mArrowLongLeft, mArrowMatrix, mPaint);
326                break;
327            case RIGHT_HANDLE_GRABBED:
328                mArrowMatrix.setTranslate(0, 0);
329                if (!isHoriz()) {
330                    mArrowMatrix.preRotate(-90, 0, 0);
331                    // since bg width is > height of screen in landscape mode...
332                    mArrowMatrix.postTranslate(0, height + (mBackgroundWidth - height));
333                }
334                canvas.drawBitmap(mArrowLongRight, mArrowMatrix, mPaint);
335                break;
336            default:
337                throw new IllegalStateException("invalid mGrabbedState: " + mGrabbedState);
338        }
339
340        final int bgHeight = mBackgroundHeight;
341        final int bgTop = isHoriz() ?
342                height - bgHeight:
343                width - bgHeight;
344
345        if (VISUAL_DEBUG) {
346            // draw circle bounding arc drawable: good sanity check we're doing the math correctly
347            float or = OUTER_ROTARY_RADIUS_DIP * mDensity;
348            final int vOffset = mBackgroundWidth - height;
349            final int midX = isHoriz() ? width / 2 : mBackgroundWidth / 2 - vOffset;
350            if (isHoriz()) {
351                canvas.drawCircle(midX, or + bgTop, or, mPaint);
352            } else {
353                canvas.drawCircle(or + bgTop, midX, or, mPaint);
354            }
355        }
356
357        // left dimple / icon
358        {
359            final int xOffset = mLeftHandleX + mRotaryOffsetX;
360            final int drawableY = getYOnArc(
361                    mBackgroundWidth,
362                    mInnerRadius,
363                    mOuterRadius,
364                    xOffset);
365            final int x = isHoriz() ? xOffset : drawableY + bgTop;
366            final int y = isHoriz() ? drawableY + bgTop : height - xOffset;
367            if (mGrabbedState != RIGHT_HANDLE_GRABBED) {
368                drawCentered(mDimple, canvas, x, y);
369                drawCentered(mLeftHandleIcon, canvas, x, y);
370            } else {
371                drawCentered(mDimpleDim, canvas, x, y);
372            }
373        }
374
375        // center dimple
376        {
377            final int xOffset = isHoriz() ?
378                    width / 2 + mRotaryOffsetX:
379                    height / 2 + mRotaryOffsetX;
380            final int drawableY = getYOnArc(
381                    mBackgroundWidth,
382                    mInnerRadius,
383                    mOuterRadius,
384                    xOffset);
385
386            if (isHoriz()) {
387                drawCentered(mDimpleDim, canvas, xOffset, drawableY + bgTop);
388            } else {
389                // vertical
390                drawCentered(mDimpleDim, canvas, drawableY + bgTop, height - xOffset);
391            }
392        }
393
394        // right dimple / icon
395        {
396            final int xOffset = mRightHandleX + mRotaryOffsetX;
397            final int drawableY = getYOnArc(
398                    mBackgroundWidth,
399                    mInnerRadius,
400                    mOuterRadius,
401                    xOffset);
402
403            final int x = isHoriz() ? xOffset : drawableY + bgTop;
404            final int y = isHoriz() ? drawableY + bgTop : height - xOffset;
405            if (mGrabbedState != LEFT_HANDLE_GRABBED) {
406                drawCentered(mDimple, canvas, x, y);
407                drawCentered(mRightHandleIcon, canvas, x, y);
408            } else {
409                drawCentered(mDimpleDim, canvas, x, y);
410            }
411        }
412
413        // draw extra left hand dimples
414        int dimpleLeft = mRotaryOffsetX + mLeftHandleX - mDimpleSpacing;
415        final int halfdimple = mDimpleWidth / 2;
416        while (dimpleLeft > -halfdimple) {
417            final int drawableY = getYOnArc(
418                    mBackgroundWidth,
419                    mInnerRadius,
420                    mOuterRadius,
421                    dimpleLeft);
422
423            if (isHoriz()) {
424                drawCentered(mDimpleDim, canvas, dimpleLeft, drawableY + bgTop);
425            } else {
426                drawCentered(mDimpleDim, canvas, drawableY + bgTop, height - dimpleLeft);
427            }
428            dimpleLeft -= mDimpleSpacing;
429        }
430
431        // draw extra right hand dimples
432        int dimpleRight = mRotaryOffsetX + mRightHandleX + mDimpleSpacing;
433        final int rightThresh = mRight + halfdimple;
434        while (dimpleRight < rightThresh) {
435            final int drawableY = getYOnArc(
436                    mBackgroundWidth,
437                    mInnerRadius,
438                    mOuterRadius,
439                    dimpleRight);
440
441            if (isHoriz()) {
442                drawCentered(mDimpleDim, canvas, dimpleRight, drawableY + bgTop);
443            } else {
444                drawCentered(mDimpleDim, canvas, drawableY + bgTop, height - dimpleRight);
445            }
446            dimpleRight += mDimpleSpacing;
447        }
448    }
449
450    /**
451     * Assuming bitmap is a bounding box around a piece of an arc drawn by two concentric circles
452     * (as the background drawable for the rotary widget is), and given an x coordinate along the
453     * drawable, return the y coordinate of a point on the arc that is between the two concentric
454     * circles.  The resulting y combined with the incoming x is a point along the circle in
455     * between the two concentric circles.
456     *
457     * @param backgroundWidth The width of the asset (the bottom of the box surrounding the arc).
458     * @param innerRadius The radius of the circle that intersects the drawable at the bottom two
459     *        corders of the drawable (top two corners in terms of drawing coordinates).
460     * @param outerRadius The radius of the circle who's top most point is the top center of the
461     *        drawable (bottom center in terms of drawing coordinates).
462     * @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
463     *        in between the two concentric circles.
464     */
465    private int getYOnArc(int backgroundWidth, int innerRadius, int outerRadius, int x) {
466
467        // the hypotenuse
468        final int halfWidth = (outerRadius - innerRadius) / 2;
469        final int middleRadius = innerRadius + halfWidth;
470
471        // the bottom leg of the triangle
472        final int triangleBottom = (backgroundWidth / 2) - x;
473
474        // "Our offense is like the pythagorean theorem: There is no answer!" - Shaquille O'Neal
475        final int triangleY =
476                (int) Math.sqrt(middleRadius * middleRadius - triangleBottom * triangleBottom);
477
478        // convert to drawing coordinates:
479        // middleRadius - triangleY =
480        //   the vertical distance from the outer edge of the circle to the desired point
481        // from there we add the distance from the top of the drawable to the middle circle
482        return middleRadius - triangleY + halfWidth;
483    }
484
485    /**
486     * Handle touch screen events.
487     *
488     * @param event The motion event.
489     * @return True if the event was handled, false otherwise.
490     */
491    @Override
492    public boolean onTouchEvent(MotionEvent event) {
493        if (mAnimating) {
494            return true;
495        }
496        if (mVelocityTracker == null) {
497            mVelocityTracker = VelocityTracker.obtain();
498        }
499        mVelocityTracker.addMovement(event);
500
501        final int height = getHeight();
502
503        final int eventX = isHoriz() ?
504                (int) event.getX():
505                height - ((int) event.getY());
506        final int hitWindow = mDimpleWidth;
507
508        final int action = event.getAction();
509        switch (action) {
510            case MotionEvent.ACTION_DOWN:
511                if (DBG) log("touch-down");
512                mTriggered = false;
513                if (mGrabbedState != NOTHING_GRABBED) {
514                    reset();
515                    invalidate();
516                }
517                if (eventX < mLeftHandleX + hitWindow) {
518                    mRotaryOffsetX = eventX - mLeftHandleX;
519                    setGrabbedState(LEFT_HANDLE_GRABBED);
520                    invalidate();
521                    vibrate(VIBRATE_SHORT);
522                } else if (eventX > mRightHandleX - hitWindow) {
523                    mRotaryOffsetX = eventX - mRightHandleX;
524                    setGrabbedState(RIGHT_HANDLE_GRABBED);
525                    invalidate();
526                    vibrate(VIBRATE_SHORT);
527                }
528                break;
529
530            case MotionEvent.ACTION_MOVE:
531                if (DBG) log("touch-move");
532                if (mGrabbedState == LEFT_HANDLE_GRABBED) {
533                    mRotaryOffsetX = eventX - mLeftHandleX;
534                    invalidate();
535                    final int rightThresh = isHoriz() ? getRight() : height;
536                    if (eventX >= rightThresh - mEdgeTriggerThresh && !mTriggered) {
537                        mTriggered = true;
538                        dispatchTriggerEvent(OnDialTriggerListener.LEFT_HANDLE);
539                        final VelocityTracker velocityTracker = mVelocityTracker;
540                        velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
541                        final int rawVelocity = isHoriz() ?
542                                (int) velocityTracker.getXVelocity():
543                                -(int) velocityTracker.getYVelocity();
544                        final int velocity = Math.max(mMinimumVelocity, rawVelocity);
545                        mDimplesOfFling = Math.max(
546                                8,
547                                Math.abs(velocity / mDimpleSpacing));
548                        startAnimationWithVelocity(
549                                eventX - mLeftHandleX,
550                                mDimplesOfFling * mDimpleSpacing,
551                                velocity);
552                    }
553                } else if (mGrabbedState == RIGHT_HANDLE_GRABBED) {
554                    mRotaryOffsetX = eventX - mRightHandleX;
555                    invalidate();
556                    if (eventX <= mEdgeTriggerThresh && !mTriggered) {
557                        mTriggered = true;
558                        dispatchTriggerEvent(OnDialTriggerListener.RIGHT_HANDLE);
559                        final VelocityTracker velocityTracker = mVelocityTracker;
560                        velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
561                        final int rawVelocity = isHoriz() ?
562                                (int) velocityTracker.getXVelocity():
563                                - (int) velocityTracker.getYVelocity();
564                        final int velocity = Math.min(-mMinimumVelocity, rawVelocity);
565                        mDimplesOfFling = Math.max(
566                                8,
567                                Math.abs(velocity / mDimpleSpacing));
568                        startAnimationWithVelocity(
569                                eventX - mRightHandleX,
570                                -(mDimplesOfFling * mDimpleSpacing),
571                                velocity);
572                    }
573                }
574                break;
575            case MotionEvent.ACTION_UP:
576                if (DBG) log("touch-up");
577                // handle animating back to start if they didn't trigger
578                if (mGrabbedState == LEFT_HANDLE_GRABBED
579                        && Math.abs(eventX - mLeftHandleX) > 5) {
580                    // set up "snap back" animation
581                    startAnimation(eventX - mLeftHandleX, 0, SNAP_BACK_ANIMATION_DURATION_MILLIS);
582                } else if (mGrabbedState == RIGHT_HANDLE_GRABBED
583                        && Math.abs(eventX - mRightHandleX) > 5) {
584                    // set up "snap back" animation
585                    startAnimation(eventX - mRightHandleX, 0, SNAP_BACK_ANIMATION_DURATION_MILLIS);
586                }
587                mRotaryOffsetX = 0;
588                setGrabbedState(NOTHING_GRABBED);
589                invalidate();
590                if (mVelocityTracker != null) {
591                    mVelocityTracker.recycle(); // wishin' we had generational GC
592                    mVelocityTracker = null;
593                }
594                break;
595            case MotionEvent.ACTION_CANCEL:
596                if (DBG) log("touch-cancel");
597                reset();
598                invalidate();
599                if (mVelocityTracker != null) {
600                    mVelocityTracker.recycle();
601                    mVelocityTracker = null;
602                }
603                break;
604        }
605        return true;
606    }
607
608    private void startAnimation(int startX, int endX, int duration) {
609        mAnimating = true;
610        mAnimationStartTime = currentAnimationTimeMillis();
611        mAnimationDuration = duration;
612        mAnimatingDeltaXStart = startX;
613        mAnimatingDeltaXEnd = endX;
614        setGrabbedState(NOTHING_GRABBED);
615        mDimplesOfFling = 0;
616        invalidate();
617    }
618
619    private void startAnimationWithVelocity(int startX, int endX, int pixelsPerSecond) {
620        mAnimating = true;
621        mAnimationStartTime = currentAnimationTimeMillis();
622        mAnimationDuration = 1000 * (endX - startX) / pixelsPerSecond;
623        mAnimatingDeltaXStart = startX;
624        mAnimatingDeltaXEnd = endX;
625        setGrabbedState(NOTHING_GRABBED);
626        invalidate();
627    }
628
629    private void updateAnimation() {
630        final long millisSoFar = currentAnimationTimeMillis() - mAnimationStartTime;
631        final long millisLeft = mAnimationDuration - millisSoFar;
632        final int totalDeltaX = mAnimatingDeltaXStart - mAnimatingDeltaXEnd;
633        final boolean goingRight = totalDeltaX < 0;
634        if (DBG) log("millisleft for animating: " + millisLeft);
635        if (millisLeft <= 0) {
636            reset();
637            return;
638        }
639        // from 0 to 1 as animation progresses
640        float interpolation =
641                mInterpolator.getInterpolation((float) millisSoFar / mAnimationDuration);
642        final int dx = (int) (totalDeltaX * (1 - interpolation));
643        mRotaryOffsetX = mAnimatingDeltaXEnd + dx;
644
645        // once we have gone far enough to animate the current buttons off screen, we start
646        // wrapping the offset back to the other side so that when the animation is finished,
647        // the buttons will come back into their original places.
648        if (mDimplesOfFling > 0) {
649            if (!goingRight && mRotaryOffsetX < -3 * mDimpleSpacing) {
650                // wrap around on fling left
651                mRotaryOffsetX += mDimplesOfFling * mDimpleSpacing;
652            } else if (goingRight && mRotaryOffsetX > 3 * mDimpleSpacing) {
653                // wrap around on fling right
654                mRotaryOffsetX -= mDimplesOfFling * mDimpleSpacing;
655            }
656        }
657        invalidate();
658    }
659
660    private void reset() {
661        mAnimating = false;
662        mRotaryOffsetX = 0;
663        mDimplesOfFling = 0;
664        setGrabbedState(NOTHING_GRABBED);
665        mTriggered = false;
666    }
667
668    /**
669     * Triggers haptic feedback.
670     */
671    private synchronized void vibrate(long duration) {
672        final boolean hapticEnabled = Settings.System.getIntForUser(
673                mContext.getContentResolver(), Settings.System.HAPTIC_FEEDBACK_ENABLED, 1,
674                UserHandle.USER_CURRENT) != 0;
675        if (hapticEnabled) {
676            if (mVibrator == null) {
677                mVibrator = (android.os.Vibrator) getContext()
678                        .getSystemService(Context.VIBRATOR_SERVICE);
679            }
680            mVibrator.vibrate(duration);
681        }
682    }
683
684    /**
685     * Draw the bitmap so that it's centered
686     * on the point (x,y), then draws it using specified canvas.
687     * TODO: is there already a utility method somewhere for this?
688     */
689    private void drawCentered(Bitmap d, Canvas c, int x, int y) {
690        int w = d.getWidth();
691        int h = d.getHeight();
692
693        c.drawBitmap(d, x - (w / 2), y - (h / 2), mPaint);
694    }
695
696
697    /**
698     * Registers a callback to be invoked when the dial
699     * is "triggered" by rotating it one way or the other.
700     *
701     * @param l the OnDialTriggerListener to attach to this view
702     */
703    public void setOnDialTriggerListener(OnDialTriggerListener l) {
704        mOnDialTriggerListener = l;
705    }
706
707    /**
708     * Dispatches a trigger event to our listener.
709     */
710    private void dispatchTriggerEvent(int whichHandle) {
711        vibrate(VIBRATE_LONG);
712        if (mOnDialTriggerListener != null) {
713            mOnDialTriggerListener.onDialTrigger(this, whichHandle);
714        }
715    }
716
717    /**
718     * Sets the current grabbed state, and dispatches a grabbed state change
719     * event to our listener.
720     */
721    private void setGrabbedState(int newState) {
722        if (newState != mGrabbedState) {
723            mGrabbedState = newState;
724            if (mOnDialTriggerListener != null) {
725                mOnDialTriggerListener.onGrabbedStateChange(this, mGrabbedState);
726            }
727        }
728    }
729
730    /**
731     * Interface definition for a callback to be invoked when the dial
732     * is "triggered" by rotating it one way or the other.
733     */
734    public interface OnDialTriggerListener {
735        /**
736         * The dial was triggered because the user grabbed the left handle,
737         * and rotated the dial clockwise.
738         */
739        public static final int LEFT_HANDLE = 1;
740
741        /**
742         * The dial was triggered because the user grabbed the right handle,
743         * and rotated the dial counterclockwise.
744         */
745        public static final int RIGHT_HANDLE = 2;
746
747        /**
748         * Called when the dial is triggered.
749         *
750         * @param v The view that was triggered
751         * @param whichHandle  Which "dial handle" the user grabbed,
752         *        either {@link #LEFT_HANDLE}, {@link #RIGHT_HANDLE}.
753         */
754        void onDialTrigger(View v, int whichHandle);
755
756        /**
757         * Called when the "grabbed state" changes (i.e. when
758         * the user either grabs or releases one of the handles.)
759         *
760         * @param v the view that was triggered
761         * @param grabbedState the new state: either {@link #NOTHING_GRABBED},
762         * {@link #LEFT_HANDLE_GRABBED}, or {@link #RIGHT_HANDLE_GRABBED}.
763         */
764        void onGrabbedStateChange(View v, int grabbedState);
765    }
766
767
768    // Debugging / testing code
769
770    private void log(String msg) {
771        Log.d(LOG_TAG, msg);
772    }
773}
774