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