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