MultiWaveView.java revision 72b26c1fa25077b1f3367eb211be20b629f7b1d4
1/*
2 * Copyright (C) 2011 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.multiwaveview;
18
19import android.animation.Animator;
20import android.animation.Animator.AnimatorListener;
21import android.animation.AnimatorListenerAdapter;
22import android.animation.TimeInterpolator;
23import android.animation.ValueAnimator;
24import android.animation.ValueAnimator.AnimatorUpdateListener;
25import android.content.Context;
26import android.content.res.Resources;
27import android.content.res.TypedArray;
28import android.graphics.Canvas;
29import android.graphics.RectF;
30import android.os.Vibrator;
31import android.text.TextUtils;
32import android.util.AttributeSet;
33import android.util.Log;
34import android.util.TypedValue;
35import android.view.Gravity;
36import android.view.MotionEvent;
37import android.view.View;
38import android.view.accessibility.AccessibilityEvent;
39import android.view.accessibility.AccessibilityManager;
40
41import com.android.internal.R;
42
43import java.util.ArrayList;
44
45/**
46 * A special widget containing a center and outer ring. Moving the center ring to the outer ring
47 * causes an event that can be caught by implementing OnTriggerListener.
48 */
49public class MultiWaveView extends View {
50    private static final String TAG = "MultiWaveView";
51    private static final boolean DEBUG = false;
52
53    // Wave state machine
54    private static final int STATE_IDLE = 0;
55    private static final int STATE_FIRST_TOUCH = 1;
56    private static final int STATE_TRACKING = 2;
57    private static final int STATE_SNAP = 3;
58    private static final int STATE_FINISH = 4;
59
60    // Animation properties.
61    private static final float SNAP_MARGIN_DEFAULT = 20.0f; // distance to ring before we snap to it
62
63    public interface OnTriggerListener {
64        int NO_HANDLE = 0;
65        int CENTER_HANDLE = 1;
66        public void onGrabbed(View v, int handle);
67        public void onReleased(View v, int handle);
68        public void onTrigger(View v, int target);
69        public void onGrabbedStateChange(View v, int handle);
70    }
71
72    // Tune-able parameters
73    private static final int CHEVRON_INCREMENTAL_DELAY = 160;
74    private static final int CHEVRON_ANIMATION_DURATION = 850;
75    private static final int RETURN_TO_HOME_DELAY = 1200;
76    private static final int RETURN_TO_HOME_DURATION = 300;
77    private static final int HIDE_ANIMATION_DELAY = 200;
78    private static final int HIDE_ANIMATION_DURATION = RETURN_TO_HOME_DELAY;
79    private static final int SHOW_ANIMATION_DURATION = 0;
80    private static final int SHOW_ANIMATION_DELAY = 0;
81    private static final float TAP_RADIUS_SCALE_ACCESSIBILITY_ENABLED = 1.3f;
82    private TimeInterpolator mChevronAnimationInterpolator = Ease.Quad.easeOut;
83
84    private ArrayList<TargetDrawable> mTargetDrawables = new ArrayList<TargetDrawable>();
85    private ArrayList<TargetDrawable> mChevronDrawables = new ArrayList<TargetDrawable>();
86    private ArrayList<Tweener> mChevronAnimations = new ArrayList<Tweener>();
87    private ArrayList<Tweener> mTargetAnimations = new ArrayList<Tweener>();
88    private ArrayList<String> mTargetDescriptions;
89    private ArrayList<String> mDirectionDescriptions;
90    private Tweener mHandleAnimation;
91    private OnTriggerListener mOnTriggerListener;
92    private TargetDrawable mHandleDrawable;
93    private TargetDrawable mOuterRing;
94    private Vibrator mVibrator;
95
96    private int mFeedbackCount = 3;
97    private int mVibrationDuration = 0;
98    private int mGrabbedState;
99    private int mActiveTarget = -1;
100    private float mTapRadius;
101    private float mWaveCenterX;
102    private float mWaveCenterY;
103    private int mMaxTargetHeight;
104    private int mMaxTargetWidth;
105    private float mHorizontalOffset;
106    private float mVerticalOffset;
107
108    private float mOuterRadius = 0.0f;
109    private float mHitRadius = 0.0f;
110    private float mSnapMargin = 0.0f;
111    private boolean mDragging;
112    private int mNewTargetResources;
113
114    private AnimatorListener mResetListener = new AnimatorListenerAdapter() {
115        public void onAnimationEnd(Animator animator) {
116            switchToState(STATE_IDLE, mWaveCenterX, mWaveCenterY);
117        }
118    };
119
120    private AnimatorListener mResetListenerWithPing = new AnimatorListenerAdapter() {
121        public void onAnimationEnd(Animator animator) {
122            ping();
123            switchToState(STATE_IDLE, mWaveCenterX, mWaveCenterY);
124        }
125    };
126
127    private AnimatorUpdateListener mUpdateListener = new AnimatorUpdateListener() {
128        public void onAnimationUpdate(ValueAnimator animation) {
129            invalidateGlobalRegion(mHandleDrawable);
130            invalidate();
131        }
132    };
133
134    private boolean mAnimatingTargets;
135    private AnimatorListener mTargetUpdateListener = new AnimatorListenerAdapter() {
136        public void onAnimationEnd(Animator animator) {
137            if (mNewTargetResources != 0) {
138                internalSetTargetResources(mNewTargetResources);
139                mNewTargetResources = 0;
140                hideTargets(false);
141            }
142            mAnimatingTargets = false;
143        }
144    };
145    private int mTargetResourceId;
146    private int mTargetDescriptionsResourceId;
147    private int mDirectionDescriptionsResourceId;
148    private boolean mAlwaysTrackFinger;
149    private int mHorizontalInset;
150    private int mVerticalInset;
151    private int mGravity = Gravity.TOP;
152
153    public MultiWaveView(Context context) {
154        this(context, null);
155    }
156
157    public MultiWaveView(Context context, AttributeSet attrs) {
158        super(context, attrs);
159        Resources res = context.getResources();
160
161        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MultiWaveView);
162        mOuterRadius = a.getDimension(R.styleable.MultiWaveView_outerRadius, mOuterRadius);
163//        mHorizontalOffset = a.getDimension(R.styleable.MultiWaveView_horizontalOffset,
164//                mHorizontalOffset);
165//        mVerticalOffset = a.getDimension(R.styleable.MultiWaveView_verticalOffset, mVerticalOffset);
166        mHitRadius = a.getDimension(R.styleable.MultiWaveView_hitRadius, mHitRadius);
167        mSnapMargin = a.getDimension(R.styleable.MultiWaveView_snapMargin, mSnapMargin);
168        mVibrationDuration = a.getInt(R.styleable.MultiWaveView_vibrationDuration,
169                mVibrationDuration);
170        mFeedbackCount = a.getInt(R.styleable.MultiWaveView_feedbackCount,
171                mFeedbackCount);
172        mHandleDrawable = new TargetDrawable(res,
173                a.peekValue(R.styleable.MultiWaveView_handleDrawable).resourceId);
174        mTapRadius = mHandleDrawable.getWidth()/2;
175        mOuterRing = new TargetDrawable(res,
176                a.peekValue(R.styleable.MultiWaveView_waveDrawable).resourceId);
177        mAlwaysTrackFinger = a.getBoolean(R.styleable.MultiWaveView_alwaysTrackFinger, false);
178        mGravity = a.getInt(R.styleable.MultiWaveView_gravity, Gravity.TOP);
179
180        // Read chevron animation drawables
181        final int chevrons[] = { R.styleable.MultiWaveView_leftChevronDrawable,
182                R.styleable.MultiWaveView_rightChevronDrawable,
183                R.styleable.MultiWaveView_topChevronDrawable,
184                R.styleable.MultiWaveView_bottomChevronDrawable
185        };
186
187        for (int chevron : chevrons) {
188            TypedValue typedValue = a.peekValue(chevron);
189            for (int i = 0; i < mFeedbackCount; i++) {
190                mChevronDrawables.add(
191                    typedValue != null ? new TargetDrawable(res, typedValue.resourceId) : null);
192            }
193        }
194
195        // Read array of target drawables
196        TypedValue outValue = new TypedValue();
197        if (a.getValue(R.styleable.MultiWaveView_targetDrawables, outValue)) {
198            internalSetTargetResources(outValue.resourceId);
199        }
200        if (mTargetDrawables == null || mTargetDrawables.size() == 0) {
201            throw new IllegalStateException("Must specify at least one target drawable");
202        }
203
204        // Read array of target descriptions
205        if (a.getValue(R.styleable.MultiWaveView_targetDescriptions, outValue)) {
206            final int resourceId = outValue.resourceId;
207            if (resourceId == 0) {
208                throw new IllegalStateException("Must specify target descriptions");
209            }
210            setTargetDescriptionsResourceId(resourceId);
211        }
212
213        // Read array of direction descriptions
214        if (a.getValue(R.styleable.MultiWaveView_directionDescriptions, outValue)) {
215            final int resourceId = outValue.resourceId;
216            if (resourceId == 0) {
217                throw new IllegalStateException("Must specify direction descriptions");
218            }
219            setDirectionDescriptionsResourceId(resourceId);
220        }
221
222        a.recycle();
223        setVibrateEnabled(mVibrationDuration > 0);
224    }
225
226    private void dump() {
227        Log.v(TAG, "Outer Radius = " + mOuterRadius);
228        Log.v(TAG, "HitRadius = " + mHitRadius);
229        Log.v(TAG, "SnapMargin = " + mSnapMargin);
230        Log.v(TAG, "FeedbackCount = " + mFeedbackCount);
231        Log.v(TAG, "VibrationDuration = " + mVibrationDuration);
232        Log.v(TAG, "TapRadius = " + mTapRadius);
233        Log.v(TAG, "WaveCenterX = " + mWaveCenterX);
234        Log.v(TAG, "WaveCenterY = " + mWaveCenterY);
235        Log.v(TAG, "HorizontalOffset = " + mHorizontalOffset);
236        Log.v(TAG, "VerticalOffset = " + mVerticalOffset);
237    }
238
239    @Override
240    protected int getSuggestedMinimumWidth() {
241        // View should be large enough to contain the background + handle and
242        // target drawable on either edge.
243        return mOuterRing.getWidth() + mMaxTargetWidth;
244    }
245
246    @Override
247    protected int getSuggestedMinimumHeight() {
248        // View should be large enough to contain the unlock ring + target and
249        // target drawable on either edge
250        return mOuterRing.getHeight() + mMaxTargetHeight;
251    }
252
253    private int resolveMeasured(int measureSpec, int desired)
254    {
255        int result = 0;
256        int specSize = MeasureSpec.getSize(measureSpec);
257        switch (MeasureSpec.getMode(measureSpec)) {
258            case MeasureSpec.UNSPECIFIED:
259                result = desired;
260                break;
261            case MeasureSpec.AT_MOST:
262                result = Math.min(specSize, desired);
263                break;
264            case MeasureSpec.EXACTLY:
265            default:
266                result = specSize;
267        }
268        return result;
269    }
270
271    @Override
272    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
273        final int minimumWidth = getSuggestedMinimumWidth();
274        final int minimumHeight = getSuggestedMinimumHeight();
275        int computedWidth = resolveMeasured(widthMeasureSpec, minimumWidth);
276        int computedHeight = resolveMeasured(heightMeasureSpec, minimumHeight);
277        setupGravity((computedWidth - minimumWidth), (computedHeight - minimumHeight));
278        setMeasuredDimension(computedWidth, computedHeight);
279    }
280
281    private void switchToState(int state, float x, float y) {
282        switch (state) {
283            case STATE_IDLE:
284                deactivateTargets();
285                mHandleDrawable.setState(TargetDrawable.STATE_INACTIVE);
286                break;
287
288            case STATE_FIRST_TOUCH:
289                stopHandleAnimation();
290                deactivateTargets();
291                showTargets(true);
292                mHandleDrawable.setState(TargetDrawable.STATE_ACTIVE);
293                setGrabbedState(OnTriggerListener.CENTER_HANDLE);
294                if (AccessibilityManager.getInstance(mContext).isEnabled()) {
295                    announceTargets();
296                }
297                break;
298
299            case STATE_TRACKING:
300                break;
301
302            case STATE_SNAP:
303                break;
304
305            case STATE_FINISH:
306                doFinish();
307                break;
308        }
309    }
310
311    /**
312     * Animation used to attract user's attention to the target button.
313     * Assumes mChevronDrawables is an a list with an even number of chevrons filled with
314     * mFeedbackCount items in the order: left, right, top, bottom.
315     */
316    private void startChevronAnimation() {
317        final float r = mHandleDrawable.getWidth() * 0.4f;
318        final float chevronAnimationDistance = mOuterRadius * 0.9f;
319        final float from[][] = {
320                {mWaveCenterX - r, mWaveCenterY},  // left
321                {mWaveCenterX + r, mWaveCenterY},  // right
322                {mWaveCenterX, mWaveCenterY - r},  // top
323                {mWaveCenterX, mWaveCenterY + r} }; // bottom
324        final float to[][] = {
325                {mWaveCenterX - chevronAnimationDistance, mWaveCenterY},  // left
326                {mWaveCenterX + chevronAnimationDistance, mWaveCenterY},  // right
327                {mWaveCenterX, mWaveCenterY - chevronAnimationDistance},  // top
328                {mWaveCenterX, mWaveCenterY + chevronAnimationDistance} }; // bottom
329
330        mChevronAnimations.clear();
331        final float startScale = 0.5f;
332        final float endScale = 2.0f;
333        for (int direction = 0; direction < 4; direction++) {
334            for (int count = 0; count < mFeedbackCount; count++) {
335                int delay = count * CHEVRON_INCREMENTAL_DELAY;
336                final TargetDrawable icon = mChevronDrawables.get(direction*mFeedbackCount + count);
337                if (icon == null) {
338                    continue;
339                }
340                mChevronAnimations.add(Tweener.to(icon, CHEVRON_ANIMATION_DURATION,
341                        "ease", mChevronAnimationInterpolator,
342                        "delay", delay,
343                        "x", new float[] { from[direction][0], to[direction][0] },
344                        "y", new float[] { from[direction][1], to[direction][1] },
345                        "alpha", new float[] {1.0f, 0.0f},
346                        "scaleX", new float[] {startScale, endScale},
347                        "scaleY", new float[] {startScale, endScale},
348                        "onUpdate", mUpdateListener));
349            }
350        }
351    }
352
353    private void stopChevronAnimation() {
354        for (Tweener anim : mChevronAnimations) {
355            anim.animator.end();
356        }
357        mChevronAnimations.clear();
358    }
359
360    private void stopHandleAnimation() {
361        if (mHandleAnimation != null) {
362            mHandleAnimation.animator.end();
363            mHandleAnimation = null;
364        }
365    }
366
367    private void deactivateTargets() {
368        for (TargetDrawable target : mTargetDrawables) {
369            target.setState(TargetDrawable.STATE_INACTIVE);
370        }
371        mActiveTarget = -1;
372    }
373
374    void invalidateGlobalRegion(TargetDrawable drawable) {
375        int width = drawable.getWidth();
376        int height = drawable.getHeight();
377        RectF childBounds = new RectF(0, 0, width, height);
378        childBounds.offset(drawable.getX() - width/2, drawable.getY() - height/2);
379        View view = this;
380        while (view.getParent() != null && view.getParent() instanceof View) {
381            view = (View) view.getParent();
382            view.getMatrix().mapRect(childBounds);
383            view.invalidate((int) Math.floor(childBounds.left),
384                    (int) Math.floor(childBounds.top),
385                    (int) Math.ceil(childBounds.right),
386                    (int) Math.ceil(childBounds.bottom));
387        }
388    }
389
390    /**
391     * Dispatches a trigger event to listener. Ignored if a listener is not set.
392     * @param whichHandle the handle that triggered the event.
393     */
394    private void dispatchTriggerEvent(int whichHandle) {
395        vibrate();
396        if (mOnTriggerListener != null) {
397            mOnTriggerListener.onTrigger(this, whichHandle);
398        }
399    }
400
401    private void dispatchGrabbedEvent(int whichHandler) {
402        vibrate();
403        if (mOnTriggerListener != null) {
404            mOnTriggerListener.onGrabbed(this, whichHandler);
405        }
406    }
407
408    private void doFinish() {
409        final int activeTarget = mActiveTarget;
410        boolean targetHit =  activeTarget != -1;
411
412        // Hide unselected targets
413        hideTargets(true);
414
415        // Highlight the selected one
416        mHandleDrawable.setAlpha(targetHit ? 0.0f : 1.0f);
417        if (targetHit) {
418            mTargetDrawables.get(activeTarget).setState(TargetDrawable.STATE_ACTIVE);
419
420            hideUnselected(activeTarget);
421
422            // Inform listener of any active targets.  Typically only one will be active.
423            if (DEBUG) Log.v(TAG, "Finish with target hit = " + targetHit);
424            dispatchTriggerEvent(mActiveTarget);
425            mHandleAnimation = Tweener.to(mHandleDrawable, 0,
426                    "ease", Ease.Quart.easeOut,
427                    "delay", RETURN_TO_HOME_DELAY,
428                    "alpha", 1.0f,
429                    "x", mWaveCenterX,
430                    "y", mWaveCenterY,
431                    "onUpdate", mUpdateListener,
432                    "onComplete", mResetListener);
433        } else {
434            // Animate finger outline back to home position
435            mHandleAnimation = Tweener.to(mHandleDrawable, RETURN_TO_HOME_DURATION,
436                    "ease", Ease.Quart.easeOut,
437                    "delay", 0,
438                    "alpha", 1.0f,
439                    "x", mWaveCenterX,
440                    "y", mWaveCenterY,
441                    "onUpdate", mUpdateListener,
442                    "onComplete", mDragging ? mResetListenerWithPing : mResetListener);
443        }
444
445        setGrabbedState(OnTriggerListener.NO_HANDLE);
446    }
447
448    private void hideUnselected(int active) {
449        for (int i = 0; i < mTargetDrawables.size(); i++) {
450            if (i != active) {
451                mTargetDrawables.get(i).setAlpha(0.0f);
452            }
453        }
454        mOuterRing.setAlpha(0.0f);
455    }
456
457    private void hideTargets(boolean animate) {
458        if (mTargetAnimations.size() > 0) {
459            stopTargetAnimation();
460        }
461        // Note: these animations should complete at the same time so that we can swap out
462        // the target assets asynchronously from the setTargetResources() call.
463        mAnimatingTargets = animate;
464        if (animate) {
465            final int duration = animate ? HIDE_ANIMATION_DURATION : 0;
466            for (TargetDrawable target : mTargetDrawables) {
467                target.setState(TargetDrawable.STATE_INACTIVE);
468                mTargetAnimations.add(Tweener.to(target, duration,
469                        "alpha", 0.0f,
470                        "delay", HIDE_ANIMATION_DELAY,
471                        "onUpdate", mUpdateListener));
472            }
473            mTargetAnimations.add(Tweener.to(mOuterRing, duration,
474                    "alpha", 0.0f,
475                    "delay", HIDE_ANIMATION_DELAY,
476                    "onUpdate", mUpdateListener,
477                    "onComplete", mTargetUpdateListener));
478        } else {
479            for (TargetDrawable target : mTargetDrawables) {
480                target.setState(TargetDrawable.STATE_INACTIVE);
481                target.setAlpha(0.0f);
482            }
483            mOuterRing.setAlpha(0.0f);
484        }
485    }
486
487    private void showTargets(boolean animate) {
488        if (mTargetAnimations.size() > 0) {
489            stopTargetAnimation();
490        }
491        mAnimatingTargets = animate;
492        if (animate) {
493            for (TargetDrawable target : mTargetDrawables) {
494                target.setState(TargetDrawable.STATE_INACTIVE);
495                mTargetAnimations.add(Tweener.to(target, SHOW_ANIMATION_DURATION,
496                        "alpha", 1.0f,
497                        "delay", SHOW_ANIMATION_DELAY,
498                        "onUpdate", mUpdateListener));
499            }
500            mTargetAnimations.add(Tweener.to(mOuterRing, SHOW_ANIMATION_DURATION,
501                    "alpha", 1.0f,
502                    "delay", SHOW_ANIMATION_DELAY,
503                    "onUpdate", mUpdateListener,
504                    "onComplete", mTargetUpdateListener));
505        } else {
506            for (TargetDrawable target : mTargetDrawables) {
507                target.setState(TargetDrawable.STATE_INACTIVE);
508                target.setAlpha(1.0f);
509            }
510            mOuterRing.setAlpha(1.0f);
511        }
512    }
513
514    private void stopTargetAnimation() {
515        for (Tweener anim : mTargetAnimations) {
516            anim.animator.end();
517        }
518        mTargetAnimations.clear();
519    }
520
521    private void vibrate() {
522        if (mVibrator != null) {
523            mVibrator.vibrate(mVibrationDuration);
524        }
525    }
526
527    private void internalSetTargetResources(int resourceId) {
528        Resources res = getContext().getResources();
529        TypedArray array = res.obtainTypedArray(resourceId);
530        int count = array.length();
531        ArrayList<TargetDrawable> targetDrawables = new ArrayList<TargetDrawable>(count);
532        int maxWidth = mHandleDrawable.getWidth();
533        int maxHeight = mHandleDrawable.getHeight();
534        for (int i = 0; i < count; i++) {
535            TypedValue value = array.peekValue(i);
536            TargetDrawable target= new TargetDrawable(res, value != null ? value.resourceId : 0);
537            targetDrawables.add(target);
538            maxWidth = Math.max(maxWidth, target.getWidth());
539            maxHeight = Math.max(maxHeight, target.getHeight());
540        }
541        mTargetResourceId = resourceId;
542        mTargetDrawables = targetDrawables;
543        if (mMaxTargetWidth != maxWidth || mMaxTargetHeight != maxHeight) {
544            mMaxTargetWidth = maxWidth;
545            mMaxTargetHeight = maxHeight;
546            requestLayout(); // required to resize layout and call updateTargetPositions()
547        } else {
548            updateTargetPositions();
549        }
550        array.recycle();
551    }
552
553    /**
554     * Loads an array of drawables from the given resourceId.
555     *
556     * @param resourceId
557     */
558    public void setTargetResources(int resourceId) {
559        if (mAnimatingTargets) {
560            // postpone this change until we return to the initial state
561            mNewTargetResources = resourceId;
562        } else {
563            internalSetTargetResources(resourceId);
564        }
565    }
566
567    public int getTargetResourceId() {
568        return mTargetResourceId;
569    }
570
571    /**
572     * Sets the resource id specifying the target descriptions for accessibility.
573     *
574     * @param resourceId The resource id.
575     */
576    public void setTargetDescriptionsResourceId(int resourceId) {
577        mTargetDescriptionsResourceId = resourceId;
578        if (mTargetDescriptions != null) {
579            mTargetDescriptions.clear();
580        }
581    }
582
583    /**
584     * Gets the resource id specifying the target descriptions for accessibility.
585     *
586     * @return The resource id.
587     */
588    public int getTargetDescriptionsResourceId() {
589        return mTargetDescriptionsResourceId;
590    }
591
592    /**
593     * Sets the resource id specifying the target direction descriptions for accessibility.
594     *
595     * @param resourceId The resource id.
596     */
597    public void setDirectionDescriptionsResourceId(int resourceId) {
598        mDirectionDescriptionsResourceId = resourceId;
599        if (mDirectionDescriptions != null) {
600            mDirectionDescriptions.clear();
601        }
602    }
603
604    /**
605     * Gets the resource id specifying the target direction descriptions.
606     *
607     * @return The resource id.
608     */
609    public int getDirectionDescriptionsResourceId() {
610        return mDirectionDescriptionsResourceId;
611    }
612
613    /**
614     * Enable or disable vibrate on touch.
615     *
616     * @param enabled
617     */
618    public void setVibrateEnabled(boolean enabled) {
619        if (enabled && mVibrator == null) {
620            mVibrator = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE);
621        } else {
622            mVibrator = null;
623        }
624    }
625
626    /**
627     * Starts chevron animation. Example use case: show chevron animation whenever the phone rings
628     * or the user touches the screen.
629     *
630     */
631    public void ping() {
632        stopChevronAnimation();
633        startChevronAnimation();
634    }
635
636    /**
637     * Resets the widget to default state and cancels all animation. If animate is 'true', will
638     * animate objects into place. Otherwise, objects will snap back to place.
639     *
640     * @param animate
641     */
642    public void reset(boolean animate) {
643        stopChevronAnimation();
644        stopHandleAnimation();
645        stopTargetAnimation();
646        hideChevrons();
647        hideTargets(animate);
648        mHandleDrawable.setX(mWaveCenterX);
649        mHandleDrawable.setY(mWaveCenterY);
650        mHandleDrawable.setState(TargetDrawable.STATE_INACTIVE);
651        Tweener.reset();
652    }
653
654    @Override
655    public boolean onTouchEvent(MotionEvent event) {
656        final int action = event.getAction();
657        boolean handled = false;
658        switch (action) {
659            case MotionEvent.ACTION_DOWN:
660                if (DEBUG) Log.v(TAG, "*** DOWN ***");
661                handleDown(event);
662                handled = true;
663                break;
664
665            case MotionEvent.ACTION_MOVE:
666                if (DEBUG) Log.v(TAG, "*** MOVE ***");
667                handleMove(event);
668                handled = true;
669                break;
670
671            case MotionEvent.ACTION_UP:
672                if (DEBUG) Log.v(TAG, "*** UP ***");
673                handleMove(event);
674                handleUp(event);
675                handled = true;
676                break;
677
678            case MotionEvent.ACTION_CANCEL:
679                if (DEBUG) Log.v(TAG, "*** CANCEL ***");
680                // handleMove(event);
681                handleCancel(event);
682                handled = true;
683                break;
684        }
685        invalidate();
686        return handled ? true : super.onTouchEvent(event);
687    }
688
689    private void moveHandleTo(float x, float y, boolean animate) {
690        // TODO: animate the handle based on the current state/position
691        mHandleDrawable.setX(x);
692        mHandleDrawable.setY(y);
693    }
694
695    private void handleDown(MotionEvent event) {
696       if (!trySwitchToFirstTouchState(event)) {
697            mDragging = false;
698            stopTargetAnimation();
699            ping();
700        }
701    }
702
703    private void handleUp(MotionEvent event) {
704        if (DEBUG && mDragging) Log.v(TAG, "** Handle RELEASE");
705        switchToState(STATE_FINISH, event.getX(), event.getY());
706    }
707
708    private void handleCancel(MotionEvent event) {
709        if (DEBUG && mDragging) Log.v(TAG, "** Handle CANCEL");
710        mActiveTarget = -1; // Drop the active target if canceled.
711        switchToState(STATE_FINISH, event.getX(), event.getY());
712    }
713
714    private void handleMove(MotionEvent event) {
715        if (!mDragging) {
716            trySwitchToFirstTouchState(event);
717            return;
718        }
719
720        int activeTarget = -1;
721        final int historySize = event.getHistorySize();
722        for (int k = 0; k < historySize + 1; k++) {
723            float x = k < historySize ? event.getHistoricalX(k) : event.getX();
724            float y = k < historySize ? event.getHistoricalY(k) : event.getY();
725            float tx = x - mWaveCenterX;
726            float ty = y - mWaveCenterY;
727            float touchRadius = (float) Math.sqrt(dist2(tx, ty));
728            final float scale = touchRadius > mOuterRadius ? mOuterRadius / touchRadius : 1.0f;
729            float limitX = mWaveCenterX + tx * scale;
730            float limitY = mWaveCenterY + ty * scale;
731
732            boolean singleTarget = mTargetDrawables.size() == 1;
733            if (singleTarget) {
734                // Snap to outer ring if there's only one target
735                float snapRadius = mOuterRadius - mSnapMargin;
736                if (touchRadius > snapRadius) {
737                    activeTarget = 0;
738                    x = limitX;
739                    y = limitY;
740                }
741            } else {
742                // If there's more than one target, snap to the closest one less than hitRadius away.
743                float best = Float.MAX_VALUE;
744                final float hitRadius2 = mHitRadius * mHitRadius;
745                for (int i = 0; i < mTargetDrawables.size(); i++) {
746                    // Snap to the first target in range
747                    TargetDrawable target = mTargetDrawables.get(i);
748                    float dx = limitX - target.getX();
749                    float dy = limitY - target.getY();
750                    float dist2 = dx*dx + dy*dy;
751                    if (target.isEnabled() && dist2 < hitRadius2 && dist2 < best) {
752                        activeTarget = i;
753                        best = dist2;
754                    }
755                }
756                x = limitX;
757                y = limitY;
758            }
759            if (activeTarget != -1) {
760                switchToState(STATE_SNAP, x,y);
761                float newX = singleTarget ? limitX : mTargetDrawables.get(activeTarget).getX();
762                float newY = singleTarget ? limitY : mTargetDrawables.get(activeTarget).getY();
763                moveHandleTo(newX, newY, false);
764                TargetDrawable currentTarget = mTargetDrawables.get(activeTarget);
765                if (currentTarget.hasState(TargetDrawable.STATE_FOCUSED)) {
766                    currentTarget.setState(TargetDrawable.STATE_FOCUSED);
767                    mHandleDrawable.setAlpha(0.0f);
768                }
769            } else {
770                switchToState(STATE_TRACKING, x, y);
771                moveHandleTo(x, y, false);
772                mHandleDrawable.setAlpha(1.0f);
773            }
774        }
775
776        // Draw handle outside parent's bounds
777        invalidateGlobalRegion(mHandleDrawable);
778
779        if (mActiveTarget != activeTarget && activeTarget != -1) {
780            dispatchGrabbedEvent(activeTarget);
781            if (AccessibilityManager.getInstance(mContext).isEnabled()) {
782                String targetContentDescription = getTargetDescription(activeTarget);
783                announceText(targetContentDescription);
784            }
785        }
786        mActiveTarget = activeTarget;
787    }
788
789    @Override
790    public boolean onHoverEvent(MotionEvent event) {
791        if (AccessibilityManager.getInstance(mContext).isTouchExplorationEnabled()) {
792            final int action = event.getAction();
793            switch (action) {
794                case MotionEvent.ACTION_HOVER_ENTER:
795                    event.setAction(MotionEvent.ACTION_DOWN);
796                    break;
797                case MotionEvent.ACTION_HOVER_MOVE:
798                    event.setAction(MotionEvent.ACTION_MOVE);
799                    break;
800                case MotionEvent.ACTION_HOVER_EXIT:
801                    event.setAction(MotionEvent.ACTION_UP);
802                    break;
803            }
804            onTouchEvent(event);
805            event.setAction(action);
806        }
807        return super.onHoverEvent(event);
808    }
809
810    /**
811     * Sets the current grabbed state, and dispatches a grabbed state change
812     * event to our listener.
813     */
814    private void setGrabbedState(int newState) {
815        if (newState != mGrabbedState) {
816            if (newState != OnTriggerListener.NO_HANDLE) {
817                vibrate();
818            }
819            mGrabbedState = newState;
820            if (mOnTriggerListener != null) {
821                if (newState == OnTriggerListener.NO_HANDLE) {
822                    mOnTriggerListener.onReleased(this, OnTriggerListener.CENTER_HANDLE);
823                } else {
824                    mOnTriggerListener.onGrabbed(this, OnTriggerListener.CENTER_HANDLE);
825                }
826                mOnTriggerListener.onGrabbedStateChange(this, mGrabbedState);
827            }
828        }
829    }
830
831    private boolean trySwitchToFirstTouchState(MotionEvent event) {
832        final float x = event.getX();
833        final float y = event.getY();
834        final float dx = x - mWaveCenterX;
835        final float dy = y - mWaveCenterY;
836        if (mAlwaysTrackFinger || dist2(dx,dy) <= getScaledTapRadiusSquared()) {
837            if (DEBUG) Log.v(TAG, "** Handle HIT");
838            switchToState(STATE_FIRST_TOUCH, x, y);
839            moveHandleTo(x, y, false);
840            mDragging = true;
841            return true;
842        }
843        return false;
844    }
845
846    private void performInitialLayout(float centerX, float centerY) {
847        if (mOuterRadius == 0.0f) {
848            mOuterRadius = 0.5f*(float) Math.sqrt(dist2(centerX, centerY));
849        }
850        if (mHitRadius == 0.0f) {
851            // Use the radius of inscribed circle of the first target.
852            mHitRadius = mTargetDrawables.get(0).getWidth() / 2.0f;
853        }
854        if (mSnapMargin == 0.0f) {
855            mSnapMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
856                    SNAP_MARGIN_DEFAULT, getContext().getResources().getDisplayMetrics());
857        }
858        hideChevrons();
859        hideTargets(false);
860        moveHandleTo(centerX, centerY, false);
861    }
862
863    private void setupGravity(int dx, int dy) {
864        final int layoutDirection = getResolvedLayoutDirection();
865        final int absoluteGravity = Gravity.getAbsoluteGravity(mGravity, layoutDirection);
866
867        switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
868            case Gravity.LEFT:
869                mHorizontalInset = 0;
870                break;
871            case Gravity.RIGHT:
872                mHorizontalInset = dx;
873                break;
874            case Gravity.CENTER_HORIZONTAL:
875            default:
876                mHorizontalInset = dx / 2;
877                break;
878        }
879        switch (absoluteGravity & Gravity.VERTICAL_GRAVITY_MASK) {
880            case Gravity.TOP:
881                mVerticalInset = 0;
882                break;
883            case Gravity.BOTTOM:
884                mVerticalInset = dy;
885                break;
886            case Gravity.CENTER_VERTICAL:
887            default:
888                mVerticalInset = dy / 2;
889                break;
890        }
891    }
892
893    @Override
894    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
895        super.onLayout(changed, left, top, right, bottom);
896        final int width = right - left;
897        final int height = bottom - top;
898        float newWaveCenterX = mHorizontalOffset + mHorizontalInset
899                + Math.max(width, mMaxTargetWidth + mOuterRing.getWidth()) / 2;
900        float newWaveCenterY = mVerticalOffset + mVerticalInset
901                + Math.max(height, + mMaxTargetHeight + mOuterRing.getHeight()) / 2;
902        if (newWaveCenterX != mWaveCenterX || newWaveCenterY != mWaveCenterY) {
903            if (mWaveCenterX == 0 && mWaveCenterY == 0) {
904                performInitialLayout(newWaveCenterX, newWaveCenterY);
905            }
906            mWaveCenterX = newWaveCenterX;
907            mWaveCenterY = newWaveCenterY;
908
909            mOuterRing.setX(mWaveCenterX);
910            mOuterRing.setY(Math.max(mWaveCenterY, mWaveCenterY));
911        }
912        updateTargetPositions();
913        if (DEBUG) dump();
914    }
915
916    private void updateTargetPositions() {
917        // Reposition the target drawables if the view changed.
918        for (int i = 0; i < mTargetDrawables.size(); i++) {
919            final TargetDrawable targetIcon = mTargetDrawables.get(i);
920            double angle = -2.0f * Math.PI * i / mTargetDrawables.size();
921            float xPosition = mWaveCenterX + mOuterRadius * (float) Math.cos(angle);
922            float yPosition = mWaveCenterY + mOuterRadius * (float) Math.sin(angle);
923            targetIcon.setX(xPosition);
924            targetIcon.setY(yPosition);
925        }
926    }
927
928    private void hideChevrons() {
929        for (TargetDrawable chevron : mChevronDrawables) {
930            if (chevron != null) {
931                chevron.setAlpha(0.0f);
932            }
933        }
934    }
935
936    @Override
937    protected void onDraw(Canvas canvas) {
938        mOuterRing.draw(canvas);
939        for (TargetDrawable target : mTargetDrawables) {
940            if (target != null) {
941                target.draw(canvas);
942            }
943        }
944        for (TargetDrawable target : mChevronDrawables) {
945            if (target != null) {
946                target.draw(canvas);
947            }
948        }
949        mHandleDrawable.draw(canvas);
950    }
951
952    public void setOnTriggerListener(OnTriggerListener listener) {
953        mOnTriggerListener = listener;
954    }
955
956    private float square(float d) {
957        return d * d;
958    }
959
960    private float dist2(float dx, float dy) {
961        return dx*dx + dy*dy;
962    }
963
964    private float getScaledTapRadiusSquared() {
965        final float scaledTapRadius;
966        if (AccessibilityManager.getInstance(mContext).isEnabled()) {
967            scaledTapRadius = TAP_RADIUS_SCALE_ACCESSIBILITY_ENABLED * mTapRadius;
968        } else {
969            scaledTapRadius = mTapRadius;
970        }
971        return square(scaledTapRadius);
972    }
973
974    private void announceTargets() {
975        StringBuilder utterance = new StringBuilder();
976        final int targetCount = mTargetDrawables.size();
977        for (int i = 0; i < targetCount; i++) {
978            String targetDescription = getTargetDescription(i);
979            String directionDescription = getDirectionDescription(i);
980            if (!TextUtils.isEmpty(targetDescription)
981                    && !TextUtils.isEmpty(directionDescription)) {
982                String text = String.format(directionDescription, targetDescription);
983                utterance.append(text);
984            }
985            if (utterance.length() > 0) {
986                announceText(utterance.toString());
987            }
988        }
989    }
990
991    private void announceText(String text) {
992        setContentDescription(text);
993        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
994        setContentDescription(null);
995    }
996
997    private String getTargetDescription(int index) {
998        if (mTargetDescriptions == null || mTargetDescriptions.isEmpty()) {
999            mTargetDescriptions = loadDescriptions(mTargetDescriptionsResourceId);
1000            if (mTargetDrawables.size() != mTargetDescriptions.size()) {
1001                Log.w(TAG, "The number of target drawables must be"
1002                        + " euqal to the number of target descriptions.");
1003                return null;
1004            }
1005        }
1006        return mTargetDescriptions.get(index);
1007    }
1008
1009    private String getDirectionDescription(int index) {
1010        if (mDirectionDescriptions == null || mDirectionDescriptions.isEmpty()) {
1011            mDirectionDescriptions = loadDescriptions(mDirectionDescriptionsResourceId);
1012            if (mTargetDrawables.size() != mDirectionDescriptions.size()) {
1013                Log.w(TAG, "The number of target drawables must be"
1014                        + " euqal to the number of direction descriptions.");
1015                return null;
1016            }
1017        }
1018        return mDirectionDescriptions.get(index);
1019    }
1020
1021    private ArrayList<String> loadDescriptions(int resourceId) {
1022        TypedArray array = getContext().getResources().obtainTypedArray(resourceId);
1023        final int count = array.length();
1024        ArrayList<String> targetContentDescriptions = new ArrayList<String>(count);
1025        for (int i = 0; i < count; i++) {
1026            String contentDescription = array.getString(i);
1027            targetContentDescriptions.add(contentDescription);
1028        }
1029        array.recycle();
1030        return targetContentDescriptions;
1031    }
1032
1033    public int getResourceIdForTarget(int index) {
1034        final TargetDrawable drawable = mTargetDrawables.get(index);
1035        return drawable == null ? 0 : drawable.getResourceId();
1036    }
1037
1038    public void setEnableTarget(int resourceId, boolean enabled) {
1039        for (int i = 0; i < mTargetDrawables.size(); i++) {
1040            final TargetDrawable target = mTargetDrawables.get(i);
1041            if (target.getResourceId() == resourceId) {
1042                target.setEnabled(enabled);
1043                break; // should never be more than one match
1044            }
1045        }
1046    }
1047
1048    /**
1049     * Gets the position of a target in the array that matches the given resource.
1050     * @param resourceId
1051     * @return the index or -1 if not found
1052     */
1053    public int getTargetPosition(int resourceId) {
1054        for (int i = 0; i < mTargetDrawables.size(); i++) {
1055            final TargetDrawable target = mTargetDrawables.get(i);
1056            if (target.getResourceId() == resourceId) {
1057                return i; // should never be more than one match
1058            }
1059        }
1060        return -1;
1061    }
1062}
1063