1 /* 2 * Copyright (C) 2016 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.incallui.answer.impl.affordance; 18 19 import android.animation.Animator; 20 import android.animation.AnimatorListenerAdapter; 21 import android.animation.ValueAnimator; 22 import android.content.Context; 23 import android.support.annotation.Nullable; 24 import android.view.MotionEvent; 25 import android.view.VelocityTracker; 26 import android.view.View; 27 import android.view.ViewConfiguration; 28 import com.android.incallui.answer.impl.utils.FlingAnimationUtils; 29 import com.android.incallui.answer.impl.utils.Interpolators; 30 31 /** A touch handler of the swipe buttons */ 32 public class SwipeButtonHelper { 33 34 public static final float SWIPE_RESTING_ALPHA_AMOUNT = 0.87f; 35 public static final long HINT_PHASE1_DURATION = 200; 36 private static final long HINT_PHASE2_DURATION = 350; 37 private static final float BACKGROUND_RADIUS_SCALE_FACTOR = 0.25f; 38 private static final int HINT_CIRCLE_OPEN_DURATION = 500; 39 40 private final Context context; 41 private final Callback callback; 42 43 private FlingAnimationUtils flingAnimationUtils; 44 private VelocityTracker velocityTracker; 45 private boolean swipingInProgress; 46 private float initialTouchX; 47 private float initialTouchY; 48 private float translation; 49 private float translationOnDown; 50 private int touchSlop; 51 private int minTranslationAmount; 52 private int minFlingVelocity; 53 private int hintGrowAmount; 54 @Nullable private SwipeButtonView leftIcon; 55 @Nullable private SwipeButtonView rightIcon; 56 private Animator swipeAnimator; 57 private int minBackgroundRadius; 58 private boolean motionCancelled; 59 private int touchTargetSize; 60 private View targetedView; 61 private boolean touchSlopExeeded; 62 private AnimatorListenerAdapter flingEndListener = 63 new AnimatorListenerAdapter() { 64 @Override 65 public void onAnimationEnd(Animator animation) { 66 swipeAnimator = null; 67 swipingInProgress = false; 68 targetedView = null; 69 } 70 }; 71 72 private class AnimationEndRunnable implements Runnable { 73 private final boolean rightPage; 74 AnimationEndRunnable(boolean rightPage)75 public AnimationEndRunnable(boolean rightPage) { 76 this.rightPage = rightPage; 77 } 78 79 @Override run()80 public void run() { 81 callback.onAnimationToSideEnded(rightPage); 82 } 83 }; 84 SwipeButtonHelper(Callback callback, Context context)85 public SwipeButtonHelper(Callback callback, Context context) { 86 this.context = context; 87 this.callback = callback; 88 init(); 89 } 90 init()91 public void init() { 92 initIcons(); 93 updateIcon( 94 leftIcon, 95 0.0f, 96 leftIcon != null ? leftIcon.getRestingAlpha() : 0, 97 false, 98 false, 99 true, 100 false); 101 updateIcon( 102 rightIcon, 103 0.0f, 104 rightIcon != null ? rightIcon.getRestingAlpha() : 0, 105 false, 106 false, 107 true, 108 false); 109 initDimens(); 110 } 111 initDimens()112 private void initDimens() { 113 final ViewConfiguration configuration = ViewConfiguration.get(context); 114 touchSlop = configuration.getScaledPagingTouchSlop(); 115 minFlingVelocity = configuration.getScaledMinimumFlingVelocity(); 116 minTranslationAmount = 117 context.getResources().getDimensionPixelSize(R.dimen.answer_min_swipe_amount); 118 minBackgroundRadius = 119 context 120 .getResources() 121 .getDimensionPixelSize(R.dimen.answer_affordance_min_background_radius); 122 touchTargetSize = 123 context.getResources().getDimensionPixelSize(R.dimen.answer_affordance_touch_target_size); 124 hintGrowAmount = 125 context.getResources().getDimensionPixelSize(R.dimen.hint_grow_amount_sideways); 126 flingAnimationUtils = new FlingAnimationUtils(context, 0.4f); 127 } 128 initIcons()129 private void initIcons() { 130 leftIcon = callback.getLeftIcon(); 131 rightIcon = callback.getRightIcon(); 132 updatePreviews(); 133 } 134 updatePreviews()135 public void updatePreviews() { 136 if (leftIcon != null) { 137 leftIcon.setPreviewView(callback.getLeftPreview()); 138 } 139 if (rightIcon != null) { 140 rightIcon.setPreviewView(callback.getRightPreview()); 141 } 142 } 143 onTouchEvent(MotionEvent event)144 public boolean onTouchEvent(MotionEvent event) { 145 int action = event.getActionMasked(); 146 if (motionCancelled && action != MotionEvent.ACTION_DOWN) { 147 return false; 148 } 149 final float y = event.getY(); 150 final float x = event.getX(); 151 152 boolean isUp = false; 153 switch (action) { 154 case MotionEvent.ACTION_DOWN: 155 View targetView = getIconAtPosition(x, y); 156 if (targetView == null || (targetedView != null && targetedView != targetView)) { 157 motionCancelled = true; 158 return false; 159 } 160 if (targetedView != null) { 161 cancelAnimation(); 162 } else { 163 touchSlopExeeded = false; 164 } 165 startSwiping(targetView); 166 initialTouchX = x; 167 initialTouchY = y; 168 translationOnDown = translation; 169 initVelocityTracker(); 170 trackMovement(event); 171 motionCancelled = false; 172 break; 173 case MotionEvent.ACTION_POINTER_DOWN: 174 motionCancelled = true; 175 endMotion(true /* forceSnapBack */, x, y); 176 break; 177 case MotionEvent.ACTION_MOVE: 178 trackMovement(event); 179 float xDist = x - initialTouchX; 180 float yDist = y - initialTouchY; 181 float distance = (float) Math.hypot(xDist, yDist); 182 if (!touchSlopExeeded && distance > touchSlop) { 183 touchSlopExeeded = true; 184 } 185 if (swipingInProgress) { 186 if (targetedView == rightIcon) { 187 distance = translationOnDown - distance; 188 distance = Math.min(0, distance); 189 } else { 190 distance = translationOnDown + distance; 191 distance = Math.max(0, distance); 192 } 193 setTranslation(distance, false /* isReset */, false /* animateReset */); 194 } 195 break; 196 197 case MotionEvent.ACTION_UP: 198 isUp = true; 199 // fall through 200 case MotionEvent.ACTION_CANCEL: 201 boolean hintOnTheRight = targetedView == rightIcon; 202 trackMovement(event); 203 endMotion(!isUp, x, y); 204 if (!touchSlopExeeded && isUp) { 205 callback.onIconClicked(hintOnTheRight); 206 } 207 break; 208 } 209 return true; 210 } 211 startSwiping(View targetView)212 private void startSwiping(View targetView) { 213 callback.onSwipingStarted(targetView == rightIcon); 214 swipingInProgress = true; 215 targetedView = targetView; 216 } 217 getIconAtPosition(float x, float y)218 private View getIconAtPosition(float x, float y) { 219 if (leftSwipePossible() && isOnIcon(leftIcon, x, y)) { 220 return leftIcon; 221 } 222 if (rightSwipePossible() && isOnIcon(rightIcon, x, y)) { 223 return rightIcon; 224 } 225 return null; 226 } 227 isOnAffordanceIcon(float x, float y)228 public boolean isOnAffordanceIcon(float x, float y) { 229 return isOnIcon(leftIcon, x, y) || isOnIcon(rightIcon, x, y); 230 } 231 isOnIcon(View icon, float x, float y)232 private boolean isOnIcon(View icon, float x, float y) { 233 float iconX = icon.getX() + icon.getWidth() / 2.0f; 234 float iconY = icon.getY() + icon.getHeight() / 2.0f; 235 double distance = Math.hypot(x - iconX, y - iconY); 236 return distance <= touchTargetSize / 2; 237 } 238 endMotion(boolean forceSnapBack, float lastX, float lastY)239 private void endMotion(boolean forceSnapBack, float lastX, float lastY) { 240 if (swipingInProgress) { 241 flingWithCurrentVelocity(forceSnapBack, lastX, lastY); 242 } else { 243 targetedView = null; 244 } 245 if (velocityTracker != null) { 246 velocityTracker.recycle(); 247 velocityTracker = null; 248 } 249 } 250 rightSwipePossible()251 private boolean rightSwipePossible() { 252 return rightIcon != null && rightIcon.getVisibility() == View.VISIBLE; 253 } 254 leftSwipePossible()255 private boolean leftSwipePossible() { 256 return leftIcon != null && leftIcon.getVisibility() == View.VISIBLE; 257 } 258 startHintAnimation(boolean right, @Nullable Runnable onFinishedListener)259 public void startHintAnimation(boolean right, @Nullable Runnable onFinishedListener) { 260 cancelAnimation(); 261 startHintAnimationPhase1(right, onFinishedListener); 262 } 263 startHintAnimationPhase1( final boolean right, @Nullable final Runnable onFinishedListener)264 private void startHintAnimationPhase1( 265 final boolean right, @Nullable final Runnable onFinishedListener) { 266 final SwipeButtonView targetView = right ? rightIcon : leftIcon; 267 ValueAnimator animator = getAnimatorToRadius(right, hintGrowAmount); 268 if (animator == null) { 269 if (onFinishedListener != null) { 270 onFinishedListener.run(); 271 } 272 return; 273 } 274 animator.addListener( 275 new AnimatorListenerAdapter() { 276 private boolean cancelled; 277 278 @Override 279 public void onAnimationCancel(Animator animation) { 280 cancelled = true; 281 } 282 283 @Override 284 public void onAnimationEnd(Animator animation) { 285 if (cancelled) { 286 swipeAnimator = null; 287 targetedView = null; 288 if (onFinishedListener != null) { 289 onFinishedListener.run(); 290 } 291 } else { 292 startUnlockHintAnimationPhase2(right, onFinishedListener); 293 } 294 } 295 }); 296 animator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN); 297 animator.setDuration(HINT_PHASE1_DURATION); 298 animator.start(); 299 swipeAnimator = animator; 300 targetedView = targetView; 301 } 302 303 /** Phase 2: Move back. */ startUnlockHintAnimationPhase2( boolean right, @Nullable final Runnable onFinishedListener)304 private void startUnlockHintAnimationPhase2( 305 boolean right, @Nullable final Runnable onFinishedListener) { 306 ValueAnimator animator = getAnimatorToRadius(right, 0); 307 if (animator == null) { 308 if (onFinishedListener != null) { 309 onFinishedListener.run(); 310 } 311 return; 312 } 313 animator.addListener( 314 new AnimatorListenerAdapter() { 315 @Override 316 public void onAnimationEnd(Animator animation) { 317 swipeAnimator = null; 318 targetedView = null; 319 if (onFinishedListener != null) { 320 onFinishedListener.run(); 321 } 322 } 323 }); 324 animator.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN); 325 animator.setDuration(HINT_PHASE2_DURATION); 326 animator.setStartDelay(HINT_CIRCLE_OPEN_DURATION); 327 animator.start(); 328 swipeAnimator = animator; 329 } 330 getAnimatorToRadius(final boolean right, int radius)331 private ValueAnimator getAnimatorToRadius(final boolean right, int radius) { 332 final SwipeButtonView targetView = right ? rightIcon : leftIcon; 333 if (targetView == null) { 334 return null; 335 } 336 ValueAnimator animator = ValueAnimator.ofFloat(targetView.getCircleRadius(), radius); 337 animator.addUpdateListener( 338 new ValueAnimator.AnimatorUpdateListener() { 339 @Override 340 public void onAnimationUpdate(ValueAnimator animation) { 341 float newRadius = (float) animation.getAnimatedValue(); 342 targetView.setCircleRadiusWithoutAnimation(newRadius); 343 float translation = getTranslationFromRadius(newRadius); 344 SwipeButtonHelper.this.translation = right ? -translation : translation; 345 updateIconsFromTranslation(targetView); 346 } 347 }); 348 return animator; 349 } 350 cancelAnimation()351 private void cancelAnimation() { 352 if (swipeAnimator != null) { 353 swipeAnimator.cancel(); 354 } 355 } 356 flingWithCurrentVelocity(boolean forceSnapBack, float lastX, float lastY)357 private void flingWithCurrentVelocity(boolean forceSnapBack, float lastX, float lastY) { 358 float vel = getCurrentVelocity(lastX, lastY); 359 360 // We snap back if the current translation is not far enough 361 boolean snapBack = isBelowFalsingThreshold(); 362 363 // or if the velocity is in the opposite direction. 364 boolean velIsInWrongDirection = vel * translation < 0; 365 snapBack |= Math.abs(vel) > minFlingVelocity && velIsInWrongDirection; 366 vel = snapBack ^ velIsInWrongDirection ? 0 : vel; 367 fling(vel, snapBack || forceSnapBack, translation < 0); 368 } 369 isBelowFalsingThreshold()370 private boolean isBelowFalsingThreshold() { 371 return Math.abs(translation) < Math.abs(translationOnDown) + getMinTranslationAmount(); 372 } 373 getMinTranslationAmount()374 private int getMinTranslationAmount() { 375 float factor = callback.getAffordanceFalsingFactor(); 376 return (int) (minTranslationAmount * factor); 377 } 378 fling(float vel, final boolean snapBack, boolean right)379 private void fling(float vel, final boolean snapBack, boolean right) { 380 float target = 381 right ? -callback.getMaxTranslationDistance() : callback.getMaxTranslationDistance(); 382 target = snapBack ? 0 : target; 383 384 ValueAnimator animator = ValueAnimator.ofFloat(translation, target); 385 flingAnimationUtils.apply(animator, translation, target, vel); 386 animator.addUpdateListener( 387 new ValueAnimator.AnimatorUpdateListener() { 388 @Override 389 public void onAnimationUpdate(ValueAnimator animation) { 390 translation = (float) animation.getAnimatedValue(); 391 } 392 }); 393 animator.addListener(flingEndListener); 394 if (!snapBack) { 395 startFinishingCircleAnimation(vel * 0.375f, new AnimationEndRunnable(right), right); 396 callback.onAnimationToSideStarted(right, translation, vel); 397 } else { 398 reset(true); 399 } 400 animator.start(); 401 swipeAnimator = animator; 402 if (snapBack) { 403 callback.onSwipingAborted(); 404 } 405 } 406 startFinishingCircleAnimation( float velocity, Runnable mAnimationEndRunnable, boolean right)407 private void startFinishingCircleAnimation( 408 float velocity, Runnable mAnimationEndRunnable, boolean right) { 409 SwipeButtonView targetView = right ? rightIcon : leftIcon; 410 if (targetView != null) { 411 targetView.finishAnimation(velocity, mAnimationEndRunnable); 412 } 413 } 414 setTranslation(float translation, boolean isReset, boolean animateReset)415 private void setTranslation(float translation, boolean isReset, boolean animateReset) { 416 translation = rightSwipePossible() ? translation : Math.max(0, translation); 417 translation = leftSwipePossible() ? translation : Math.min(0, translation); 418 float absTranslation = Math.abs(translation); 419 if (translation != this.translation || isReset) { 420 SwipeButtonView targetView = translation > 0 ? leftIcon : rightIcon; 421 SwipeButtonView otherView = translation > 0 ? rightIcon : leftIcon; 422 float alpha = absTranslation / getMinTranslationAmount(); 423 424 // We interpolate the alpha of the other icons to 0 425 float fadeOutAlpha = 1.0f - alpha; 426 fadeOutAlpha = Math.max(fadeOutAlpha, 0.0f); 427 428 boolean animateIcons = isReset && animateReset; 429 boolean forceNoCircleAnimation = isReset && !animateReset; 430 float radius = getRadiusFromTranslation(absTranslation); 431 boolean slowAnimation = isReset && isBelowFalsingThreshold(); 432 if (targetView != null) { 433 if (!isReset) { 434 updateIcon( 435 targetView, 436 radius, 437 alpha + fadeOutAlpha * targetView.getRestingAlpha(), 438 false, 439 false, 440 false, 441 false); 442 } else { 443 updateIcon( 444 targetView, 445 0.0f, 446 fadeOutAlpha * targetView.getRestingAlpha(), 447 animateIcons, 448 slowAnimation, 449 false, 450 forceNoCircleAnimation); 451 } 452 } 453 if (otherView != null) { 454 updateIcon( 455 otherView, 456 0.0f, 457 fadeOutAlpha * otherView.getRestingAlpha(), 458 animateIcons, 459 slowAnimation, 460 false, 461 forceNoCircleAnimation); 462 } 463 464 this.translation = translation; 465 } 466 } 467 updateIconsFromTranslation(SwipeButtonView targetView)468 private void updateIconsFromTranslation(SwipeButtonView targetView) { 469 float absTranslation = Math.abs(translation); 470 float alpha = absTranslation / getMinTranslationAmount(); 471 472 // We interpolate the alpha of the other icons to 0 473 float fadeOutAlpha = 1.0f - alpha; 474 fadeOutAlpha = Math.max(0.0f, fadeOutAlpha); 475 476 // We interpolate the alpha of the targetView to 1 477 SwipeButtonView otherView = targetView == rightIcon ? leftIcon : rightIcon; 478 updateIconAlpha(targetView, alpha + fadeOutAlpha * targetView.getRestingAlpha(), false); 479 if (otherView != null) { 480 updateIconAlpha(otherView, fadeOutAlpha * otherView.getRestingAlpha(), false); 481 } 482 } 483 getTranslationFromRadius(float circleSize)484 private float getTranslationFromRadius(float circleSize) { 485 float translation = (circleSize - minBackgroundRadius) / BACKGROUND_RADIUS_SCALE_FACTOR; 486 return translation > 0.0f ? translation + touchSlop : 0.0f; 487 } 488 getRadiusFromTranslation(float translation)489 private float getRadiusFromTranslation(float translation) { 490 if (translation <= touchSlop) { 491 return 0.0f; 492 } 493 return (translation - touchSlop) * BACKGROUND_RADIUS_SCALE_FACTOR + minBackgroundRadius; 494 } 495 animateHideLeftRightIcon()496 public void animateHideLeftRightIcon() { 497 cancelAnimation(); 498 updateIcon(rightIcon, 0f, 0f, true, false, false, false); 499 updateIcon(leftIcon, 0f, 0f, true, false, false, false); 500 } 501 updateIcon( @ullable SwipeButtonView view, float circleRadius, float alpha, boolean animate, boolean slowRadiusAnimation, boolean force, boolean forceNoCircleAnimation)502 private void updateIcon( 503 @Nullable SwipeButtonView view, 504 float circleRadius, 505 float alpha, 506 boolean animate, 507 boolean slowRadiusAnimation, 508 boolean force, 509 boolean forceNoCircleAnimation) { 510 if (view == null) { 511 return; 512 } 513 if (view.getVisibility() != View.VISIBLE && !force) { 514 return; 515 } 516 if (forceNoCircleAnimation) { 517 view.setCircleRadiusWithoutAnimation(circleRadius); 518 } else { 519 view.setCircleRadius(circleRadius, slowRadiusAnimation); 520 } 521 updateIconAlpha(view, alpha, animate); 522 } 523 updateIconAlpha(SwipeButtonView view, float alpha, boolean animate)524 private void updateIconAlpha(SwipeButtonView view, float alpha, boolean animate) { 525 float scale = getScale(alpha, view); 526 alpha = Math.min(1.0f, alpha); 527 view.setImageAlpha(alpha, animate); 528 view.setImageScale(scale, animate); 529 } 530 getScale(float alpha, SwipeButtonView icon)531 private float getScale(float alpha, SwipeButtonView icon) { 532 float scale = alpha / icon.getRestingAlpha() * 0.2f + SwipeButtonView.MIN_ICON_SCALE_AMOUNT; 533 return Math.min(scale, SwipeButtonView.MAX_ICON_SCALE_AMOUNT); 534 } 535 trackMovement(MotionEvent event)536 private void trackMovement(MotionEvent event) { 537 if (velocityTracker != null) { 538 velocityTracker.addMovement(event); 539 } 540 } 541 initVelocityTracker()542 private void initVelocityTracker() { 543 if (velocityTracker != null) { 544 velocityTracker.recycle(); 545 } 546 velocityTracker = VelocityTracker.obtain(); 547 } 548 getCurrentVelocity(float lastX, float lastY)549 private float getCurrentVelocity(float lastX, float lastY) { 550 if (velocityTracker == null) { 551 return 0; 552 } 553 velocityTracker.computeCurrentVelocity(1000); 554 float aX = velocityTracker.getXVelocity(); 555 float aY = velocityTracker.getYVelocity(); 556 float bX = lastX - initialTouchX; 557 float bY = lastY - initialTouchY; 558 float bLen = (float) Math.hypot(bX, bY); 559 // Project the velocity onto the distance vector: a * b / |b| 560 float projectedVelocity = (aX * bX + aY * bY) / bLen; 561 if (targetedView == rightIcon) { 562 projectedVelocity = -projectedVelocity; 563 } 564 return projectedVelocity; 565 } 566 onConfigurationChanged()567 public void onConfigurationChanged() { 568 initDimens(); 569 initIcons(); 570 } 571 onRtlPropertiesChanged()572 public void onRtlPropertiesChanged() { 573 initIcons(); 574 } 575 reset(boolean animate)576 public void reset(boolean animate) { 577 cancelAnimation(); 578 setTranslation(0.0f, true, animate); 579 motionCancelled = true; 580 if (swipingInProgress) { 581 callback.onSwipingAborted(); 582 swipingInProgress = false; 583 } 584 } 585 isSwipingInProgress()586 public boolean isSwipingInProgress() { 587 return swipingInProgress; 588 } 589 launchAffordance(boolean animate, boolean left)590 public void launchAffordance(boolean animate, boolean left) { 591 SwipeButtonView targetView = left ? leftIcon : rightIcon; 592 if (swipingInProgress || targetView == null) { 593 // We don't want to mess with the state if the user is actually swiping already. 594 return; 595 } 596 SwipeButtonView otherView = left ? rightIcon : leftIcon; 597 startSwiping(targetView); 598 if (animate) { 599 fling(0, false, !left); 600 updateIcon(otherView, 0.0f, 0, true, false, true, false); 601 } else { 602 callback.onAnimationToSideStarted(!left, translation, 0); 603 translation = 604 left ? callback.getMaxTranslationDistance() : callback.getMaxTranslationDistance(); 605 updateIcon(otherView, 0.0f, 0.0f, false, false, true, false); 606 targetView.instantFinishAnimation(); 607 flingEndListener.onAnimationEnd(null); 608 new AnimationEndRunnable(!left).run(); 609 } 610 } 611 612 /** Callback interface for various actions */ 613 public interface Callback { 614 615 /** 616 * Notifies the callback when an animation to a side page was started. 617 * 618 * @param rightPage Is the page animated to the right page? 619 */ onAnimationToSideStarted(boolean rightPage, float translation, float vel)620 void onAnimationToSideStarted(boolean rightPage, float translation, float vel); 621 622 /** Notifies the callback the animation to a side page has ended. */ onAnimationToSideEnded(boolean rightPage)623 void onAnimationToSideEnded(boolean rightPage); 624 getMaxTranslationDistance()625 float getMaxTranslationDistance(); 626 onSwipingStarted(boolean rightIcon)627 void onSwipingStarted(boolean rightIcon); 628 onSwipingAborted()629 void onSwipingAborted(); 630 onIconClicked(boolean rightIcon)631 void onIconClicked(boolean rightIcon); 632 633 @Nullable getLeftIcon()634 SwipeButtonView getLeftIcon(); 635 636 @Nullable getRightIcon()637 SwipeButtonView getRightIcon(); 638 639 @Nullable getLeftPreview()640 View getLeftPreview(); 641 642 @Nullable getRightPreview()643 View getRightPreview(); 644 645 /** @return The factor the minimum swipe amount should be multiplied with. */ getAffordanceFalsingFactor()646 float getAffordanceFalsingFactor(); 647 } 648 } 649