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