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