FlingUpDownMethod.java revision 1019500220518fb5fb023fcb7d370ab3cbf12307
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 17package com.android.incallui.answer.impl.answermethod; 18 19import android.animation.Animator; 20import android.animation.AnimatorListenerAdapter; 21import android.animation.AnimatorSet; 22import android.animation.ObjectAnimator; 23import android.animation.PropertyValuesHolder; 24import android.animation.ValueAnimator; 25import android.annotation.SuppressLint; 26import android.content.Context; 27import android.content.res.ColorStateList; 28import android.graphics.PorterDuff.Mode; 29import android.graphics.drawable.Drawable; 30import android.os.Bundle; 31import android.support.annotation.ColorInt; 32import android.support.annotation.FloatRange; 33import android.support.annotation.IntDef; 34import android.support.annotation.NonNull; 35import android.support.annotation.Nullable; 36import android.support.annotation.VisibleForTesting; 37import android.support.v4.graphics.ColorUtils; 38import android.support.v4.view.animation.FastOutLinearInInterpolator; 39import android.support.v4.view.animation.FastOutSlowInInterpolator; 40import android.support.v4.view.animation.LinearOutSlowInInterpolator; 41import android.support.v4.view.animation.PathInterpolatorCompat; 42import android.view.LayoutInflater; 43import android.view.MotionEvent; 44import android.view.View; 45import android.view.View.AccessibilityDelegate; 46import android.view.ViewGroup; 47import android.view.accessibility.AccessibilityNodeInfo; 48import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; 49import android.view.animation.BounceInterpolator; 50import android.view.animation.DecelerateInterpolator; 51import android.view.animation.Interpolator; 52import android.widget.ImageView; 53import android.widget.TextView; 54import com.android.dialer.common.DpUtil; 55import com.android.dialer.common.LogUtil; 56import com.android.dialer.common.MathUtil; 57import com.android.dialer.util.DrawableConverter; 58import com.android.dialer.util.ViewUtil; 59import com.android.incallui.answer.impl.answermethod.FlingUpDownTouchHandler.OnProgressChangedListener; 60import com.android.incallui.answer.impl.classifier.FalsingManager; 61import com.android.incallui.answer.impl.hint.AnswerHint; 62import com.android.incallui.answer.impl.hint.AnswerHintFactory; 63import com.android.incallui.answer.impl.hint.PawImageLoaderImpl; 64import java.lang.annotation.Retention; 65import java.lang.annotation.RetentionPolicy; 66 67/** Answer method that swipes up to answer or down to reject. */ 68@SuppressLint("ClickableViewAccessibility") 69public class FlingUpDownMethod extends AnswerMethod implements OnProgressChangedListener { 70 71 private static final float SWIPE_LERP_PROGRESS_FACTOR = 0.5f; 72 private static final long ANIMATE_DURATION_SHORT_MILLIS = 667; 73 private static final long ANIMATE_DURATION_NORMAL_MILLIS = 1_333; 74 private static final long ANIMATE_DURATION_LONG_MILLIS = 1_500; 75 private static final long BOUNCE_ANIMATION_DELAY = 167; 76 private static final long VIBRATION_TIME_MILLIS = 1_833; 77 private static final long SETTLE_ANIMATION_DURATION_MILLIS = 100; 78 private static final int HINT_JUMP_DP = 60; 79 private static final int HINT_DIP_DP = 8; 80 private static final float HINT_SCALE_RATIO = 1.15f; 81 private static final long SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS = 333; 82 private static final int HINT_REJECT_SHOW_DURATION_MILLIS = 2000; 83 private static final int ICON_END_CALL_ROTATION_DEGREES = 135; 84 private static final int HINT_REJECT_FADE_TRANSLATION_Y_DP = -8; 85 private static final float SWIPE_TO_ANSWER_MAX_TRANSLATION_Y_DP = 150; 86 private static final int SWIPE_TO_REJECT_MAX_TRANSLATION_Y_DP = 24; 87 88 @Retention(RetentionPolicy.SOURCE) 89 @IntDef( 90 value = { 91 AnimationState.NONE, 92 AnimationState.ENTRY, 93 AnimationState.BOUNCE, 94 AnimationState.SWIPE, 95 AnimationState.SETTLE, 96 AnimationState.HINT, 97 AnimationState.COMPLETED 98 } 99 ) 100 @VisibleForTesting 101 @interface AnimationState { 102 103 int NONE = 0; 104 int ENTRY = 1; // Entry animation for incoming call 105 int BOUNCE = 2; // An idle state in which text and icon slightly bounces off its base repeatedly 106 int SWIPE = 3; // A special state in which text and icon follows the finger movement 107 int SETTLE = 4; // A short animation to reset from swipe and prepare for hint or bounce 108 int HINT = 5; // Jump animation to suggest what to do 109 int COMPLETED = 6; // Animation loop completed. Occurs after user swipes beyond threshold 110 } 111 112 private static void moveTowardY(View view, float newY) { 113 view.setTranslationY(MathUtil.lerp(view.getTranslationY(), newY, SWIPE_LERP_PROGRESS_FACTOR)); 114 } 115 116 private static void moveTowardX(View view, float newX) { 117 view.setTranslationX(MathUtil.lerp(view.getTranslationX(), newX, SWIPE_LERP_PROGRESS_FACTOR)); 118 } 119 120 private static void fadeToward(View view, float newAlpha) { 121 view.setAlpha(MathUtil.lerp(view.getAlpha(), newAlpha, SWIPE_LERP_PROGRESS_FACTOR)); 122 } 123 124 private static void rotateToward(View view, float newRotation) { 125 view.setRotation(MathUtil.lerp(view.getRotation(), newRotation, SWIPE_LERP_PROGRESS_FACTOR)); 126 } 127 128 private TextView swipeToAnswerText; 129 private TextView swipeToRejectText; 130 private View contactPuckContainer; 131 private ImageView contactPuckBackground; 132 private ImageView contactPuckIcon; 133 private View incomingDisconnectText; 134 private Animator lockBounceAnim; 135 private AnimatorSet lockEntryAnim; 136 private AnimatorSet lockHintAnim; 137 private AnimatorSet lockSettleAnim; 138 @AnimationState private int animationState = AnimationState.NONE; 139 @AnimationState private int afterSettleAnimationState = AnimationState.NONE; 140 // a value for finger swipe progress. -1 or less for "reject"; 1 or more for "accept". 141 private float swipeProgress; 142 private Animator rejectHintHide; 143 private Animator vibrationAnimator; 144 private Drawable contactPhoto; 145 private boolean incomingWillDisconnect; 146 private FlingUpDownTouchHandler touchHandler; 147 private FalsingManager falsingManager; 148 149 private AnswerHint answerHint; 150 151 @Override 152 public void onCreate(@Nullable Bundle bundle) { 153 super.onCreate(bundle); 154 falsingManager = new FalsingManager(getContext()); 155 } 156 157 @Override 158 public void onStart() { 159 super.onStart(); 160 falsingManager.onScreenOn(); 161 if (getView() != null) { 162 if (animationState == AnimationState.SWIPE || animationState == AnimationState.HINT) { 163 swipeProgress = 0; 164 updateContactPuck(); 165 onMoveReset(false); 166 } else if (animationState == AnimationState.ENTRY) { 167 // When starting from the lock screen, the activity may be stopped and started briefly. 168 // Don't let that interrupt the entry animation 169 startSwipeToAnswerEntryAnimation(); 170 } 171 } 172 } 173 174 @Override 175 public void onStop() { 176 endAnimation(); 177 falsingManager.onScreenOff(); 178 if (getActivity().isFinishing()) { 179 setAnimationState(AnimationState.COMPLETED); 180 } 181 super.onStop(); 182 } 183 184 @Nullable 185 @Override 186 public View onCreateView( 187 LayoutInflater layoutInflater, @Nullable ViewGroup viewGroup, @Nullable Bundle bundle) { 188 View view = layoutInflater.inflate(R.layout.swipe_up_down_method, viewGroup, false); 189 190 contactPuckContainer = view.findViewById(R.id.incoming_call_puck_container); 191 contactPuckBackground = (ImageView) view.findViewById(R.id.incoming_call_puck_bg); 192 contactPuckIcon = (ImageView) view.findViewById(R.id.incoming_call_puck_icon); 193 swipeToAnswerText = (TextView) view.findViewById(R.id.incoming_swipe_to_answer_text); 194 swipeToRejectText = (TextView) view.findViewById(R.id.incoming_swipe_to_reject_text); 195 incomingDisconnectText = view.findViewById(R.id.incoming_will_disconnect_text); 196 incomingDisconnectText.setAlpha(incomingWillDisconnect ? 1 : 0); 197 198 view.setAccessibilityDelegate( 199 new AccessibilityDelegate() { 200 @Override 201 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { 202 super.onInitializeAccessibilityNodeInfo(host, info); 203 info.addAction( 204 new AccessibilityAction( 205 R.id.accessibility_action_answer, getString(R.string.call_incoming_answer))); 206 info.addAction( 207 new AccessibilityAction( 208 R.id.accessibility_action_decline, getString(R.string.call_incoming_decline))); 209 } 210 211 @Override 212 public boolean performAccessibilityAction(View host, int action, Bundle args) { 213 if (action == R.id.accessibility_action_answer) { 214 performAccept(); 215 return true; 216 } else if (action == R.id.accessibility_action_decline) { 217 performReject(); 218 return true; 219 } 220 return super.performAccessibilityAction(host, action, args); 221 } 222 }); 223 224 swipeProgress = 0; 225 226 updateContactPuck(); 227 228 touchHandler = FlingUpDownTouchHandler.attach(view, this, falsingManager); 229 230 answerHint = 231 new AnswerHintFactory(new PawImageLoaderImpl()) 232 .create(getContext(), ANIMATE_DURATION_LONG_MILLIS, BOUNCE_ANIMATION_DELAY); 233 answerHint.onCreateView( 234 layoutInflater, 235 (ViewGroup) view.findViewById(R.id.hint_container), 236 contactPuckContainer, 237 swipeToAnswerText); 238 return view; 239 } 240 241 @Override 242 public void onViewCreated(View view, @Nullable Bundle bundle) { 243 super.onViewCreated(view, bundle); 244 setAnimationState(AnimationState.ENTRY); 245 } 246 247 @Override 248 public void onDestroyView() { 249 super.onDestroyView(); 250 if (touchHandler != null) { 251 touchHandler.detach(); 252 touchHandler = null; 253 } 254 } 255 256 @Override 257 public void onProgressChanged(@FloatRange(from = -1f, to = 1f) float progress) { 258 swipeProgress = progress; 259 if (animationState == AnimationState.SWIPE && getContext() != null && isVisible()) { 260 updateSwipeTextAndPuckForTouch(); 261 } 262 } 263 264 @Override 265 public void onTrackingStart() { 266 setAnimationState(AnimationState.SWIPE); 267 } 268 269 @Override 270 public void onTrackingStopped() {} 271 272 @Override 273 public void onMoveReset(boolean showHint) { 274 if (showHint) { 275 showSwipeHint(); 276 } else { 277 setAnimationState(AnimationState.BOUNCE); 278 } 279 resetTouchState(); 280 getParent().resetAnswerProgress(); 281 } 282 283 @Override 284 public void onMoveFinish(boolean accept) { 285 touchHandler.setTouchEnabled(false); 286 answerHint.onAnswered(); 287 if (accept) { 288 performAccept(); 289 } else { 290 performReject(); 291 } 292 } 293 294 @Override 295 public boolean shouldUseFalsing(@NonNull MotionEvent downEvent) { 296 if (contactPuckContainer == null) { 297 return false; 298 } 299 300 float puckCenterX = contactPuckContainer.getX() + (contactPuckContainer.getWidth() / 2); 301 float puckCenterY = contactPuckContainer.getY() + (contactPuckContainer.getHeight() / 2); 302 double radius = contactPuckContainer.getHeight() / 2; 303 304 // Squaring a number is more performant than taking a sqrt, so we compare the square of the 305 // distance with the square of the radius. 306 double distSq = 307 Math.pow(downEvent.getX() - puckCenterX, 2) + Math.pow(downEvent.getY() - puckCenterY, 2); 308 return distSq >= Math.pow(radius, 2); 309 } 310 311 @Override 312 public void setContactPhoto(Drawable contactPhoto) { 313 this.contactPhoto = contactPhoto; 314 315 updateContactPuck(); 316 } 317 318 private void updateContactPuck() { 319 if (contactPuckIcon == null) { 320 return; 321 } 322 if (getParent().isVideoCall() || getParent().isVideoUpgradeRequest()) { 323 contactPuckIcon.setImageResource(R.drawable.quantum_ic_videocam_white_24); 324 } else { 325 contactPuckIcon.setImageResource(R.drawable.quantum_ic_call_white_24); 326 } 327 328 int size = 329 contactPuckBackground 330 .getResources() 331 .getDimensionPixelSize( 332 shouldShowPhotoInPuck() 333 ? R.dimen.answer_contact_puck_size_photo 334 : R.dimen.answer_contact_puck_size_no_photo); 335 contactPuckBackground.setImageDrawable( 336 shouldShowPhotoInPuck() 337 ? makeRoundedDrawable(contactPuckBackground.getContext(), contactPhoto, size) 338 : null); 339 ViewGroup.LayoutParams contactPuckParams = contactPuckBackground.getLayoutParams(); 340 contactPuckParams.height = size; 341 contactPuckParams.width = size; 342 contactPuckBackground.setLayoutParams(contactPuckParams); 343 contactPuckIcon.setAlpha(shouldShowPhotoInPuck() ? 0f : 1f); 344 } 345 346 private Drawable makeRoundedDrawable(Context context, Drawable contactPhoto, int size) { 347 return DrawableConverter.getRoundedDrawable(context, contactPhoto, size, size); 348 } 349 350 private boolean shouldShowPhotoInPuck() { 351 return (getParent().isVideoCall() || getParent().isVideoUpgradeRequest()) 352 && contactPhoto != null; 353 } 354 355 @Override 356 public void setHintText(@Nullable CharSequence hintText) { 357 if (hintText == null) { 358 swipeToAnswerText.setText(R.string.call_incoming_swipe_to_answer); 359 swipeToRejectText.setText(R.string.call_incoming_swipe_to_reject); 360 } else { 361 swipeToAnswerText.setText(hintText); 362 swipeToRejectText.setText(null); 363 } 364 } 365 366 @Override 367 public void setShowIncomingWillDisconnect(boolean incomingWillDisconnect) { 368 this.incomingWillDisconnect = incomingWillDisconnect; 369 if (incomingDisconnectText != null) { 370 incomingDisconnectText.animate().alpha(incomingWillDisconnect ? 1 : 0); 371 } 372 } 373 374 private void showSwipeHint() { 375 setAnimationState(AnimationState.HINT); 376 } 377 378 private void updateSwipeTextAndPuckForTouch() { 379 // Clamp progress value between -1 and 1. 380 final float clampedProgress = MathUtil.clamp(swipeProgress, -1 /* min */, 1 /* max */); 381 final float positiveAdjustedProgress = Math.abs(clampedProgress); 382 final boolean isAcceptingFlow = clampedProgress >= 0; 383 384 // Cancel view property animators on views we're about to mutate 385 swipeToAnswerText.animate().cancel(); 386 contactPuckIcon.animate().cancel(); 387 388 // Since the animation progression is controlled by user gesture instead of real timeline, the 389 // spec timeline can be divided into 9 slots. Each slot is equivalent to 83ms in the spec. 390 // Therefore, we use 9 slots of 83ms to map user gesture into the spec timeline. 391 final float progressSlots = 9; 392 393 // Fade out the "swipe up to answer". It only takes 1 slot to complete the fade. 394 float swipeTextAlpha = Math.max(0, 1 - Math.abs(clampedProgress) * progressSlots); 395 fadeToward(swipeToAnswerText, swipeTextAlpha); 396 // Fade out the "swipe down to dismiss" at the same time. Don't ever increase its alpha 397 fadeToward(swipeToRejectText, Math.min(swipeTextAlpha, swipeToRejectText.getAlpha())); 398 // Fade out the "incoming will disconnect" text 399 fadeToward(incomingDisconnectText, incomingWillDisconnect ? swipeTextAlpha : 0); 400 401 // Move swipe text back to zero. 402 moveTowardX(swipeToAnswerText, 0 /* newX */); 403 moveTowardY(swipeToAnswerText, 0 /* newY */); 404 405 // Animate puck color 406 @ColorInt 407 int destPuckColor = 408 getContext() 409 .getColor( 410 isAcceptingFlow ? R.color.call_accept_background : R.color.call_hangup_background); 411 destPuckColor = 412 ColorUtils.setAlphaComponent(destPuckColor, (int) (0xFF * positiveAdjustedProgress)); 413 contactPuckBackground.setBackgroundTintList(ColorStateList.valueOf(destPuckColor)); 414 contactPuckBackground.setBackgroundTintMode(Mode.SRC_ATOP); 415 contactPuckBackground.setColorFilter(destPuckColor); 416 417 // Animate decline icon 418 if (isAcceptingFlow || getParent().isVideoCall() || getParent().isVideoUpgradeRequest()) { 419 rotateToward(contactPuckIcon, 0f); 420 } else { 421 rotateToward(contactPuckIcon, positiveAdjustedProgress * ICON_END_CALL_ROTATION_DEGREES); 422 } 423 424 // Fade in icon 425 if (shouldShowPhotoInPuck()) { 426 fadeToward(contactPuckIcon, positiveAdjustedProgress); 427 } 428 float iconProgress = Math.min(1f, positiveAdjustedProgress * 4); 429 @ColorInt 430 int iconColor = 431 ColorUtils.setAlphaComponent( 432 contactPuckIcon.getContext().getColor(R.color.incoming_answer_icon), 433 (int) (0xFF * (1 - iconProgress))); 434 contactPuckIcon.setImageTintList(ColorStateList.valueOf(iconColor)); 435 436 // Move puck. 437 if (isAcceptingFlow) { 438 moveTowardY( 439 contactPuckContainer, 440 -clampedProgress * DpUtil.dpToPx(getContext(), SWIPE_TO_ANSWER_MAX_TRANSLATION_Y_DP)); 441 } else { 442 moveTowardY( 443 contactPuckContainer, 444 -clampedProgress * DpUtil.dpToPx(getContext(), SWIPE_TO_REJECT_MAX_TRANSLATION_Y_DP)); 445 } 446 447 getParent().onAnswerProgressUpdate(clampedProgress); 448 } 449 450 private void startSwipeToAnswerSwipeAnimation() { 451 LogUtil.i("FlingUpDownMethod.startSwipeToAnswerSwipeAnimation", "Start swipe animation."); 452 resetTouchState(); 453 endAnimation(); 454 } 455 456 private void setPuckTouchState() { 457 contactPuckBackground.setActivated(touchHandler.isTracking()); 458 } 459 460 private void resetTouchState() { 461 if (getContext() == null) { 462 // State will be reset in onStart(), so just abort. 463 return; 464 } 465 contactPuckContainer.animate().scaleX(1 /* scaleX */); 466 contactPuckContainer.animate().scaleY(1 /* scaleY */); 467 contactPuckBackground.animate().scaleX(1 /* scaleX */); 468 contactPuckBackground.animate().scaleY(1 /* scaleY */); 469 contactPuckBackground.setBackgroundTintList(null); 470 contactPuckBackground.setColorFilter(null); 471 contactPuckIcon.setImageTintList( 472 ColorStateList.valueOf(getContext().getColor(R.color.incoming_answer_icon))); 473 contactPuckIcon.animate().rotation(0); 474 475 getParent().resetAnswerProgress(); 476 setPuckTouchState(); 477 478 final float alpha = 1; 479 swipeToAnswerText.animate().alpha(alpha); 480 contactPuckContainer.animate().alpha(alpha); 481 contactPuckBackground.animate().alpha(alpha); 482 contactPuckIcon.animate().alpha(shouldShowPhotoInPuck() ? 0 : alpha); 483 } 484 485 @VisibleForTesting 486 void setAnimationState(@AnimationState int state) { 487 if (state != AnimationState.HINT && animationState == state) { 488 return; 489 } 490 491 if (animationState == AnimationState.COMPLETED) { 492 LogUtil.e( 493 "FlingUpDownMethod.setAnimationState", 494 "Animation loop has completed. Cannot switch to new state: " + state); 495 return; 496 } 497 498 if (state == AnimationState.HINT || state == AnimationState.BOUNCE) { 499 if (animationState == AnimationState.SWIPE) { 500 afterSettleAnimationState = state; 501 state = AnimationState.SETTLE; 502 } 503 } 504 505 LogUtil.i("FlingUpDownMethod.setAnimationState", "animation state: " + state); 506 animationState = state; 507 508 // Start animation after the current one is finished completely. 509 View view = getView(); 510 if (view != null) { 511 // As long as the fragment is added, we can start update the animation state. 512 if (isAdded() && (animationState == state)) { 513 updateAnimationState(); 514 } else { 515 endAnimation(); 516 } 517 } 518 } 519 520 @AnimationState 521 @VisibleForTesting 522 int getAnimationState() { 523 return animationState; 524 } 525 526 private void updateAnimationState() { 527 switch (animationState) { 528 case AnimationState.ENTRY: 529 startSwipeToAnswerEntryAnimation(); 530 break; 531 case AnimationState.BOUNCE: 532 startSwipeToAnswerBounceAnimation(); 533 break; 534 case AnimationState.SWIPE: 535 startSwipeToAnswerSwipeAnimation(); 536 break; 537 case AnimationState.SETTLE: 538 startSwipeToAnswerSettleAnimation(); 539 break; 540 case AnimationState.COMPLETED: 541 clearSwipeToAnswerUi(); 542 break; 543 case AnimationState.HINT: 544 startSwipeToAnswerHintAnimation(); 545 break; 546 case AnimationState.NONE: 547 default: 548 LogUtil.e( 549 "FlingUpDownMethod.updateAnimationState", 550 "Unexpected animation state: " + animationState); 551 break; 552 } 553 } 554 555 private void startSwipeToAnswerEntryAnimation() { 556 LogUtil.i("FlingUpDownMethod.startSwipeToAnswerEntryAnimation", "Swipe entry animation."); 557 endAnimation(); 558 559 lockEntryAnim = new AnimatorSet(); 560 Animator textUp = 561 ObjectAnimator.ofFloat( 562 swipeToAnswerText, 563 View.TRANSLATION_Y, 564 DpUtil.dpToPx(getContext(), 192 /* dp */), 565 DpUtil.dpToPx(getContext(), -20 /* dp */)); 566 textUp.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 567 textUp.setInterpolator(new LinearOutSlowInInterpolator()); 568 569 Animator textDown = 570 ObjectAnimator.ofFloat( 571 swipeToAnswerText, 572 View.TRANSLATION_Y, 573 DpUtil.dpToPx(getContext(), -20) /* dp */, 574 0 /* end pos */); 575 textDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 576 textUp.setInterpolator(new FastOutSlowInInterpolator()); 577 578 // "Swipe down to reject" text fades in with a slight translation 579 swipeToRejectText.setAlpha(0f); 580 Animator rejectTextShow = 581 ObjectAnimator.ofPropertyValuesHolder( 582 swipeToRejectText, 583 PropertyValuesHolder.ofFloat(View.ALPHA, 1f), 584 PropertyValuesHolder.ofFloat( 585 View.TRANSLATION_Y, 586 DpUtil.dpToPx(getContext(), HINT_REJECT_FADE_TRANSLATION_Y_DP), 587 0f)); 588 rejectTextShow.setInterpolator(new FastOutLinearInInterpolator()); 589 rejectTextShow.setDuration(ANIMATE_DURATION_SHORT_MILLIS); 590 rejectTextShow.setStartDelay(SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS); 591 592 Animator puckUp = 593 ObjectAnimator.ofFloat( 594 contactPuckContainer, 595 View.TRANSLATION_Y, 596 DpUtil.dpToPx(getContext(), 400 /* dp */), 597 DpUtil.dpToPx(getContext(), -12 /* dp */)); 598 puckUp.setDuration(ANIMATE_DURATION_LONG_MILLIS); 599 puckUp.setInterpolator( 600 PathInterpolatorCompat.create( 601 0 /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */)); 602 603 Animator puckDown = 604 ObjectAnimator.ofFloat( 605 contactPuckContainer, 606 View.TRANSLATION_Y, 607 DpUtil.dpToPx(getContext(), -12 /* dp */), 608 0 /* end pos */); 609 puckDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 610 puckDown.setInterpolator(new FastOutSlowInInterpolator()); 611 612 Animator puckScaleUp = 613 createUniformScaleAnimators( 614 contactPuckBackground, 615 0.33f /* beginScale */, 616 1.1f /* endScale */, 617 ANIMATE_DURATION_NORMAL_MILLIS, 618 PathInterpolatorCompat.create( 619 0.4f /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */)); 620 Animator puckScaleDown = 621 createUniformScaleAnimators( 622 contactPuckBackground, 623 1.1f /* beginScale */, 624 1 /* endScale */, 625 ANIMATE_DURATION_NORMAL_MILLIS, 626 new FastOutSlowInInterpolator()); 627 628 // Upward animation chain. 629 lockEntryAnim.play(textUp).with(puckScaleUp).with(puckUp); 630 631 // Downward animation chain. 632 lockEntryAnim.play(textDown).with(puckDown).with(puckScaleDown).after(puckUp); 633 634 lockEntryAnim.play(rejectTextShow).after(puckUp); 635 636 // Add vibration animation. 637 addVibrationAnimator(lockEntryAnim); 638 639 lockEntryAnim.addListener( 640 new AnimatorListenerAdapter() { 641 642 public boolean canceled; 643 644 @Override 645 public void onAnimationCancel(Animator animation) { 646 super.onAnimationCancel(animation); 647 canceled = true; 648 } 649 650 @Override 651 public void onAnimationEnd(Animator animation) { 652 super.onAnimationEnd(animation); 653 if (!canceled) { 654 onEntryAnimationDone(); 655 } 656 } 657 }); 658 lockEntryAnim.start(); 659 } 660 661 @VisibleForTesting 662 void onEntryAnimationDone() { 663 LogUtil.i("FlingUpDownMethod.onEntryAnimationDone", "Swipe entry anim ends."); 664 if (animationState == AnimationState.ENTRY) { 665 setAnimationState(AnimationState.BOUNCE); 666 } 667 } 668 669 private void startSwipeToAnswerBounceAnimation() { 670 LogUtil.i("FlingUpDownMethod.startSwipeToAnswerBounceAnimation", "Swipe bounce animation."); 671 endAnimation(); 672 673 if (ViewUtil.areAnimationsDisabled(getContext())) { 674 swipeToAnswerText.setTranslationY(0); 675 contactPuckContainer.setTranslationY(0); 676 contactPuckBackground.setScaleY(1f); 677 contactPuckBackground.setScaleX(1f); 678 swipeToRejectText.setAlpha(1f); 679 swipeToRejectText.setTranslationY(0); 680 return; 681 } 682 683 lockBounceAnim = createBreatheAnimation(); 684 685 answerHint.onBounceStart(); 686 lockBounceAnim.addListener( 687 new AnimatorListenerAdapter() { 688 boolean firstPass = true; 689 690 @Override 691 public void onAnimationEnd(Animator animation) { 692 super.onAnimationEnd(animation); 693 if (getContext() != null 694 && lockBounceAnim != null 695 && animationState == AnimationState.BOUNCE) { 696 // AnimatorSet doesn't have repeat settings. Instead, we start a new one after the 697 // previous set is completed, until endAnimation is called. 698 LogUtil.v("FlingUpDownMethod.onAnimationEnd", "Bounce again."); 699 700 // If this is the first time repeating the animation, we should recreate it so its 701 // starting values will be correct 702 if (firstPass) { 703 lockBounceAnim = createBreatheAnimation(); 704 lockBounceAnim.addListener(this); 705 } 706 firstPass = false; 707 answerHint.onBounceStart(); 708 lockBounceAnim.start(); 709 } 710 } 711 }); 712 lockBounceAnim.start(); 713 } 714 715 private Animator createBreatheAnimation() { 716 AnimatorSet breatheAnimation = new AnimatorSet(); 717 float textOffset = DpUtil.dpToPx(getContext(), 42 /* dp */); 718 Animator textUp = 719 ObjectAnimator.ofFloat( 720 swipeToAnswerText, View.TRANSLATION_Y, 0 /* begin pos */, -textOffset); 721 textUp.setInterpolator(new FastOutSlowInInterpolator()); 722 textUp.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 723 724 Animator textDown = 725 ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, -textOffset, 0 /* end pos */); 726 textDown.setInterpolator(new FastOutSlowInInterpolator()); 727 textDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 728 729 // "Swipe down to reject" text fade in 730 Animator rejectTextShow = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 1f); 731 rejectTextShow.setInterpolator(new LinearOutSlowInInterpolator()); 732 rejectTextShow.setDuration(ANIMATE_DURATION_SHORT_MILLIS); 733 rejectTextShow.setStartDelay(SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS); 734 735 // reject hint text translate in 736 Animator rejectTextTranslate = 737 ObjectAnimator.ofFloat( 738 swipeToRejectText, 739 View.TRANSLATION_Y, 740 DpUtil.dpToPx(getContext(), HINT_REJECT_FADE_TRANSLATION_Y_DP), 741 0f); 742 rejectTextTranslate.setInterpolator(new FastOutSlowInInterpolator()); 743 rejectTextTranslate.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 744 745 // reject hint text fade out 746 Animator rejectTextHide = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 0f); 747 rejectTextHide.setInterpolator(new FastOutLinearInInterpolator()); 748 rejectTextHide.setDuration(ANIMATE_DURATION_SHORT_MILLIS); 749 750 Interpolator curve = 751 PathInterpolatorCompat.create( 752 0.4f /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */); 753 float puckOffset = DpUtil.dpToPx(getContext(), 42 /* dp */); 754 Animator puckUp = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, -puckOffset); 755 puckUp.setInterpolator(curve); 756 puckUp.setDuration(ANIMATE_DURATION_LONG_MILLIS); 757 758 final float scale = 1.0625f; 759 Animator puckScaleUp = 760 createUniformScaleAnimators( 761 contactPuckBackground, 762 1 /* beginScale */, 763 scale, 764 ANIMATE_DURATION_NORMAL_MILLIS, 765 curve); 766 767 Animator puckDown = 768 ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, 0 /* end pos */); 769 puckDown.setInterpolator(new FastOutSlowInInterpolator()); 770 puckDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 771 772 Animator puckScaleDown = 773 createUniformScaleAnimators( 774 contactPuckBackground, 775 scale, 776 1 /* endScale */, 777 ANIMATE_DURATION_NORMAL_MILLIS, 778 new FastOutSlowInInterpolator()); 779 780 // Bounce upward animation chain. 781 breatheAnimation 782 .play(textUp) 783 .with(rejectTextHide) 784 .with(puckUp) 785 .with(puckScaleUp) 786 .after(167 /* delay */); 787 788 // Bounce downward animation chain. 789 breatheAnimation 790 .play(puckDown) 791 .with(textDown) 792 .with(puckScaleDown) 793 .with(rejectTextShow) 794 .with(rejectTextTranslate) 795 .after(puckUp); 796 797 // Add vibration animation to the animator set. 798 addVibrationAnimator(breatheAnimation); 799 800 return breatheAnimation; 801 } 802 803 private void startSwipeToAnswerSettleAnimation() { 804 endAnimation(); 805 806 ObjectAnimator puckScale = 807 ObjectAnimator.ofPropertyValuesHolder( 808 contactPuckBackground, 809 PropertyValuesHolder.ofFloat(View.SCALE_X, 1), 810 PropertyValuesHolder.ofFloat(View.SCALE_Y, 1)); 811 puckScale.setDuration(SETTLE_ANIMATION_DURATION_MILLIS); 812 813 ObjectAnimator iconRotation = ObjectAnimator.ofFloat(contactPuckIcon, View.ROTATION, 0); 814 iconRotation.setDuration(SETTLE_ANIMATION_DURATION_MILLIS); 815 816 ObjectAnimator swipeToAnswerTextFade = 817 createFadeAnimation(swipeToAnswerText, 1, SETTLE_ANIMATION_DURATION_MILLIS); 818 819 ObjectAnimator contactPuckContainerFade = 820 createFadeAnimation(contactPuckContainer, 1, SETTLE_ANIMATION_DURATION_MILLIS); 821 822 ObjectAnimator contactPuckBackgroundFade = 823 createFadeAnimation(contactPuckBackground, 1, SETTLE_ANIMATION_DURATION_MILLIS); 824 825 ObjectAnimator contactPuckIconFade = 826 createFadeAnimation( 827 contactPuckIcon, shouldShowPhotoInPuck() ? 0 : 1, SETTLE_ANIMATION_DURATION_MILLIS); 828 829 ObjectAnimator contactPuckTranslation = 830 ObjectAnimator.ofPropertyValuesHolder( 831 contactPuckContainer, 832 PropertyValuesHolder.ofFloat(View.TRANSLATION_X, 0), 833 PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0)); 834 contactPuckTranslation.setDuration(SETTLE_ANIMATION_DURATION_MILLIS); 835 836 lockSettleAnim = new AnimatorSet(); 837 lockSettleAnim 838 .play(puckScale) 839 .with(iconRotation) 840 .with(swipeToAnswerTextFade) 841 .with(contactPuckContainerFade) 842 .with(contactPuckBackgroundFade) 843 .with(contactPuckIconFade) 844 .with(contactPuckTranslation); 845 846 lockSettleAnim.addListener( 847 new AnimatorListenerAdapter() { 848 @Override 849 public void onAnimationCancel(Animator animation) { 850 afterSettleAnimationState = AnimationState.NONE; 851 } 852 853 @Override 854 public void onAnimationEnd(Animator animation) { 855 onSettleAnimationDone(); 856 } 857 }); 858 859 lockSettleAnim.start(); 860 } 861 862 @VisibleForTesting 863 void onSettleAnimationDone() { 864 if (afterSettleAnimationState != AnimationState.NONE) { 865 int nextState = afterSettleAnimationState; 866 afterSettleAnimationState = AnimationState.NONE; 867 lockSettleAnim = null; 868 869 setAnimationState(nextState); 870 } 871 } 872 873 private ObjectAnimator createFadeAnimation(View target, float targetAlpha, long duration) { 874 ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(target, View.ALPHA, targetAlpha); 875 objectAnimator.setDuration(duration); 876 return objectAnimator; 877 } 878 879 private void startSwipeToAnswerHintAnimation() { 880 if (rejectHintHide != null) { 881 rejectHintHide.cancel(); 882 } 883 884 endAnimation(); 885 resetTouchState(); 886 887 if (ViewUtil.areAnimationsDisabled(getContext())) { 888 onHintAnimationDone(false); 889 return; 890 } 891 892 lockHintAnim = new AnimatorSet(); 893 float jumpOffset = DpUtil.dpToPx(getContext(), HINT_JUMP_DP); 894 float dipOffset = DpUtil.dpToPx(getContext(), HINT_DIP_DP); 895 float scaleSize = HINT_SCALE_RATIO; 896 float textOffset = jumpOffset + (scaleSize - 1) * contactPuckBackground.getHeight(); 897 int shortAnimTime = 898 getContext().getResources().getInteger(android.R.integer.config_shortAnimTime); 899 int mediumAnimTime = 900 getContext().getResources().getInteger(android.R.integer.config_mediumAnimTime); 901 902 // Puck squashes to anticipate jump 903 ObjectAnimator puckAnticipate = 904 ObjectAnimator.ofPropertyValuesHolder( 905 contactPuckContainer, 906 PropertyValuesHolder.ofFloat(View.SCALE_Y, .95f), 907 PropertyValuesHolder.ofFloat(View.SCALE_X, 1.05f)); 908 puckAnticipate.setRepeatCount(1); 909 puckAnticipate.setRepeatMode(ValueAnimator.REVERSE); 910 puckAnticipate.setDuration(shortAnimTime / 2); 911 puckAnticipate.setInterpolator(new DecelerateInterpolator()); 912 puckAnticipate.addListener( 913 new AnimatorListenerAdapter() { 914 @Override 915 public void onAnimationStart(Animator animation) { 916 super.onAnimationStart(animation); 917 contactPuckContainer.setPivotY(contactPuckContainer.getHeight()); 918 } 919 920 @Override 921 public void onAnimationEnd(Animator animation) { 922 super.onAnimationEnd(animation); 923 contactPuckContainer.setPivotY(contactPuckContainer.getHeight() / 2); 924 } 925 }); 926 927 // Ensure puck is at the right starting point for the jump 928 ObjectAnimator puckResetTranslation = 929 ObjectAnimator.ofPropertyValuesHolder( 930 contactPuckContainer, 931 PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0), 932 PropertyValuesHolder.ofFloat(View.TRANSLATION_X, 0)); 933 puckResetTranslation.setDuration(shortAnimTime / 2); 934 puckAnticipate.setInterpolator(new DecelerateInterpolator()); 935 936 Animator textUp = ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, -textOffset); 937 textUp.setInterpolator(new LinearOutSlowInInterpolator()); 938 textUp.setDuration(shortAnimTime); 939 940 Animator puckUp = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, -jumpOffset); 941 puckUp.setInterpolator(new LinearOutSlowInInterpolator()); 942 puckUp.setDuration(shortAnimTime); 943 944 Animator puckScaleUp = 945 createUniformScaleAnimators( 946 contactPuckBackground, 1f, scaleSize, shortAnimTime, new LinearOutSlowInInterpolator()); 947 948 Animator rejectHintShow = 949 ObjectAnimator.ofPropertyValuesHolder( 950 swipeToRejectText, 951 PropertyValuesHolder.ofFloat(View.ALPHA, 1f), 952 PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f)); 953 rejectHintShow.setDuration(shortAnimTime); 954 955 Animator rejectHintDip = 956 ObjectAnimator.ofFloat(swipeToRejectText, View.TRANSLATION_Y, dipOffset); 957 rejectHintDip.setInterpolator(new LinearOutSlowInInterpolator()); 958 rejectHintDip.setDuration(shortAnimTime); 959 960 Animator textDown = ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, 0); 961 textDown.setInterpolator(new LinearOutSlowInInterpolator()); 962 textDown.setDuration(mediumAnimTime); 963 964 Animator puckDown = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, 0); 965 BounceInterpolator bounce = new BounceInterpolator(); 966 puckDown.setInterpolator(bounce); 967 puckDown.setDuration(mediumAnimTime); 968 969 Animator puckScaleDown = 970 createUniformScaleAnimators( 971 contactPuckBackground, scaleSize, 1f, shortAnimTime, new LinearOutSlowInInterpolator()); 972 973 Animator rejectHintUp = ObjectAnimator.ofFloat(swipeToRejectText, View.TRANSLATION_Y, 0); 974 rejectHintUp.setInterpolator(new LinearOutSlowInInterpolator()); 975 rejectHintUp.setDuration(mediumAnimTime); 976 977 lockHintAnim.play(puckAnticipate).with(puckResetTranslation).before(puckUp); 978 lockHintAnim 979 .play(textUp) 980 .with(puckUp) 981 .with(puckScaleUp) 982 .with(rejectHintDip) 983 .with(rejectHintShow); 984 lockHintAnim.play(textDown).with(puckDown).with(puckScaleDown).with(rejectHintUp).after(puckUp); 985 lockHintAnim.start(); 986 987 rejectHintHide = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 0); 988 rejectHintHide.setStartDelay(HINT_REJECT_SHOW_DURATION_MILLIS); 989 rejectHintHide.addListener( 990 new AnimatorListenerAdapter() { 991 992 private boolean canceled; 993 994 @Override 995 public void onAnimationCancel(Animator animation) { 996 super.onAnimationCancel(animation); 997 canceled = true; 998 rejectHintHide = null; 999 } 1000 1001 @Override 1002 public void onAnimationEnd(Animator animation) { 1003 super.onAnimationEnd(animation); 1004 onHintAnimationDone(canceled); 1005 } 1006 }); 1007 rejectHintHide.start(); 1008 } 1009 1010 @VisibleForTesting 1011 void onHintAnimationDone(boolean canceled) { 1012 if (!canceled && animationState == AnimationState.HINT) { 1013 setAnimationState(AnimationState.BOUNCE); 1014 } 1015 rejectHintHide = null; 1016 } 1017 1018 private void clearSwipeToAnswerUi() { 1019 LogUtil.i("FlingUpDownMethod.clearSwipeToAnswerUi", "Clear swipe animation."); 1020 endAnimation(); 1021 swipeToAnswerText.setVisibility(View.GONE); 1022 contactPuckContainer.setVisibility(View.GONE); 1023 } 1024 1025 private void endAnimation() { 1026 LogUtil.i("FlingUpDownMethod.endAnimation", "End animations."); 1027 if (lockSettleAnim != null) { 1028 lockSettleAnim.cancel(); 1029 lockSettleAnim = null; 1030 } 1031 if (lockBounceAnim != null) { 1032 lockBounceAnim.cancel(); 1033 lockBounceAnim = null; 1034 } 1035 if (lockEntryAnim != null) { 1036 lockEntryAnim.cancel(); 1037 lockEntryAnim = null; 1038 } 1039 if (lockHintAnim != null) { 1040 lockHintAnim.cancel(); 1041 lockHintAnim = null; 1042 } 1043 if (rejectHintHide != null) { 1044 rejectHintHide.cancel(); 1045 rejectHintHide = null; 1046 } 1047 if (vibrationAnimator != null) { 1048 vibrationAnimator.end(); 1049 vibrationAnimator = null; 1050 } 1051 answerHint.onBounceEnd(); 1052 } 1053 1054 // Create an animator to scale on X/Y directions uniformly. 1055 private Animator createUniformScaleAnimators( 1056 View target, float begin, float end, long duration, Interpolator interpolator) { 1057 ObjectAnimator animator = 1058 ObjectAnimator.ofPropertyValuesHolder( 1059 target, 1060 PropertyValuesHolder.ofFloat(View.SCALE_X, begin, end), 1061 PropertyValuesHolder.ofFloat(View.SCALE_Y, begin, end)); 1062 animator.setDuration(duration); 1063 animator.setInterpolator(interpolator); 1064 return animator; 1065 } 1066 1067 private void addVibrationAnimator(AnimatorSet animatorSet) { 1068 if (vibrationAnimator != null) { 1069 vibrationAnimator.end(); 1070 } 1071 1072 // Note that we animate the value between 0 and 1, but internally VibrateInterpolator will 1073 // translate it into actually X translation value. 1074 vibrationAnimator = 1075 ObjectAnimator.ofFloat( 1076 contactPuckContainer, View.TRANSLATION_X, 0 /* begin value */, 1 /* end value */); 1077 vibrationAnimator.setDuration(VIBRATION_TIME_MILLIS); 1078 vibrationAnimator.setInterpolator(new VibrateInterpolator(getContext())); 1079 1080 animatorSet.play(vibrationAnimator).after(0 /* delay */); 1081 } 1082 1083 private void performAccept() { 1084 LogUtil.i("FlingUpDownMethod.performAccept", null); 1085 swipeToAnswerText.setVisibility(View.GONE); 1086 contactPuckContainer.setVisibility(View.GONE); 1087 1088 // Complete the animation loop. 1089 setAnimationState(AnimationState.COMPLETED); 1090 getParent().answerFromMethod(); 1091 } 1092 1093 private void performReject() { 1094 LogUtil.i("FlingUpDownMethod.performReject", null); 1095 swipeToAnswerText.setVisibility(View.GONE); 1096 contactPuckContainer.setVisibility(View.GONE); 1097 1098 // Complete the animation loop. 1099 setAnimationState(AnimationState.COMPLETED); 1100 getParent().rejectFromMethod(); 1101 } 1102 1103 /** Custom interpolator class for puck vibration. */ 1104 private static class VibrateInterpolator implements Interpolator { 1105 1106 private static final long RAMP_UP_BEGIN_MS = 583; 1107 private static final long RAMP_UP_DURATION_MS = 167; 1108 private static final long RAMP_UP_END_MS = RAMP_UP_BEGIN_MS + RAMP_UP_DURATION_MS; 1109 private static final long RAMP_DOWN_BEGIN_MS = 1_583; 1110 private static final long RAMP_DOWN_DURATION_MS = 250; 1111 private static final long RAMP_DOWN_END_MS = RAMP_DOWN_BEGIN_MS + RAMP_DOWN_DURATION_MS; 1112 private static final long RAMP_TOTAL_TIME_MS = RAMP_DOWN_END_MS; 1113 private final float ampMax; 1114 private final float freqMax = 80; 1115 private Interpolator sliderInterpolator = new FastOutSlowInInterpolator(); 1116 1117 VibrateInterpolator(Context context) { 1118 ampMax = DpUtil.dpToPx(context, 1 /* dp */); 1119 } 1120 1121 @Override 1122 public float getInterpolation(float t) { 1123 float slider = 0; 1124 float time = t * RAMP_TOTAL_TIME_MS; 1125 1126 // Calculate the slider value based on RAMP_UP and RAMP_DOWN times. Between RAMP_UP and 1127 // RAMP_DOWN, the slider remains the maximum value of 1. 1128 if (time > RAMP_UP_BEGIN_MS && time < RAMP_UP_END_MS) { 1129 // Ramp up. 1130 slider = 1131 sliderInterpolator.getInterpolation( 1132 (time - RAMP_UP_BEGIN_MS) / (float) RAMP_UP_DURATION_MS); 1133 } else if ((time >= RAMP_UP_END_MS) && time <= RAMP_DOWN_BEGIN_MS) { 1134 // Vibrate at maximum 1135 slider = 1; 1136 } else if (time > RAMP_DOWN_BEGIN_MS && time < RAMP_DOWN_END_MS) { 1137 // Ramp down. 1138 slider = 1139 1 1140 - sliderInterpolator.getInterpolation( 1141 (time - RAMP_DOWN_BEGIN_MS) / (float) RAMP_DOWN_DURATION_MS); 1142 } 1143 1144 float ampNormalized = ampMax * slider; 1145 float freqNormalized = freqMax * slider; 1146 1147 return (float) (ampNormalized * Math.sin(time * freqNormalized)); 1148 } 1149 } 1150} 1151