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