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