GlowPadView.java revision 245b453733d0b611960844d939e0013f285a5a9a
1/*
2 * Copyright (C) 2012 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.ComponentName;
26import android.content.Context;
27import android.content.pm.PackageManager;
28import android.content.pm.PackageManager.NameNotFoundException;
29import android.content.res.Resources;
30import android.content.res.TypedArray;
31import android.graphics.Canvas;
32import android.graphics.drawable.Drawable;
33import android.os.Bundle;
34import android.os.Vibrator;
35import android.text.TextUtils;
36import android.util.AttributeSet;
37import android.util.Log;
38import android.util.TypedValue;
39import android.view.Gravity;
40import android.view.MotionEvent;
41import android.view.View;
42import android.view.accessibility.AccessibilityManager;
43
44import com.android.internal.R;
45
46import java.util.ArrayList;
47
48/**
49 * A re-usable widget containing a center, outer ring and wave animation.
50 */
51public class GlowPadView extends View {
52    private static final String TAG = "GlowPadView";
53    private static final boolean DEBUG = false;
54
55    // Wave state machine
56    private static final int STATE_IDLE = 0;
57    private static final int STATE_START = 1;
58    private static final int STATE_FIRST_TOUCH = 2;
59    private static final int STATE_TRACKING = 3;
60    private static final int STATE_SNAP = 4;
61    private static final int STATE_FINISH = 5;
62
63    // Animation properties.
64    private static final float SNAP_MARGIN_DEFAULT = 20.0f; // distance to ring before we snap to it
65
66    public interface OnTriggerListener {
67        int NO_HANDLE = 0;
68        int CENTER_HANDLE = 1;
69        public void onGrabbed(View v, int handle);
70        public void onReleased(View v, int handle);
71        public void onTrigger(View v, int target);
72        public void onGrabbedStateChange(View v, int handle);
73        public void onFinishFinalAnimation();
74    }
75
76    // Tuneable parameters for animation
77    private static final int WAVE_ANIMATION_DURATION = 1350;
78    private static final int RETURN_TO_HOME_DELAY = 1200;
79    private static final int RETURN_TO_HOME_DURATION = 200;
80    private static final int HIDE_ANIMATION_DELAY = 200;
81    private static final int HIDE_ANIMATION_DURATION = 200;
82    private static final int SHOW_ANIMATION_DURATION = 200;
83    private static final int SHOW_ANIMATION_DELAY = 50;
84    private static final int INITIAL_SHOW_HANDLE_DURATION = 200;
85    private static final int REVEAL_GLOW_DELAY = 0;
86    private static final int REVEAL_GLOW_DURATION = 0;
87
88    private static final float TAP_RADIUS_SCALE_ACCESSIBILITY_ENABLED = 1.3f;
89    private static final float TARGET_SCALE_EXPANDED = 1.0f;
90    private static final float TARGET_SCALE_COLLAPSED = 0.8f;
91    private static final float RING_SCALE_EXPANDED = 1.0f;
92    private static final float RING_SCALE_COLLAPSED = 0.5f;
93
94    private ArrayList<TargetDrawable> mTargetDrawables = new ArrayList<TargetDrawable>();
95    private AnimationBundle mWaveAnimations = new AnimationBundle();
96    private AnimationBundle mTargetAnimations = new AnimationBundle();
97    private AnimationBundle mGlowAnimations = new AnimationBundle();
98    private ArrayList<String> mTargetDescriptions;
99    private ArrayList<String> mDirectionDescriptions;
100    private OnTriggerListener mOnTriggerListener;
101    private TargetDrawable mHandleDrawable;
102    private TargetDrawable mOuterRing;
103    private Vibrator mVibrator;
104
105    private int mFeedbackCount = 3;
106    private int mVibrationDuration = 0;
107    private int mGrabbedState;
108    private int mActiveTarget = -1;
109    private float mGlowRadius;
110    private float mWaveCenterX;
111    private float mWaveCenterY;
112    private int mMaxTargetHeight;
113    private int mMaxTargetWidth;
114
115    private float mOuterRadius = 0.0f;
116    private float mSnapMargin = 0.0f;
117    private boolean mDragging;
118    private int mNewTargetResources;
119
120    private class AnimationBundle extends ArrayList<Tweener> {
121        private static final long serialVersionUID = 0xA84D78726F127468L;
122        private boolean mSuspended;
123
124        public void start() {
125            if (mSuspended) return; // ignore attempts to start animations
126            final int count = size();
127            for (int i = 0; i < count; i++) {
128                Tweener anim = get(i);
129                anim.animator.start();
130            }
131        }
132
133        public void cancel() {
134            final int count = size();
135            for (int i = 0; i < count; i++) {
136                Tweener anim = get(i);
137                anim.animator.cancel();
138            }
139            clear();
140        }
141
142        public void stop() {
143            final int count = size();
144            for (int i = 0; i < count; i++) {
145                Tweener anim = get(i);
146                anim.animator.end();
147            }
148            clear();
149        }
150
151        public void setSuspended(boolean suspend) {
152            mSuspended = suspend;
153        }
154    };
155
156    private AnimatorListener mResetListener = new AnimatorListenerAdapter() {
157        public void onAnimationEnd(Animator animator) {
158            switchToState(STATE_IDLE, mWaveCenterX, mWaveCenterY);
159            dispatchOnFinishFinalAnimation();
160        }
161    };
162
163    private AnimatorListener mResetListenerWithPing = new AnimatorListenerAdapter() {
164        public void onAnimationEnd(Animator animator) {
165            ping();
166            switchToState(STATE_IDLE, mWaveCenterX, mWaveCenterY);
167            dispatchOnFinishFinalAnimation();
168        }
169    };
170
171    private AnimatorUpdateListener mUpdateListener = new AnimatorUpdateListener() {
172        public void onAnimationUpdate(ValueAnimator animation) {
173            invalidate();
174        }
175    };
176
177    private boolean mAnimatingTargets;
178    private AnimatorListener mTargetUpdateListener = new AnimatorListenerAdapter() {
179        public void onAnimationEnd(Animator animator) {
180            if (mNewTargetResources != 0) {
181                internalSetTargetResources(mNewTargetResources);
182                mNewTargetResources = 0;
183                hideTargets(false, false);
184            }
185            mAnimatingTargets = false;
186        }
187    };
188    private int mTargetResourceId;
189    private int mTargetDescriptionsResourceId;
190    private int mDirectionDescriptionsResourceId;
191    private boolean mAlwaysTrackFinger;
192    private int mHorizontalInset;
193    private int mVerticalInset;
194    private int mGravity = Gravity.TOP;
195    private boolean mInitialLayout = true;
196    private Tweener mBackgroundAnimator;
197    private PointCloud mPointCloud;
198    private float mInnerRadius;
199    private int mPointerId;
200
201    public GlowPadView(Context context) {
202        this(context, null);
203    }
204
205    public GlowPadView(Context context, AttributeSet attrs) {
206        super(context, attrs);
207        Resources res = context.getResources();
208
209        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.GlowPadView);
210        mInnerRadius = a.getDimension(R.styleable.GlowPadView_innerRadius, mInnerRadius);
211        mOuterRadius = a.getDimension(R.styleable.GlowPadView_outerRadius, mOuterRadius);
212        mSnapMargin = a.getDimension(R.styleable.GlowPadView_snapMargin, mSnapMargin);
213        mVibrationDuration = a.getInt(R.styleable.GlowPadView_vibrationDuration,
214                mVibrationDuration);
215        mFeedbackCount = a.getInt(R.styleable.GlowPadView_feedbackCount,
216                mFeedbackCount);
217        TypedValue handle = a.peekValue(R.styleable.GlowPadView_handleDrawable);
218        mHandleDrawable = new TargetDrawable(res, handle != null ? handle.resourceId : 0);
219        mHandleDrawable.setState(TargetDrawable.STATE_INACTIVE);
220        mOuterRing = new TargetDrawable(res,
221                getResourceId(a, R.styleable.GlowPadView_outerRingDrawable));
222
223        mAlwaysTrackFinger = a.getBoolean(R.styleable.GlowPadView_alwaysTrackFinger, false);
224
225        int pointId = getResourceId(a, R.styleable.GlowPadView_pointDrawable);
226        Drawable pointDrawable = pointId != 0 ? res.getDrawable(pointId) : null;
227        mGlowRadius = a.getDimension(R.styleable.GlowPadView_glowRadius, 0.0f);
228
229        TypedValue outValue = new TypedValue();
230
231        // Read array of target drawables
232        if (a.getValue(R.styleable.GlowPadView_targetDrawables, outValue)) {
233            internalSetTargetResources(outValue.resourceId);
234        }
235        if (mTargetDrawables == null || mTargetDrawables.size() == 0) {
236            throw new IllegalStateException("Must specify at least one target drawable");
237        }
238
239        // Read array of target descriptions
240        if (a.getValue(R.styleable.GlowPadView_targetDescriptions, outValue)) {
241            final int resourceId = outValue.resourceId;
242            if (resourceId == 0) {
243                throw new IllegalStateException("Must specify target descriptions");
244            }
245            setTargetDescriptionsResourceId(resourceId);
246        }
247
248        // Read array of direction descriptions
249        if (a.getValue(R.styleable.GlowPadView_directionDescriptions, outValue)) {
250            final int resourceId = outValue.resourceId;
251            if (resourceId == 0) {
252                throw new IllegalStateException("Must specify direction descriptions");
253            }
254            setDirectionDescriptionsResourceId(resourceId);
255        }
256
257        a.recycle();
258
259        // Use gravity attribute from LinearLayout
260        a = context.obtainStyledAttributes(attrs, android.R.styleable.LinearLayout);
261        mGravity = a.getInt(android.R.styleable.LinearLayout_gravity, Gravity.TOP);
262        a.recycle();
263
264        setVibrateEnabled(mVibrationDuration > 0);
265
266        assignDefaultsIfNeeded();
267
268        mPointCloud = new PointCloud(pointDrawable);
269        mPointCloud.makePointCloud(mInnerRadius, mOuterRadius);
270        mPointCloud.glowManager.setRadius(mGlowRadius);
271    }
272
273    private int getResourceId(TypedArray a, int id) {
274        TypedValue tv = a.peekValue(id);
275        return tv == null ? 0 : tv.resourceId;
276    }
277
278    private void dump() {
279        Log.v(TAG, "Outer Radius = " + mOuterRadius);
280        Log.v(TAG, "SnapMargin = " + mSnapMargin);
281        Log.v(TAG, "FeedbackCount = " + mFeedbackCount);
282        Log.v(TAG, "VibrationDuration = " + mVibrationDuration);
283        Log.v(TAG, "GlowRadius = " + mGlowRadius);
284        Log.v(TAG, "WaveCenterX = " + mWaveCenterX);
285        Log.v(TAG, "WaveCenterY = " + mWaveCenterY);
286    }
287
288    public void suspendAnimations() {
289        mWaveAnimations.setSuspended(true);
290        mTargetAnimations.setSuspended(true);
291        mGlowAnimations.setSuspended(true);
292    }
293
294    public void resumeAnimations() {
295        mWaveAnimations.setSuspended(false);
296        mTargetAnimations.setSuspended(false);
297        mGlowAnimations.setSuspended(false);
298        mWaveAnimations.start();
299        mTargetAnimations.start();
300        mGlowAnimations.start();
301    }
302
303    @Override
304    protected int getSuggestedMinimumWidth() {
305        // View should be large enough to contain the background + handle and
306        // target drawable on either edge.
307        return (int) (Math.max(mOuterRing.getWidth(), 2 * mOuterRadius) + mMaxTargetWidth);
308    }
309
310    @Override
311    protected int getSuggestedMinimumHeight() {
312        // View should be large enough to contain the unlock ring + target and
313        // target drawable on either edge
314        return (int) (Math.max(mOuterRing.getHeight(), 2 * mOuterRadius) + mMaxTargetHeight);
315    }
316
317    private int resolveMeasured(int measureSpec, int desired)
318    {
319        int result = 0;
320        int specSize = MeasureSpec.getSize(measureSpec);
321        switch (MeasureSpec.getMode(measureSpec)) {
322            case MeasureSpec.UNSPECIFIED:
323                result = desired;
324                break;
325            case MeasureSpec.AT_MOST:
326                result = Math.min(specSize, desired);
327                break;
328            case MeasureSpec.EXACTLY:
329            default:
330                result = specSize;
331        }
332        return result;
333    }
334
335    @Override
336    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
337        final int minimumWidth = getSuggestedMinimumWidth();
338        final int minimumHeight = getSuggestedMinimumHeight();
339        int computedWidth = resolveMeasured(widthMeasureSpec, minimumWidth);
340        int computedHeight = resolveMeasured(heightMeasureSpec, minimumHeight);
341        computeInsets((computedWidth - minimumWidth), (computedHeight - minimumHeight));
342        setMeasuredDimension(computedWidth, computedHeight);
343    }
344
345    private void switchToState(int state, float x, float y) {
346        switch (state) {
347            case STATE_IDLE:
348                deactivateTargets();
349                hideGlow(0, 0, 0.0f, null);
350                startBackgroundAnimation(0, 0.0f);
351                mHandleDrawable.setState(TargetDrawable.STATE_INACTIVE);
352                mHandleDrawable.setAlpha(1.0f);
353                break;
354
355            case STATE_START:
356                startBackgroundAnimation(0, 0.0f);
357                break;
358
359            case STATE_FIRST_TOUCH:
360                mHandleDrawable.setAlpha(0.0f);
361                deactivateTargets();
362                showTargets(true);
363                startBackgroundAnimation(INITIAL_SHOW_HANDLE_DURATION, 1.0f);
364                setGrabbedState(OnTriggerListener.CENTER_HANDLE);
365                if (AccessibilityManager.getInstance(mContext).isEnabled()) {
366                    announceTargets();
367                }
368                break;
369
370            case STATE_TRACKING:
371                mHandleDrawable.setAlpha(0.0f);
372                showGlow(REVEAL_GLOW_DURATION , REVEAL_GLOW_DELAY, 1.0f, null);
373                break;
374
375            case STATE_SNAP:
376                // TODO: Add transition states (see list_selector_background_transition.xml)
377                mHandleDrawable.setAlpha(0.0f);
378                showGlow(REVEAL_GLOW_DURATION , REVEAL_GLOW_DELAY, 0.0f, null);
379                break;
380
381            case STATE_FINISH:
382                doFinish();
383                break;
384        }
385    }
386
387    private void showGlow(int duration, int delay, float finalAlpha,
388            AnimatorListener finishListener) {
389        mGlowAnimations.cancel();
390        mGlowAnimations.add(Tweener.to(mPointCloud.glowManager, duration,
391                "ease", Ease.Cubic.easeIn,
392                "delay", delay,
393                "alpha", finalAlpha,
394                "onUpdate", mUpdateListener,
395                "onComplete", finishListener));
396        mGlowAnimations.start();
397    }
398
399    private void hideGlow(int duration, int delay, float finalAlpha,
400            AnimatorListener finishListener) {
401        mGlowAnimations.cancel();
402        mGlowAnimations.add(Tweener.to(mPointCloud.glowManager, duration,
403                "ease", Ease.Quart.easeOut,
404                "delay", delay,
405                "alpha", finalAlpha,
406                "x", 0.0f,
407                "y", 0.0f,
408                "onUpdate", mUpdateListener,
409                "onComplete", finishListener));
410        mGlowAnimations.start();
411    }
412
413    private void deactivateTargets() {
414        final int count = mTargetDrawables.size();
415        for (int i = 0; i < count; i++) {
416            TargetDrawable target = mTargetDrawables.get(i);
417            target.setState(TargetDrawable.STATE_INACTIVE);
418        }
419        mActiveTarget = -1;
420    }
421
422    /**
423     * Dispatches a trigger event to listener. Ignored if a listener is not set.
424     * @param whichTarget the target that was triggered.
425     */
426    private void dispatchTriggerEvent(int whichTarget) {
427        vibrate();
428        if (mOnTriggerListener != null) {
429            mOnTriggerListener.onTrigger(this, whichTarget);
430        }
431    }
432
433    private void dispatchOnFinishFinalAnimation() {
434        if (mOnTriggerListener != null) {
435            mOnTriggerListener.onFinishFinalAnimation();
436        }
437    }
438
439    private void doFinish() {
440        final int activeTarget = mActiveTarget;
441        final boolean targetHit =  activeTarget != -1;
442
443        if (targetHit) {
444            if (DEBUG) Log.v(TAG, "Finish with target hit = " + targetHit);
445
446            highlightSelected(activeTarget);
447
448            // Inform listener of any active targets.  Typically only one will be active.
449            hideGlow(RETURN_TO_HOME_DURATION, RETURN_TO_HOME_DELAY, 0.0f, mResetListener);
450            dispatchTriggerEvent(activeTarget);
451            if (!mAlwaysTrackFinger) {
452                // Force ring and targets to finish animation to final expanded state
453                mTargetAnimations.stop();
454            }
455        } else {
456            // Animate handle back to the center based on current state.
457            hideGlow(HIDE_ANIMATION_DURATION, 0, 0.0f, mResetListenerWithPing);
458            hideTargets(true, false);
459        }
460
461        setGrabbedState(OnTriggerListener.NO_HANDLE);
462    }
463
464    private void highlightSelected(int activeTarget) {
465        // Highlight the given target and fade others
466        mTargetDrawables.get(activeTarget).setState(TargetDrawable.STATE_ACTIVE);
467        hideUnselected(activeTarget);
468    }
469
470    private void hideUnselected(int active) {
471        for (int i = 0; i < mTargetDrawables.size(); i++) {
472            if (i != active) {
473                mTargetDrawables.get(i).setAlpha(0.0f);
474            }
475        }
476    }
477
478    private void hideTargets(boolean animate, boolean expanded) {
479        mTargetAnimations.cancel();
480        // Note: these animations should complete at the same time so that we can swap out
481        // the target assets asynchronously from the setTargetResources() call.
482        mAnimatingTargets = animate;
483        final int duration = animate ? HIDE_ANIMATION_DURATION : 0;
484        final int delay = animate ? HIDE_ANIMATION_DELAY : 0;
485
486        final float targetScale = expanded ?
487                TARGET_SCALE_EXPANDED : TARGET_SCALE_COLLAPSED;
488        final int length = mTargetDrawables.size();
489        final TimeInterpolator interpolator = Ease.Cubic.easeOut;
490        for (int i = 0; i < length; i++) {
491            TargetDrawable target = mTargetDrawables.get(i);
492            target.setState(TargetDrawable.STATE_INACTIVE);
493            mTargetAnimations.add(Tweener.to(target, duration,
494                    "ease", interpolator,
495                    "alpha", 0.0f,
496                    "scaleX", targetScale,
497                    "scaleY", targetScale,
498                    "delay", delay,
499                    "onUpdate", mUpdateListener));
500        }
501
502        final float ringScaleTarget = expanded ?
503                RING_SCALE_EXPANDED : RING_SCALE_COLLAPSED;
504        mTargetAnimations.add(Tweener.to(mOuterRing, duration,
505                "ease", interpolator,
506                "alpha", 0.0f,
507                "scaleX", ringScaleTarget,
508                "scaleY", ringScaleTarget,
509                "delay", delay,
510                "onUpdate", mUpdateListener,
511                "onComplete", mTargetUpdateListener));
512
513        mTargetAnimations.start();
514    }
515
516    private void showTargets(boolean animate) {
517        mTargetAnimations.stop();
518        mAnimatingTargets = animate;
519        final int delay = animate ? SHOW_ANIMATION_DELAY : 0;
520        final int duration = animate ? SHOW_ANIMATION_DURATION : 0;
521        final int length = mTargetDrawables.size();
522        for (int i = 0; i < length; i++) {
523            TargetDrawable target = mTargetDrawables.get(i);
524            target.setState(TargetDrawable.STATE_INACTIVE);
525            mTargetAnimations.add(Tweener.to(target, duration,
526                    "ease", Ease.Cubic.easeOut,
527                    "alpha", 1.0f,
528                    "scaleX", 1.0f,
529                    "scaleY", 1.0f,
530                    "delay", delay,
531                    "onUpdate", mUpdateListener));
532        }
533        mTargetAnimations.add(Tweener.to(mOuterRing, duration,
534                "ease", Ease.Cubic.easeOut,
535                "alpha", 1.0f,
536                "scaleX", 1.0f,
537                "scaleY", 1.0f,
538                "delay", delay,
539                "onUpdate", mUpdateListener,
540                "onComplete", mTargetUpdateListener));
541
542        mTargetAnimations.start();
543    }
544
545    private void vibrate() {
546        if (mVibrator != null) {
547            mVibrator.vibrate(mVibrationDuration);
548        }
549    }
550
551    private ArrayList<TargetDrawable> loadDrawableArray(int resourceId) {
552        Resources res = getContext().getResources();
553        TypedArray array = res.obtainTypedArray(resourceId);
554        final int count = array.length();
555        ArrayList<TargetDrawable> drawables = new ArrayList<TargetDrawable>(count);
556        for (int i = 0; i < count; i++) {
557            TypedValue value = array.peekValue(i);
558            TargetDrawable target = new TargetDrawable(res, value != null ? value.resourceId : 0);
559            drawables.add(target);
560        }
561        array.recycle();
562        return drawables;
563    }
564
565    private void internalSetTargetResources(int resourceId) {
566        final ArrayList<TargetDrawable> targets = loadDrawableArray(resourceId);
567        mTargetDrawables = targets;
568        mTargetResourceId = resourceId;
569
570        int maxWidth = mHandleDrawable.getWidth();
571        int maxHeight = mHandleDrawable.getHeight();
572        final int count = targets.size();
573        for (int i = 0; i < count; i++) {
574            TargetDrawable target = targets.get(i);
575            maxWidth = Math.max(maxWidth, target.getWidth());
576            maxHeight = Math.max(maxHeight, target.getHeight());
577        }
578        if (mMaxTargetWidth != maxWidth || mMaxTargetHeight != maxHeight) {
579            mMaxTargetWidth = maxWidth;
580            mMaxTargetHeight = maxHeight;
581            requestLayout(); // required to resize layout and call updateTargetPositions()
582        } else {
583            updateTargetPositions(mWaveCenterX, mWaveCenterY);
584            updatePointCloudPosition(mWaveCenterX, mWaveCenterY);
585        }
586    }
587
588    /**
589     * Loads an array of drawables from the given resourceId.
590     *
591     * @param resourceId
592     */
593    public void setTargetResources(int resourceId) {
594        if (mAnimatingTargets) {
595            // postpone this change until we return to the initial state
596            mNewTargetResources = resourceId;
597        } else {
598            internalSetTargetResources(resourceId);
599        }
600    }
601
602    public int getTargetResourceId() {
603        return mTargetResourceId;
604    }
605
606    /**
607     * Sets the resource id specifying the target descriptions for accessibility.
608     *
609     * @param resourceId The resource id.
610     */
611    public void setTargetDescriptionsResourceId(int resourceId) {
612        mTargetDescriptionsResourceId = resourceId;
613        if (mTargetDescriptions != null) {
614            mTargetDescriptions.clear();
615        }
616    }
617
618    /**
619     * Gets the resource id specifying the target descriptions for accessibility.
620     *
621     * @return The resource id.
622     */
623    public int getTargetDescriptionsResourceId() {
624        return mTargetDescriptionsResourceId;
625    }
626
627    /**
628     * Sets the resource id specifying the target direction descriptions for accessibility.
629     *
630     * @param resourceId The resource id.
631     */
632    public void setDirectionDescriptionsResourceId(int resourceId) {
633        mDirectionDescriptionsResourceId = resourceId;
634        if (mDirectionDescriptions != null) {
635            mDirectionDescriptions.clear();
636        }
637    }
638
639    /**
640     * Gets the resource id specifying the target direction descriptions.
641     *
642     * @return The resource id.
643     */
644    public int getDirectionDescriptionsResourceId() {
645        return mDirectionDescriptionsResourceId;
646    }
647
648    /**
649     * Enable or disable vibrate on touch.
650     *
651     * @param enabled
652     */
653    public void setVibrateEnabled(boolean enabled) {
654        if (enabled && mVibrator == null) {
655            mVibrator = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE);
656        } else {
657            mVibrator = null;
658        }
659    }
660
661    /**
662     * Starts wave animation.
663     *
664     */
665    public void ping() {
666        if (mFeedbackCount > 0) {
667            boolean doWaveAnimation = true;
668            final AnimationBundle waveAnimations = mWaveAnimations;
669
670            // Don't do a wave if there's already one in progress
671            if (waveAnimations.size() > 0 && waveAnimations.get(0).animator.isRunning()) {
672                long t = waveAnimations.get(0).animator.getCurrentPlayTime();
673                if (t < WAVE_ANIMATION_DURATION/2) {
674                    doWaveAnimation = false;
675                }
676            }
677
678            if (doWaveAnimation) {
679                startWaveAnimation();
680            }
681        }
682    }
683
684    private void stopAndHideWaveAnimation() {
685        mWaveAnimations.cancel();
686        mPointCloud.waveManager.setAlpha(0.0f);
687    }
688
689    private void startWaveAnimation() {
690        mWaveAnimations.cancel();
691        mPointCloud.waveManager.setAlpha(1.0f);
692        mPointCloud.waveManager.setRadius(mHandleDrawable.getWidth()/2.0f);
693        mWaveAnimations.add(Tweener.to(mPointCloud.waveManager, WAVE_ANIMATION_DURATION,
694                "ease", Ease.Quad.easeOut,
695                "delay", 0,
696                "radius", 2.0f * mOuterRadius,
697                "onUpdate", mUpdateListener,
698                "onComplete",
699                new AnimatorListenerAdapter() {
700                    public void onAnimationEnd(Animator animator) {
701                        mPointCloud.waveManager.setRadius(0.0f);
702                        mPointCloud.waveManager.setAlpha(0.0f);
703                    }
704                }));
705        mWaveAnimations.start();
706    }
707
708    /**
709     * Resets the widget to default state and cancels all animation. If animate is 'true', will
710     * animate objects into place. Otherwise, objects will snap back to place.
711     *
712     * @param animate
713     */
714    public void reset(boolean animate) {
715        mGlowAnimations.stop();
716        mTargetAnimations.stop();
717        startBackgroundAnimation(0, 0.0f);
718        stopAndHideWaveAnimation();
719        hideTargets(animate, false);
720        hideGlow(0, 0, 0.0f, null);
721        Tweener.reset();
722    }
723
724    private void startBackgroundAnimation(int duration, float alpha) {
725        final Drawable background = getBackground();
726        if (mAlwaysTrackFinger && background != null) {
727            if (mBackgroundAnimator != null) {
728                mBackgroundAnimator.animator.cancel();
729            }
730            mBackgroundAnimator = Tweener.to(background, duration,
731                    "ease", Ease.Cubic.easeIn,
732                    "alpha", (int)(255.0f * alpha),
733                    "delay", SHOW_ANIMATION_DELAY);
734            mBackgroundAnimator.animator.start();
735        }
736    }
737
738    @Override
739    public boolean onTouchEvent(MotionEvent event) {
740        final int action = event.getActionMasked();
741        boolean handled = false;
742        switch (action) {
743            case MotionEvent.ACTION_POINTER_DOWN:
744            case MotionEvent.ACTION_DOWN:
745                if (DEBUG) Log.v(TAG, "*** DOWN ***");
746                handleDown(event);
747                handleMove(event);
748                handled = true;
749                break;
750
751            case MotionEvent.ACTION_MOVE:
752                if (DEBUG) Log.v(TAG, "*** MOVE ***");
753                handleMove(event);
754                handled = true;
755                break;
756
757            case MotionEvent.ACTION_POINTER_UP:
758            case MotionEvent.ACTION_UP:
759                if (DEBUG) Log.v(TAG, "*** UP ***");
760                handleMove(event);
761                handleUp(event);
762                handled = true;
763                break;
764
765            case MotionEvent.ACTION_CANCEL:
766                if (DEBUG) Log.v(TAG, "*** CANCEL ***");
767                handleMove(event);
768                handleCancel(event);
769                handled = true;
770                break;
771
772        }
773        invalidate();
774        return handled ? true : super.onTouchEvent(event);
775    }
776
777    private void updateGlowPosition(float x, float y) {
778        mPointCloud.glowManager.setX(x);
779        mPointCloud.glowManager.setY(y);
780    }
781
782    private void handleDown(MotionEvent event) {
783        int actionIndex = event.getActionIndex();
784        float eventX = event.getX(actionIndex);
785        float eventY = event.getY(actionIndex);
786        switchToState(STATE_START, eventX, eventY);
787        if (!trySwitchToFirstTouchState(eventX, eventY)) {
788            mDragging = false;
789        } else {
790            mPointerId = event.getPointerId(actionIndex);
791            updateGlowPosition(eventX, eventY);
792        }
793    }
794
795    private void handleUp(MotionEvent event) {
796        if (DEBUG && mDragging) Log.v(TAG, "** Handle RELEASE");
797        int actionIndex = event.getActionIndex();
798        if (event.getPointerId(actionIndex) == mPointerId) {
799            switchToState(STATE_FINISH, event.getX(actionIndex), event.getY(actionIndex));
800        }
801    }
802
803    private void handleCancel(MotionEvent event) {
804        if (DEBUG && mDragging) Log.v(TAG, "** Handle CANCEL");
805
806        // Drop the active target if canceled.
807        mActiveTarget = -1;
808
809        int actionIndex = event.findPointerIndex(mPointerId);
810        actionIndex = actionIndex == -1 ? 0 : actionIndex;
811        switchToState(STATE_FINISH, event.getX(actionIndex), event.getY(actionIndex));
812    }
813
814    private void handleMove(MotionEvent event) {
815        int activeTarget = -1;
816        final int historySize = event.getHistorySize();
817        ArrayList<TargetDrawable> targets = mTargetDrawables;
818        int ntargets = targets.size();
819        float x = 0.0f;
820        float y = 0.0f;
821        int actionIndex = event.findPointerIndex(mPointerId);
822
823        if (actionIndex == -1) {
824            return;  // no data for this pointer
825        }
826
827        for (int k = 0; k < historySize + 1; k++) {
828            float eventX = k < historySize ? event.getHistoricalX(actionIndex, k)
829                    : event.getX(actionIndex);
830            float eventY = k < historySize ? event.getHistoricalY(actionIndex, k)
831                    : event.getY(actionIndex);
832            // tx and ty are relative to wave center
833            float tx = eventX - mWaveCenterX;
834            float ty = eventY - mWaveCenterY;
835            float touchRadius = (float) Math.sqrt(dist2(tx, ty));
836            final float scale = touchRadius > mOuterRadius ? mOuterRadius / touchRadius : 1.0f;
837            float limitX = tx * scale;
838            float limitY = ty * scale;
839            double angleRad = Math.atan2(-ty, tx);
840
841            if (!mDragging) {
842                trySwitchToFirstTouchState(eventX, eventY);
843            }
844
845            if (mDragging) {
846                // For multiple targets, snap to the one that matches
847                final float snapRadius = mOuterRadius - mSnapMargin;
848                final float snapDistance2 = snapRadius * snapRadius;
849                // Find first target in range
850                for (int i = 0; i < ntargets; i++) {
851                    TargetDrawable target = targets.get(i);
852
853                    double targetMinRad = (i - 0.5) * 2 * Math.PI / ntargets;
854                    double targetMaxRad = (i + 0.5) * 2 * Math.PI / ntargets;
855                    if (target.isEnabled()) {
856                        boolean angleMatches =
857                            (angleRad > targetMinRad && angleRad <= targetMaxRad) ||
858                            (angleRad + 2 * Math.PI > targetMinRad &&
859                             angleRad + 2 * Math.PI <= targetMaxRad);
860                        if (angleMatches && (dist2(tx, ty) > snapDistance2)) {
861                            activeTarget = i;
862                        }
863                    }
864                }
865            }
866            x = limitX;
867            y = limitY;
868        }
869
870        if (!mDragging) {
871            return;
872        }
873
874        if (activeTarget != -1) {
875            switchToState(STATE_SNAP, x,y);
876            updateGlowPosition(x, y);
877        } else {
878            switchToState(STATE_TRACKING, x, y);
879            updateGlowPosition(x, y);
880        }
881
882        if (mActiveTarget != activeTarget) {
883            // Defocus the old target
884            if (mActiveTarget != -1) {
885                TargetDrawable target = targets.get(mActiveTarget);
886                if (target.hasState(TargetDrawable.STATE_FOCUSED)) {
887                    target.setState(TargetDrawable.STATE_INACTIVE);
888                }
889            }
890            // Focus the new target
891            if (activeTarget != -1) {
892                TargetDrawable target = targets.get(activeTarget);
893                if (target.hasState(TargetDrawable.STATE_FOCUSED)) {
894                    target.setState(TargetDrawable.STATE_FOCUSED);
895                }
896                if (AccessibilityManager.getInstance(mContext).isEnabled()) {
897                    String targetContentDescription = getTargetDescription(activeTarget);
898                    announceForAccessibility(targetContentDescription);
899                }
900            }
901        }
902        mActiveTarget = activeTarget;
903    }
904
905    @Override
906    public boolean onHoverEvent(MotionEvent event) {
907        if (AccessibilityManager.getInstance(mContext).isTouchExplorationEnabled()) {
908            final int action = event.getAction();
909            switch (action) {
910                case MotionEvent.ACTION_HOVER_ENTER:
911                    event.setAction(MotionEvent.ACTION_DOWN);
912                    break;
913                case MotionEvent.ACTION_HOVER_MOVE:
914                    event.setAction(MotionEvent.ACTION_MOVE);
915                    break;
916                case MotionEvent.ACTION_HOVER_EXIT:
917                    event.setAction(MotionEvent.ACTION_UP);
918                    break;
919            }
920            onTouchEvent(event);
921            event.setAction(action);
922        }
923        return super.onHoverEvent(event);
924    }
925
926    /**
927     * Sets the current grabbed state, and dispatches a grabbed state change
928     * event to our listener.
929     */
930    private void setGrabbedState(int newState) {
931        if (newState != mGrabbedState) {
932            if (newState != OnTriggerListener.NO_HANDLE) {
933                vibrate();
934            }
935            mGrabbedState = newState;
936            if (mOnTriggerListener != null) {
937                if (newState == OnTriggerListener.NO_HANDLE) {
938                    mOnTriggerListener.onReleased(this, OnTriggerListener.CENTER_HANDLE);
939                } else {
940                    mOnTriggerListener.onGrabbed(this, OnTriggerListener.CENTER_HANDLE);
941                }
942                mOnTriggerListener.onGrabbedStateChange(this, newState);
943            }
944        }
945    }
946
947    private boolean trySwitchToFirstTouchState(float x, float y) {
948        final float tx = x - mWaveCenterX;
949        final float ty = y - mWaveCenterY;
950        if (mAlwaysTrackFinger || dist2(tx,ty) <= getScaledGlowRadiusSquared()) {
951            if (DEBUG) Log.v(TAG, "** Handle HIT");
952            switchToState(STATE_FIRST_TOUCH, x, y);
953            updateGlowPosition(tx, ty);
954            mDragging = true;
955            return true;
956        }
957        return false;
958    }
959
960    private void assignDefaultsIfNeeded() {
961        if (mOuterRadius == 0.0f) {
962            mOuterRadius = Math.max(mOuterRing.getWidth(), mOuterRing.getHeight())/2.0f;
963        }
964        if (mSnapMargin == 0.0f) {
965            mSnapMargin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
966                    SNAP_MARGIN_DEFAULT, getContext().getResources().getDisplayMetrics());
967        }
968        if (mInnerRadius == 0.0f) {
969            mInnerRadius = mHandleDrawable.getWidth() / 10.0f;
970        }
971    }
972
973    private void computeInsets(int dx, int dy) {
974        final int layoutDirection = getLayoutDirection();
975        final int absoluteGravity = Gravity.getAbsoluteGravity(mGravity, layoutDirection);
976
977        switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
978            case Gravity.LEFT:
979                mHorizontalInset = 0;
980                break;
981            case Gravity.RIGHT:
982                mHorizontalInset = dx;
983                break;
984            case Gravity.CENTER_HORIZONTAL:
985            default:
986                mHorizontalInset = dx / 2;
987                break;
988        }
989        switch (absoluteGravity & Gravity.VERTICAL_GRAVITY_MASK) {
990            case Gravity.TOP:
991                mVerticalInset = 0;
992                break;
993            case Gravity.BOTTOM:
994                mVerticalInset = dy;
995                break;
996            case Gravity.CENTER_VERTICAL:
997            default:
998                mVerticalInset = dy / 2;
999                break;
1000        }
1001    }
1002
1003    @Override
1004    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
1005        super.onLayout(changed, left, top, right, bottom);
1006        final int width = right - left;
1007        final int height = bottom - top;
1008
1009        // Target placement width/height. This puts the targets on the greater of the ring
1010        // width or the specified outer radius.
1011        final float placementWidth = Math.max(mOuterRing.getWidth(), 2 * mOuterRadius);
1012        final float placementHeight = Math.max(mOuterRing.getHeight(), 2 * mOuterRadius);
1013        float newWaveCenterX = mHorizontalInset
1014                + Math.max(width, mMaxTargetWidth + placementWidth) / 2;
1015        float newWaveCenterY = mVerticalInset
1016                + Math.max(height, + mMaxTargetHeight + placementHeight) / 2;
1017
1018        if (mInitialLayout) {
1019            stopAndHideWaveAnimation();
1020            hideTargets(false, false);
1021            mInitialLayout = false;
1022        }
1023
1024        mOuterRing.setPositionX(newWaveCenterX);
1025        mOuterRing.setPositionY(newWaveCenterY);
1026
1027        mHandleDrawable.setPositionX(newWaveCenterX);
1028        mHandleDrawable.setPositionY(newWaveCenterY);
1029
1030        updateTargetPositions(newWaveCenterX, newWaveCenterY);
1031        updatePointCloudPosition(newWaveCenterX, newWaveCenterY);
1032        updateGlowPosition(newWaveCenterX, newWaveCenterY);
1033
1034        mWaveCenterX = newWaveCenterX;
1035        mWaveCenterY = newWaveCenterY;
1036
1037        if (DEBUG) dump();
1038    }
1039
1040    private void updateTargetPositions(float centerX, float centerY) {
1041        // Reposition the target drawables if the view changed.
1042        ArrayList<TargetDrawable> targets = mTargetDrawables;
1043        final int size = targets.size();
1044        final float alpha = (float) (-2.0f * Math.PI / size);
1045        for (int i = 0; i < size; i++) {
1046            final TargetDrawable targetIcon = targets.get(i);
1047            final float angle = alpha * i;
1048            targetIcon.setPositionX(centerX);
1049            targetIcon.setPositionY(centerY);
1050            targetIcon.setX(mOuterRadius * (float) Math.cos(angle));
1051            targetIcon.setY(mOuterRadius * (float) Math.sin(angle));
1052        }
1053    }
1054
1055    private void updatePointCloudPosition(float centerX, float centerY) {
1056        mPointCloud.setCenter(centerX, centerY);
1057    }
1058
1059    @Override
1060    protected void onDraw(Canvas canvas) {
1061        mPointCloud.draw(canvas);
1062        mOuterRing.draw(canvas);
1063        final int ntargets = mTargetDrawables.size();
1064        for (int i = 0; i < ntargets; i++) {
1065            TargetDrawable target = mTargetDrawables.get(i);
1066            if (target != null) {
1067                target.draw(canvas);
1068            }
1069        }
1070        mHandleDrawable.draw(canvas);
1071    }
1072
1073    public void setOnTriggerListener(OnTriggerListener listener) {
1074        mOnTriggerListener = listener;
1075    }
1076
1077    private float square(float d) {
1078        return d * d;
1079    }
1080
1081    private float dist2(float dx, float dy) {
1082        return dx*dx + dy*dy;
1083    }
1084
1085    private float getScaledGlowRadiusSquared() {
1086        final float scaledTapRadius;
1087        if (AccessibilityManager.getInstance(mContext).isEnabled()) {
1088            scaledTapRadius = TAP_RADIUS_SCALE_ACCESSIBILITY_ENABLED * mGlowRadius;
1089        } else {
1090            scaledTapRadius = mGlowRadius;
1091        }
1092        return square(scaledTapRadius);
1093    }
1094
1095    private void announceTargets() {
1096        StringBuilder utterance = new StringBuilder();
1097        final int targetCount = mTargetDrawables.size();
1098        for (int i = 0; i < targetCount; i++) {
1099            String targetDescription = getTargetDescription(i);
1100            String directionDescription = getDirectionDescription(i);
1101            if (!TextUtils.isEmpty(targetDescription)
1102                    && !TextUtils.isEmpty(directionDescription)) {
1103                String text = String.format(directionDescription, targetDescription);
1104                utterance.append(text);
1105            }
1106        }
1107        if (utterance.length() > 0) {
1108            announceForAccessibility(utterance.toString());
1109        }
1110    }
1111
1112    private String getTargetDescription(int index) {
1113        if (mTargetDescriptions == null || mTargetDescriptions.isEmpty()) {
1114            mTargetDescriptions = loadDescriptions(mTargetDescriptionsResourceId);
1115            if (mTargetDrawables.size() != mTargetDescriptions.size()) {
1116                Log.w(TAG, "The number of target drawables must be"
1117                        + " equal to the number of target descriptions.");
1118                return null;
1119            }
1120        }
1121        return mTargetDescriptions.get(index);
1122    }
1123
1124    private String getDirectionDescription(int index) {
1125        if (mDirectionDescriptions == null || mDirectionDescriptions.isEmpty()) {
1126            mDirectionDescriptions = loadDescriptions(mDirectionDescriptionsResourceId);
1127            if (mTargetDrawables.size() != mDirectionDescriptions.size()) {
1128                Log.w(TAG, "The number of target drawables must be"
1129                        + " equal to the number of direction descriptions.");
1130                return null;
1131            }
1132        }
1133        return mDirectionDescriptions.get(index);
1134    }
1135
1136    private ArrayList<String> loadDescriptions(int resourceId) {
1137        TypedArray array = getContext().getResources().obtainTypedArray(resourceId);
1138        final int count = array.length();
1139        ArrayList<String> targetContentDescriptions = new ArrayList<String>(count);
1140        for (int i = 0; i < count; i++) {
1141            String contentDescription = array.getString(i);
1142            targetContentDescriptions.add(contentDescription);
1143        }
1144        array.recycle();
1145        return targetContentDescriptions;
1146    }
1147
1148    public int getResourceIdForTarget(int index) {
1149        final TargetDrawable drawable = mTargetDrawables.get(index);
1150        return drawable == null ? 0 : drawable.getResourceId();
1151    }
1152
1153    public void setEnableTarget(int resourceId, boolean enabled) {
1154        for (int i = 0; i < mTargetDrawables.size(); i++) {
1155            final TargetDrawable target = mTargetDrawables.get(i);
1156            if (target.getResourceId() == resourceId) {
1157                target.setEnabled(enabled);
1158                break; // should never be more than one match
1159            }
1160        }
1161    }
1162
1163    /**
1164     * Gets the position of a target in the array that matches the given resource.
1165     * @param resourceId
1166     * @return the index or -1 if not found
1167     */
1168    public int getTargetPosition(int resourceId) {
1169        for (int i = 0; i < mTargetDrawables.size(); i++) {
1170            final TargetDrawable target = mTargetDrawables.get(i);
1171            if (target.getResourceId() == resourceId) {
1172                return i; // should never be more than one match
1173            }
1174        }
1175        return -1;
1176    }
1177
1178    private boolean replaceTargetDrawables(Resources res, int existingResourceId,
1179            int newResourceId) {
1180        if (existingResourceId == 0 || newResourceId == 0) {
1181            return false;
1182        }
1183
1184        boolean result = false;
1185        final ArrayList<TargetDrawable> drawables = mTargetDrawables;
1186        final int size = drawables.size();
1187        for (int i = 0; i < size; i++) {
1188            final TargetDrawable target = drawables.get(i);
1189            if (target != null && target.getResourceId() == existingResourceId) {
1190                target.setDrawable(res, newResourceId);
1191                result = true;
1192            }
1193        }
1194
1195        if (result) {
1196            requestLayout(); // in case any given drawable's size changes
1197        }
1198
1199        return result;
1200    }
1201
1202    /**
1203     * Searches the given package for a resource to use to replace the Drawable on the
1204     * target with the given resource id
1205     * @param component of the .apk that contains the resource
1206     * @param name of the metadata in the .apk
1207     * @param existingResId the resource id of the target to search for
1208     * @return true if found in the given package and replaced at least one target Drawables
1209     */
1210    public boolean replaceTargetDrawablesIfPresent(ComponentName component, String name,
1211                int existingResId) {
1212        if (existingResId == 0) return false;
1213
1214        boolean replaced = false;
1215        if (component != null) {
1216            try {
1217                PackageManager packageManager = mContext.getPackageManager();
1218                // Look for the search icon specified in the activity meta-data
1219                Bundle metaData = packageManager.getActivityInfo(
1220                        component, PackageManager.GET_META_DATA).metaData;
1221                if (metaData != null) {
1222                    int iconResId = metaData.getInt(name);
1223                    if (iconResId != 0) {
1224                        Resources res = packageManager.getResourcesForActivity(component);
1225                        replaced = replaceTargetDrawables(res, existingResId, iconResId);
1226                    }
1227                }
1228            } catch (NameNotFoundException e) {
1229                Log.w(TAG, "Failed to swap drawable; "
1230                        + component.flattenToShortString() + " not found", e);
1231            } catch (Resources.NotFoundException nfe) {
1232                Log.w(TAG, "Failed to swap drawable from "
1233                        + component.flattenToShortString(), nfe);
1234            }
1235        }
1236        if (!replaced) {
1237            // Restore the original drawable
1238            replaceTargetDrawables(mContext.getResources(), existingResId, existingResId);
1239        }
1240        return replaced;
1241    }
1242}
1243