RotarySelector.java revision 5fef93b2a827cfafee04d7cfb827262c9b75fd91
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.graphics.Canvas;
22import android.graphics.drawable.Drawable;
23import android.os.Vibrator;
24import android.util.AttributeSet;
25import android.util.Log;
26import android.view.MotionEvent;
27import android.view.View;
28import android.view.animation.AccelerateInterpolator;
29import static android.view.animation.AnimationUtils.currentAnimationTimeMillis;
30import com.android.internal.R;
31
32
33/**
34 * Custom view that presents up to two items that are selectable by rotating a semi-circle from
35 * left to right, or right to left.  Used by incoming call screen, and the lock screen when no
36 * security pattern is set.
37 */
38public class RotarySelector extends View {
39    private static final String LOG_TAG = "RotarySelector";
40    private static final boolean DBG = false;
41
42    // Listener for onDialTrigger() callbacks.
43    private OnDialTriggerListener mOnDialTriggerListener;
44
45    private float mDensity;
46
47    // UI elements
48    private Drawable mBackground;
49    private Drawable mDimple;
50
51    private Drawable mLeftHandleIcon;
52    private Drawable mRightHandleIcon;
53
54    private Drawable mArrowShortLeftAndRight;
55    private Drawable mArrowLongLeft;  // Long arrow starting on the left, pointing clockwise
56    private Drawable mArrowLongRight;  // Long arrow starting on the right, pointing CCW
57
58    // positions of the left and right handle
59    private int mLeftHandleX;
60    private int mRightHandleX;
61
62    // current offset of user's dragging
63    private int mTouchDragOffset = 0;
64
65    // state of the animation used to bring the handle back to its start position when
66    // the user lets go before triggering an action
67    private boolean mAnimating = false;
68    private long mAnimationEndTime;
69    private int mAnimatingDelta;
70    private AccelerateInterpolator mInterpolator;
71
72    /**
73     * True after triggering an action if the user of {@link OnDialTriggerListener} wants to
74     * freeze the UI (until they transition to another screen).
75     */
76    private boolean mFrozen = false;
77
78    /**
79     * If the user is currently dragging something.
80     */
81    private int mGrabbedState = NOTHING_GRABBED;
82    private static final int NOTHING_GRABBED = 0;
83    private static final int LEFT_HANDLE_GRABBED = 1;
84    private static final int RIGHT_HANDLE_GRABBED = 2;
85
86    /**
87     * Whether the user has triggered something (e.g dragging the left handle all the way over to
88     * the right).
89     */
90    private boolean mTriggered = false;
91
92    // Vibration (haptic feedback)
93    private Vibrator mVibrator;
94    private static final long VIBRATE_SHORT = 60;  // msec
95    private static final long VIBRATE_LONG = 100;  // msec
96
97    /**
98     * The drawable for the arrows need to be scrunched this many dips towards the rotary bg below
99     * it.
100     */
101    private static final int ARROW_SCRUNCH_DIP = 6;
102
103    /**
104     * How far inset the left and right circles should be
105     */
106    private static final int EDGE_PADDING_DIP = 9;
107
108    /**
109     * How far from the edge of the screen the user must drag to trigger the event.
110     */
111    private static final int EDGE_TRIGGER_DIP = 65;
112
113    /**
114     * Dimensions of arc in background drawable.
115     */
116    static final int OUTER_ROTARY_RADIUS_DIP = 390;
117    static final int ROTARY_STROKE_WIDTH_DIP = 83;
118    private static final int ANIMATION_DURATION_MILLIS = 300;
119
120    private static final boolean DRAW_CENTER_DIMPLE = false;
121    private int mEdgeTriggerThresh;
122
123    public RotarySelector(Context context) {
124        this(context, null);
125    }
126
127    /**
128     * Constructor used when this widget is created from a layout file.
129     */
130    public RotarySelector(Context context, AttributeSet attrs) {
131        super(context, attrs);
132        if (DBG) log("IncomingCallDialWidget constructor...");
133
134        Resources r = getResources();
135        mDensity = r.getDisplayMetrics().density;
136        if (DBG) log("- Density: " + mDensity);
137
138        // Assets (all are BitmapDrawables).
139        mBackground = r.getDrawable(R.drawable.jog_dial_bg_cropped);
140        mDimple = r.getDrawable(R.drawable.jog_dial_dimple);
141
142        mArrowLongLeft = r.getDrawable(R.drawable.jog_dial_arrow_long_left_green);
143        mArrowLongRight = r.getDrawable(R.drawable.jog_dial_arrow_long_right_red);
144        mArrowShortLeftAndRight = r.getDrawable(R.drawable.jog_dial_arrow_short_left_and_right);
145
146        // Arrows:
147        // All arrow assets are the same size (they're the full width of
148        // the screen) regardless of which arrows are actually visible.
149        int arrowW = mArrowShortLeftAndRight.getIntrinsicWidth();
150        int arrowH = mArrowShortLeftAndRight.getIntrinsicHeight();
151        mArrowShortLeftAndRight.setBounds(0, 0, arrowW, arrowH);
152        mArrowLongLeft.setBounds(0, 0, arrowW, arrowH);
153        mArrowLongRight.setBounds(0, 0, arrowW, arrowH);
154
155        mInterpolator = new AccelerateInterpolator();
156
157        mEdgeTriggerThresh = (int) (mDensity * EDGE_TRIGGER_DIP);
158    }
159
160    /**
161     * Sets the left handle icon to a given resource.
162     *
163     * The resource should refer to a Drawable object, or use 0 to remove
164     * the icon.
165     *
166     * @param resId the resource ID.
167     */
168    public void setLeftHandleResource(int resId) {
169        Drawable d = null;
170        if (resId != 0) {
171            d = getResources().getDrawable(resId);
172        }
173        setLeftHandleDrawable(d);
174    }
175
176    /**
177     * Sets the left handle icon to a given Drawable.
178     *
179     * @param d the Drawable to use as the icon, or null to remove the icon.
180     */
181    public void setLeftHandleDrawable(Drawable d) {
182        mLeftHandleIcon = d;
183        invalidate();
184    }
185
186    /**
187     * Sets the right handle icon to a given resource.
188     *
189     * The resource should refer to a Drawable object, or use 0 to remove
190     * the icon.
191     *
192     * @param resId the resource ID.
193     */
194    public void setRightHandleResource(int resId) {
195        Drawable d = null;
196        if (resId != 0) {
197            d = getResources().getDrawable(resId);
198        }
199        setRightHandleDrawable(d);
200    }
201
202    /**
203     * Sets the right handle icon to a given Drawable.
204     *
205     * @param d the Drawable to use as the icon, or null to remove the icon.
206     */
207    public void setRightHandleDrawable(Drawable d) {
208        mRightHandleIcon = d;
209        invalidate();
210    }
211
212    @Override
213    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
214        final int width = MeasureSpec.getSize(widthMeasureSpec);  // screen width
215
216        final int arrowH = mArrowShortLeftAndRight.getIntrinsicHeight();
217        final int backgroundH = mBackground.getIntrinsicHeight();
218
219        // by making the height less than arrow + bg, arrow and bg will be scrunched together,
220        // overlaying somewhat (though on transparent portions of the drawable).
221        // this works because the arrows are drawn from the top, and the rotary bg is drawn
222        // from the bottom.
223        final int arrowScrunch = (int) (ARROW_SCRUNCH_DIP * mDensity);
224        setMeasuredDimension(width, backgroundH + arrowH - arrowScrunch);
225    }
226
227    @Override
228    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
229        super.onSizeChanged(w, h, oldw, oldh);
230
231        mLeftHandleX = (int) (EDGE_PADDING_DIP * mDensity) + mDimple.getIntrinsicWidth() / 2;
232        mRightHandleX =
233                getWidth() - (int) (EDGE_PADDING_DIP * mDensity) - mDimple.getIntrinsicWidth() / 2;
234    }
235
236//    private Paint mPaint = new Paint();
237
238    @Override
239    protected void onDraw(Canvas canvas) {
240        super.onDraw(canvas);
241        if (DBG) {
242            log(String.format("onDraw: mAnimating=%s, mTouchDragOffset=%d, mGrabbedState=%d," +
243                    "mFrozen=%s",
244                    mAnimating, mTouchDragOffset, mGrabbedState, mFrozen));
245        }
246
247        final int height = getHeight();
248
249        // update animating state before we draw anything
250        if (mAnimating && !mFrozen) {
251            long millisLeft = mAnimationEndTime - currentAnimationTimeMillis();
252            if (DBG) log("millisleft for animating: " + millisLeft);
253            if (millisLeft <= 0) {
254                reset();
255            } else {
256                float interpolation = mInterpolator.getInterpolation(
257                        (float) millisLeft / ANIMATION_DURATION_MILLIS);
258                mTouchDragOffset = (int) (mAnimatingDelta * interpolation);
259            }
260        }
261
262        // Background:
263        final int backgroundW = mBackground.getIntrinsicWidth();
264        final int backgroundH = mBackground.getIntrinsicHeight();
265        final int backgroundY = height - backgroundH;
266        if (DBG) log("- Background INTRINSIC: " + backgroundW + " x " + backgroundH);
267        mBackground.setBounds(0, backgroundY,
268                              backgroundW, backgroundY + backgroundH);
269        if (DBG) log("  Background BOUNDS: " + mBackground.getBounds());
270        mBackground.draw(canvas);
271
272
273        // Draw the correct arrow(s) depending on the current state:
274        Drawable currentArrow;
275        switch (mGrabbedState) {
276            case NOTHING_GRABBED:
277                currentArrow  = null; //mArrowShortLeftAndRight;
278                break;
279            case LEFT_HANDLE_GRABBED:
280                currentArrow = mArrowLongLeft;
281                break;
282            case RIGHT_HANDLE_GRABBED:
283                currentArrow = mArrowLongRight;
284                break;
285            default:
286                throw new IllegalStateException("invalid mGrabbedState: " + mGrabbedState);
287        }
288        if (currentArrow != null) currentArrow.draw(canvas);
289
290        // debug: draw circle that should match the outer arc (good sanity check)
291//        mPaint.setColor(Color.RED);
292//        mPaint.setStyle(Paint.Style.STROKE);
293//        float or = OUTER_ROTARY_RADIUS_DIP * mDensity;
294//        canvas.drawCircle(getWidth() / 2, or + mBackground.getBounds().top, or, mPaint);
295
296        final int outerRadius = (int) (mDensity * OUTER_ROTARY_RADIUS_DIP);
297        final int innerRadius =
298                (int) ((OUTER_ROTARY_RADIUS_DIP - ROTARY_STROKE_WIDTH_DIP) * mDensity);
299        final int bgTop = mBackground.getBounds().top;
300        {
301            final int xOffset = mLeftHandleX + mTouchDragOffset;
302            final int drawableY = getYOnArc(
303                    mBackground,
304                    innerRadius,
305                    outerRadius,
306                    xOffset);
307
308            drawCentered(mDimple, canvas, xOffset, drawableY + bgTop);
309            if (mGrabbedState != RIGHT_HANDLE_GRABBED) {
310                drawCentered(mLeftHandleIcon, canvas, xOffset, drawableY + bgTop);
311            }
312        }
313
314        if (DRAW_CENTER_DIMPLE) {
315            final int xOffset = getWidth() / 2 + mTouchDragOffset;
316            final int drawableY = getYOnArc(
317                    mBackground,
318                    innerRadius,
319                    outerRadius,
320                    xOffset);
321
322            drawCentered(mDimple, canvas, xOffset, drawableY + bgTop);
323        }
324
325        {
326            final int xOffset = mRightHandleX + mTouchDragOffset;
327            final int drawableY = getYOnArc(
328                    mBackground,
329                    innerRadius,
330                    outerRadius,
331                    xOffset);
332
333            drawCentered(mDimple, canvas, xOffset, drawableY + bgTop);
334            if (mGrabbedState != LEFT_HANDLE_GRABBED) {
335                drawCentered(mRightHandleIcon, canvas, xOffset, drawableY + bgTop);
336            }
337        }
338
339        if (mAnimating) invalidate();
340    }
341
342    /**
343     * Assuming drawable is a bounding box around a piece of an arc drawn by two concentric circles
344     * (as the background drawable for the rotary widget is), and given an x coordinate along the
345     * drawable, return the y coordinate of a point on the arc that is between the two concentric
346     * circles.  The resulting y combined with the incoming x is a point along the circle in
347     * between the two concentric circles.
348     *
349     * @param drawable The drawable.
350     * @param innerRadius The radius of the circle that intersects the drawable at the bottom two
351     *        corders of the drawable (top two corners in terms of drawing coordinates).
352     * @param outerRadius The radius of the circle who's top most point is the top center of the
353     *        drawable (bottom center in terms of drawing coordinates).
354     * @param x The distance along the x axis of the desired point.
355     * @return The y coordinate, in drawing coordinates, that will place (x, y) along the circle
356     *        in between the two concentric circles.
357     */
358    private int getYOnArc(Drawable drawable, int innerRadius, int outerRadius, int x) {
359
360        // the hypotenuse
361        final int halfWidth = (outerRadius - innerRadius) / 2;
362        final int middleRadius = innerRadius + halfWidth;
363
364        // the bottom leg of the triangle
365        final int triangleBottom = (drawable.getIntrinsicWidth() / 2) - x;
366
367        // "Our offense is like the pythagorean theorem: There is no answer!" - Shaquille O'Neal
368        final int triangleY =
369                (int) Math.sqrt(middleRadius * middleRadius - triangleBottom * triangleBottom);
370
371        // convert to drawing coordinates:
372        // middleRadius - triangleY =
373        //   the vertical distance from the outer edge of the circle to the desired point
374        // from there we add the distance from the top of the drawable to the middle circle
375        return middleRadius - triangleY + halfWidth;
376    }
377
378    /**
379     * Handle touch screen events.
380     *
381     * @param event The motion event.
382     * @return True if the event was handled, false otherwise.
383     */
384    @Override
385    public boolean onTouchEvent(MotionEvent event) {
386        if (mAnimating || mFrozen) {
387            return true;
388        }
389
390        final int eventX = (int) event.getX();
391        final int hitWindow = mDimple.getIntrinsicWidth();
392
393        final int action = event.getAction();
394        switch (action) {
395            case MotionEvent.ACTION_DOWN:
396                if (DBG) log("touch-down");
397                mTriggered = false;
398                if (mGrabbedState != NOTHING_GRABBED) {
399                    reset();
400                    invalidate();
401                }
402                if (eventX < mLeftHandleX + hitWindow) {
403                    mTouchDragOffset = eventX - mLeftHandleX;
404                    mGrabbedState = LEFT_HANDLE_GRABBED;
405                    invalidate();
406                    vibrate(VIBRATE_SHORT);
407                } else if (eventX > mRightHandleX - hitWindow) {
408                    mTouchDragOffset = eventX - mRightHandleX;
409                    mGrabbedState = RIGHT_HANDLE_GRABBED;
410                    invalidate();
411                    vibrate(VIBRATE_SHORT);
412                }
413                break;
414
415            case MotionEvent.ACTION_MOVE:
416                if (DBG) log("touch-move");
417                if (mGrabbedState == LEFT_HANDLE_GRABBED) {
418                    mTouchDragOffset = eventX - mLeftHandleX;
419                    invalidate();
420                    if (eventX >= getRight() - mEdgeTriggerThresh && !mTriggered) {
421                        mTriggered = true;
422                        mFrozen = dispatchTriggerEvent(OnDialTriggerListener.LEFT_HANDLE);
423                    }
424                } else if (mGrabbedState == RIGHT_HANDLE_GRABBED) {
425                    mTouchDragOffset = eventX - mRightHandleX;
426                    invalidate();
427                    if (eventX <= mEdgeTriggerThresh && !mTriggered) {
428                        mTriggered = true;
429                        mFrozen = dispatchTriggerEvent(OnDialTriggerListener.RIGHT_HANDLE);
430                    }
431                }
432                break;
433            case MotionEvent.ACTION_UP:
434                if (DBG) log("touch-up");
435                // handle animating back to start if they didn't trigger
436                if (mGrabbedState == LEFT_HANDLE_GRABBED
437                        && Math.abs(eventX - mLeftHandleX) > 5) {
438                    mAnimating = true;
439                    mAnimationEndTime = currentAnimationTimeMillis() + ANIMATION_DURATION_MILLIS;
440                    mAnimatingDelta = eventX - mLeftHandleX;
441                } else if (mGrabbedState == RIGHT_HANDLE_GRABBED
442                        && Math.abs(eventX - mRightHandleX) > 5) {
443                    mAnimating = true;
444                    mAnimationEndTime = currentAnimationTimeMillis() + ANIMATION_DURATION_MILLIS;
445                    mAnimatingDelta = eventX - mRightHandleX;
446                }
447
448                mTouchDragOffset = 0;
449                mGrabbedState = NOTHING_GRABBED;
450                invalidate();
451                break;
452            case MotionEvent.ACTION_CANCEL:
453                if (DBG) log("touch-cancel");
454                reset();
455                invalidate();
456                break;
457        }
458        return true;
459    }
460
461    private void reset() {
462        mAnimating = false;
463        mTouchDragOffset = 0;
464        mGrabbedState = NOTHING_GRABBED;
465        mTriggered = false;
466    }
467
468    /**
469     * Triggers haptic feedback.
470     */
471    private synchronized void vibrate(long duration) {
472        if (mVibrator == null) {
473            mVibrator = (android.os.Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE);
474        }
475        mVibrator.vibrate(duration);
476    }
477
478    /**
479     * Sets the bounds of the specified Drawable so that it's centered
480     * on the point (x,y), then draws it onto the specified canvas.
481     * TODO: is there already a utility method somewhere for this?
482     */
483    private static void drawCentered(Drawable d, Canvas c, int x, int y) {
484        int w = d.getIntrinsicWidth();
485        int h = d.getIntrinsicHeight();
486
487        // if (DBG) log("--> drawCentered: " + x + " , " + y + "; intrinsic " + w + " x " + h);
488        d.setBounds(x - (w / 2), y - (h / 2),
489                    x + (w / 2), y + (h / 2));
490        d.draw(c);
491    }
492
493
494    /**
495     * Registers a callback to be invoked when the dial
496     * is "triggered" by rotating it one way or the other.
497     *
498     * @param l the OnDialTriggerListener to attach to this view
499     */
500    public void setOnDialTriggerListener(OnDialTriggerListener l) {
501        mOnDialTriggerListener = l;
502    }
503
504    /**
505     * Dispatches a trigger event to our listener.
506     */
507    private boolean dispatchTriggerEvent(int whichHandle) {
508        vibrate(VIBRATE_LONG);
509        if (mOnDialTriggerListener != null) {
510            return mOnDialTriggerListener.onDialTrigger(this, whichHandle);
511        }
512        return false;
513    }
514
515    /**
516     * Interface definition for a callback to be invoked when the dial
517     * is "triggered" by rotating it one way or the other.
518     */
519    public interface OnDialTriggerListener {
520        /**
521         * The dial was triggered because the user grabbed the left handle,
522         * and rotated the dial clockwise.
523         */
524        public static final int LEFT_HANDLE = 1;
525
526        /**
527         * The dial was triggered because the user grabbed the right handle,
528         * and rotated the dial counterclockwise.
529         */
530        public static final int RIGHT_HANDLE = 2;
531
532        /**
533         * @hide
534         * The center handle is currently unused.
535         */
536        public static final int CENTER_HANDLE = 3;
537
538        /**
539         * Called when the dial is triggered.
540         *
541         * @param v The view that was triggered
542         * @param whichHandle  Which "dial handle" the user grabbed,
543         *        either {@link #LEFT_HANDLE}, {@link #RIGHT_HANDLE}, or
544         *        {@link #CENTER_HANDLE}.
545         * @return Whether the widget should freeze (e.g when the action goes to another screen,
546         *         you want the UI to stay put until the transition occurs).
547         */
548        boolean onDialTrigger(View v, int whichHandle);
549    }
550
551
552    // Debugging / testing code
553
554    private void log(String msg) {
555        Log.d(LOG_TAG, msg);
556    }
557}
558