FlingUpDownMethod.java revision 2df4538eb90b896be15eebc1d9adf1206131c8a3
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 // 392 // See specs - 393 // Accept: https://direct.googleplex.com/#/spec/8510001 394 // Decline: https://direct.googleplex.com/#/spec/3850001 395 final float progressSlots = 9; 396 397 // Fade out the "swipe up to answer". It only takes 1 slot to complete the fade. 398 float swipeTextAlpha = Math.max(0, 1 - Math.abs(clampedProgress) * progressSlots); 399 fadeToward(swipeToAnswerText, swipeTextAlpha); 400 // Fade out the "swipe down to dismiss" at the same time. Don't ever increase its alpha 401 fadeToward(swipeToRejectText, Math.min(swipeTextAlpha, swipeToRejectText.getAlpha())); 402 // Fade out the "incoming will disconnect" text 403 fadeToward(incomingDisconnectText, incomingWillDisconnect ? swipeTextAlpha : 0); 404 405 // Move swipe text back to zero. 406 moveTowardX(swipeToAnswerText, 0 /* newX */); 407 moveTowardY(swipeToAnswerText, 0 /* newY */); 408 409 // Animate puck color 410 @ColorInt 411 int destPuckColor = 412 getContext() 413 .getColor( 414 isAcceptingFlow ? R.color.call_accept_background : R.color.call_hangup_background); 415 destPuckColor = 416 ColorUtils.setAlphaComponent(destPuckColor, (int) (0xFF * positiveAdjustedProgress)); 417 contactPuckBackground.setBackgroundTintList(ColorStateList.valueOf(destPuckColor)); 418 contactPuckBackground.setBackgroundTintMode(Mode.SRC_ATOP); 419 contactPuckBackground.setColorFilter(destPuckColor); 420 421 // Animate decline icon 422 if (isAcceptingFlow || getParent().isVideoCall() || getParent().isVideoUpgradeRequest()) { 423 rotateToward(contactPuckIcon, 0f); 424 } else { 425 rotateToward(contactPuckIcon, positiveAdjustedProgress * ICON_END_CALL_ROTATION_DEGREES); 426 } 427 428 // Fade in icon 429 if (shouldShowPhotoInPuck()) { 430 fadeToward(contactPuckIcon, positiveAdjustedProgress); 431 } 432 float iconProgress = Math.min(1f, positiveAdjustedProgress * 4); 433 @ColorInt 434 int iconColor = 435 ColorUtils.setAlphaComponent( 436 contactPuckIcon.getContext().getColor(R.color.incoming_answer_icon), 437 (int) (0xFF * (1 - iconProgress))); 438 contactPuckIcon.setImageTintList(ColorStateList.valueOf(iconColor)); 439 440 // Move puck. 441 if (isAcceptingFlow) { 442 moveTowardY( 443 contactPuckContainer, 444 -clampedProgress * DpUtil.dpToPx(getContext(), SWIPE_TO_ANSWER_MAX_TRANSLATION_Y_DP)); 445 } else { 446 moveTowardY( 447 contactPuckContainer, 448 -clampedProgress * DpUtil.dpToPx(getContext(), SWIPE_TO_REJECT_MAX_TRANSLATION_Y_DP)); 449 } 450 451 getParent().onAnswerProgressUpdate(clampedProgress); 452 } 453 454 private void startSwipeToAnswerSwipeAnimation() { 455 LogUtil.i("FlingUpDownMethod.startSwipeToAnswerSwipeAnimation", "Start swipe animation."); 456 resetTouchState(); 457 endAnimation(); 458 } 459 460 private void setPuckTouchState() { 461 contactPuckBackground.setActivated(touchHandler.isTracking()); 462 } 463 464 private void resetTouchState() { 465 if (getContext() == null) { 466 // State will be reset in onStart(), so just abort. 467 return; 468 } 469 contactPuckContainer.animate().scaleX(1 /* scaleX */); 470 contactPuckContainer.animate().scaleY(1 /* scaleY */); 471 contactPuckBackground.animate().scaleX(1 /* scaleX */); 472 contactPuckBackground.animate().scaleY(1 /* scaleY */); 473 contactPuckBackground.setBackgroundTintList(null); 474 contactPuckBackground.setColorFilter(null); 475 contactPuckIcon.setImageTintList( 476 ColorStateList.valueOf(getContext().getColor(R.color.incoming_answer_icon))); 477 contactPuckIcon.animate().rotation(0); 478 479 getParent().resetAnswerProgress(); 480 setPuckTouchState(); 481 482 final float alpha = 1; 483 swipeToAnswerText.animate().alpha(alpha); 484 contactPuckContainer.animate().alpha(alpha); 485 contactPuckBackground.animate().alpha(alpha); 486 contactPuckIcon.animate().alpha(shouldShowPhotoInPuck() ? 0 : alpha); 487 } 488 489 @VisibleForTesting 490 void setAnimationState(@AnimationState int state) { 491 if (state != AnimationState.HINT && animationState == state) { 492 return; 493 } 494 495 if (animationState == AnimationState.COMPLETED) { 496 LogUtil.e( 497 "FlingUpDownMethod.setAnimationState", 498 "Animation loop has completed. Cannot switch to new state: " + state); 499 return; 500 } 501 502 if (state == AnimationState.HINT || state == AnimationState.BOUNCE) { 503 if (animationState == AnimationState.SWIPE) { 504 afterSettleAnimationState = state; 505 state = AnimationState.SETTLE; 506 } 507 } 508 509 LogUtil.i("FlingUpDownMethod.setAnimationState", "animation state: " + state); 510 animationState = state; 511 512 // Start animation after the current one is finished completely. 513 View view = getView(); 514 if (view != null) { 515 // As long as the fragment is added, we can start update the animation state. 516 if (isAdded() && (animationState == state)) { 517 updateAnimationState(); 518 } else { 519 endAnimation(); 520 } 521 } 522 } 523 524 @AnimationState 525 @VisibleForTesting 526 int getAnimationState() { 527 return animationState; 528 } 529 530 private void updateAnimationState() { 531 switch (animationState) { 532 case AnimationState.ENTRY: 533 startSwipeToAnswerEntryAnimation(); 534 break; 535 case AnimationState.BOUNCE: 536 startSwipeToAnswerBounceAnimation(); 537 break; 538 case AnimationState.SWIPE: 539 startSwipeToAnswerSwipeAnimation(); 540 break; 541 case AnimationState.SETTLE: 542 startSwipeToAnswerSettleAnimation(); 543 break; 544 case AnimationState.COMPLETED: 545 clearSwipeToAnswerUi(); 546 break; 547 case AnimationState.HINT: 548 startSwipeToAnswerHintAnimation(); 549 break; 550 case AnimationState.NONE: 551 default: 552 LogUtil.e( 553 "FlingUpDownMethod.updateAnimationState", 554 "Unexpected animation state: " + animationState); 555 break; 556 } 557 } 558 559 private void startSwipeToAnswerEntryAnimation() { 560 LogUtil.i("FlingUpDownMethod.startSwipeToAnswerEntryAnimation", "Swipe entry animation."); 561 endAnimation(); 562 563 lockEntryAnim = new AnimatorSet(); 564 Animator textUp = 565 ObjectAnimator.ofFloat( 566 swipeToAnswerText, 567 View.TRANSLATION_Y, 568 DpUtil.dpToPx(getContext(), 192 /* dp */), 569 DpUtil.dpToPx(getContext(), -20 /* dp */)); 570 textUp.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 571 textUp.setInterpolator(new LinearOutSlowInInterpolator()); 572 573 Animator textDown = 574 ObjectAnimator.ofFloat( 575 swipeToAnswerText, 576 View.TRANSLATION_Y, 577 DpUtil.dpToPx(getContext(), -20) /* dp */, 578 0 /* end pos */); 579 textDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 580 textUp.setInterpolator(new FastOutSlowInInterpolator()); 581 582 // "Swipe down to reject" text fades in with a slight translation 583 swipeToRejectText.setAlpha(0f); 584 Animator rejectTextShow = 585 ObjectAnimator.ofPropertyValuesHolder( 586 swipeToRejectText, 587 PropertyValuesHolder.ofFloat(View.ALPHA, 1f), 588 PropertyValuesHolder.ofFloat( 589 View.TRANSLATION_Y, 590 DpUtil.dpToPx(getContext(), HINT_REJECT_FADE_TRANSLATION_Y_DP), 591 0f)); 592 rejectTextShow.setInterpolator(new FastOutLinearInInterpolator()); 593 rejectTextShow.setDuration(ANIMATE_DURATION_SHORT_MILLIS); 594 rejectTextShow.setStartDelay(SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS); 595 596 Animator puckUp = 597 ObjectAnimator.ofFloat( 598 contactPuckContainer, 599 View.TRANSLATION_Y, 600 DpUtil.dpToPx(getContext(), 400 /* dp */), 601 DpUtil.dpToPx(getContext(), -12 /* dp */)); 602 puckUp.setDuration(ANIMATE_DURATION_LONG_MILLIS); 603 puckUp.setInterpolator( 604 PathInterpolatorCompat.create( 605 0 /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */)); 606 607 Animator puckDown = 608 ObjectAnimator.ofFloat( 609 contactPuckContainer, 610 View.TRANSLATION_Y, 611 DpUtil.dpToPx(getContext(), -12 /* dp */), 612 0 /* end pos */); 613 puckDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 614 puckDown.setInterpolator(new FastOutSlowInInterpolator()); 615 616 Animator puckScaleUp = 617 createUniformScaleAnimators( 618 contactPuckBackground, 619 0.33f /* beginScale */, 620 1.1f /* endScale */, 621 ANIMATE_DURATION_NORMAL_MILLIS, 622 PathInterpolatorCompat.create( 623 0.4f /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */)); 624 Animator puckScaleDown = 625 createUniformScaleAnimators( 626 contactPuckBackground, 627 1.1f /* beginScale */, 628 1 /* endScale */, 629 ANIMATE_DURATION_NORMAL_MILLIS, 630 new FastOutSlowInInterpolator()); 631 632 // Upward animation chain. 633 lockEntryAnim.play(textUp).with(puckScaleUp).with(puckUp); 634 635 // Downward animation chain. 636 lockEntryAnim.play(textDown).with(puckDown).with(puckScaleDown).after(puckUp); 637 638 lockEntryAnim.play(rejectTextShow).after(puckUp); 639 640 // Add vibration animation. 641 addVibrationAnimator(lockEntryAnim); 642 643 lockEntryAnim.addListener( 644 new AnimatorListenerAdapter() { 645 646 public boolean canceled; 647 648 @Override 649 public void onAnimationCancel(Animator animation) { 650 super.onAnimationCancel(animation); 651 canceled = true; 652 } 653 654 @Override 655 public void onAnimationEnd(Animator animation) { 656 super.onAnimationEnd(animation); 657 if (!canceled) { 658 onEntryAnimationDone(); 659 } 660 } 661 }); 662 lockEntryAnim.start(); 663 } 664 665 @VisibleForTesting 666 void onEntryAnimationDone() { 667 LogUtil.i("FlingUpDownMethod.onEntryAnimationDone", "Swipe entry anim ends."); 668 if (animationState == AnimationState.ENTRY) { 669 setAnimationState(AnimationState.BOUNCE); 670 } 671 } 672 673 private void startSwipeToAnswerBounceAnimation() { 674 LogUtil.i("FlingUpDownMethod.startSwipeToAnswerBounceAnimation", "Swipe bounce animation."); 675 endAnimation(); 676 677 if (ViewUtil.areAnimationsDisabled(getContext())) { 678 swipeToAnswerText.setTranslationY(0); 679 contactPuckContainer.setTranslationY(0); 680 contactPuckBackground.setScaleY(1f); 681 contactPuckBackground.setScaleX(1f); 682 swipeToRejectText.setAlpha(1f); 683 swipeToRejectText.setTranslationY(0); 684 return; 685 } 686 687 lockBounceAnim = createBreatheAnimation(); 688 689 answerHint.onBounceStart(); 690 lockBounceAnim.addListener( 691 new AnimatorListenerAdapter() { 692 boolean firstPass = true; 693 694 @Override 695 public void onAnimationEnd(Animator animation) { 696 super.onAnimationEnd(animation); 697 if (getContext() != null 698 && lockBounceAnim != null 699 && animationState == AnimationState.BOUNCE) { 700 // AnimatorSet doesn't have repeat settings. Instead, we start a new one after the 701 // previous set is completed, until endAnimation is called. 702 LogUtil.v("FlingUpDownMethod.onAnimationEnd", "Bounce again."); 703 704 // If this is the first time repeating the animation, we should recreate it so its 705 // starting values will be correct 706 if (firstPass) { 707 lockBounceAnim = createBreatheAnimation(); 708 lockBounceAnim.addListener(this); 709 } 710 firstPass = false; 711 answerHint.onBounceStart(); 712 lockBounceAnim.start(); 713 } 714 } 715 }); 716 lockBounceAnim.start(); 717 } 718 719 private Animator createBreatheAnimation() { 720 AnimatorSet breatheAnimation = new AnimatorSet(); 721 float textOffset = DpUtil.dpToPx(getContext(), 42 /* dp */); 722 Animator textUp = 723 ObjectAnimator.ofFloat( 724 swipeToAnswerText, View.TRANSLATION_Y, 0 /* begin pos */, -textOffset); 725 textUp.setInterpolator(new FastOutSlowInInterpolator()); 726 textUp.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 727 728 Animator textDown = 729 ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, -textOffset, 0 /* end pos */); 730 textDown.setInterpolator(new FastOutSlowInInterpolator()); 731 textDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 732 733 // "Swipe down to reject" text fade in 734 Animator rejectTextShow = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 1f); 735 rejectTextShow.setInterpolator(new LinearOutSlowInInterpolator()); 736 rejectTextShow.setDuration(ANIMATE_DURATION_SHORT_MILLIS); 737 rejectTextShow.setStartDelay(SWIPE_TO_DECLINE_FADE_IN_DELAY_MILLIS); 738 739 // reject hint text translate in 740 Animator rejectTextTranslate = 741 ObjectAnimator.ofFloat( 742 swipeToRejectText, 743 View.TRANSLATION_Y, 744 DpUtil.dpToPx(getContext(), HINT_REJECT_FADE_TRANSLATION_Y_DP), 745 0f); 746 rejectTextTranslate.setInterpolator(new FastOutSlowInInterpolator()); 747 rejectTextTranslate.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 748 749 // reject hint text fade out 750 Animator rejectTextHide = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 0f); 751 rejectTextHide.setInterpolator(new FastOutLinearInInterpolator()); 752 rejectTextHide.setDuration(ANIMATE_DURATION_SHORT_MILLIS); 753 754 Interpolator curve = 755 PathInterpolatorCompat.create( 756 0.4f /* controlX1 */, 0 /* controlY1 */, 0 /* controlX2 */, 1 /* controlY2 */); 757 float puckOffset = DpUtil.dpToPx(getContext(), 42 /* dp */); 758 Animator puckUp = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, -puckOffset); 759 puckUp.setInterpolator(curve); 760 puckUp.setDuration(ANIMATE_DURATION_LONG_MILLIS); 761 762 final float scale = 1.0625f; 763 Animator puckScaleUp = 764 createUniformScaleAnimators( 765 contactPuckBackground, 766 1 /* beginScale */, 767 scale, 768 ANIMATE_DURATION_NORMAL_MILLIS, 769 curve); 770 771 Animator puckDown = 772 ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, 0 /* end pos */); 773 puckDown.setInterpolator(new FastOutSlowInInterpolator()); 774 puckDown.setDuration(ANIMATE_DURATION_NORMAL_MILLIS); 775 776 Animator puckScaleDown = 777 createUniformScaleAnimators( 778 contactPuckBackground, 779 scale, 780 1 /* endScale */, 781 ANIMATE_DURATION_NORMAL_MILLIS, 782 new FastOutSlowInInterpolator()); 783 784 // Bounce upward animation chain. 785 breatheAnimation 786 .play(textUp) 787 .with(rejectTextHide) 788 .with(puckUp) 789 .with(puckScaleUp) 790 .after(167 /* delay */); 791 792 // Bounce downward animation chain. 793 breatheAnimation 794 .play(puckDown) 795 .with(textDown) 796 .with(puckScaleDown) 797 .with(rejectTextShow) 798 .with(rejectTextTranslate) 799 .after(puckUp); 800 801 // Add vibration animation to the animator set. 802 addVibrationAnimator(breatheAnimation); 803 804 return breatheAnimation; 805 } 806 807 private void startSwipeToAnswerSettleAnimation() { 808 endAnimation(); 809 810 ObjectAnimator puckScale = 811 ObjectAnimator.ofPropertyValuesHolder( 812 contactPuckBackground, 813 PropertyValuesHolder.ofFloat(View.SCALE_X, 1), 814 PropertyValuesHolder.ofFloat(View.SCALE_Y, 1)); 815 puckScale.setDuration(SETTLE_ANIMATION_DURATION_MILLIS); 816 817 ObjectAnimator iconRotation = ObjectAnimator.ofFloat(contactPuckIcon, View.ROTATION, 0); 818 iconRotation.setDuration(SETTLE_ANIMATION_DURATION_MILLIS); 819 820 ObjectAnimator swipeToAnswerTextFade = 821 createFadeAnimation(swipeToAnswerText, 1, SETTLE_ANIMATION_DURATION_MILLIS); 822 823 ObjectAnimator contactPuckContainerFade = 824 createFadeAnimation(contactPuckContainer, 1, SETTLE_ANIMATION_DURATION_MILLIS); 825 826 ObjectAnimator contactPuckBackgroundFade = 827 createFadeAnimation(contactPuckBackground, 1, SETTLE_ANIMATION_DURATION_MILLIS); 828 829 ObjectAnimator contactPuckIconFade = 830 createFadeAnimation( 831 contactPuckIcon, shouldShowPhotoInPuck() ? 0 : 1, SETTLE_ANIMATION_DURATION_MILLIS); 832 833 ObjectAnimator contactPuckTranslation = 834 ObjectAnimator.ofPropertyValuesHolder( 835 contactPuckContainer, 836 PropertyValuesHolder.ofFloat(View.TRANSLATION_X, 0), 837 PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0)); 838 contactPuckTranslation.setDuration(SETTLE_ANIMATION_DURATION_MILLIS); 839 840 lockSettleAnim = new AnimatorSet(); 841 lockSettleAnim 842 .play(puckScale) 843 .with(iconRotation) 844 .with(swipeToAnswerTextFade) 845 .with(contactPuckContainerFade) 846 .with(contactPuckBackgroundFade) 847 .with(contactPuckIconFade) 848 .with(contactPuckTranslation); 849 850 lockSettleAnim.addListener( 851 new AnimatorListenerAdapter() { 852 @Override 853 public void onAnimationCancel(Animator animation) { 854 afterSettleAnimationState = AnimationState.NONE; 855 } 856 857 @Override 858 public void onAnimationEnd(Animator animation) { 859 onSettleAnimationDone(); 860 } 861 }); 862 863 lockSettleAnim.start(); 864 } 865 866 @VisibleForTesting 867 void onSettleAnimationDone() { 868 if (afterSettleAnimationState != AnimationState.NONE) { 869 int nextState = afterSettleAnimationState; 870 afterSettleAnimationState = AnimationState.NONE; 871 lockSettleAnim = null; 872 873 setAnimationState(nextState); 874 } 875 } 876 877 private ObjectAnimator createFadeAnimation(View target, float targetAlpha, long duration) { 878 ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(target, View.ALPHA, targetAlpha); 879 objectAnimator.setDuration(duration); 880 return objectAnimator; 881 } 882 883 private void startSwipeToAnswerHintAnimation() { 884 if (rejectHintHide != null) { 885 rejectHintHide.cancel(); 886 } 887 888 endAnimation(); 889 resetTouchState(); 890 891 if (ViewUtil.areAnimationsDisabled(getContext())) { 892 onHintAnimationDone(false); 893 return; 894 } 895 896 lockHintAnim = new AnimatorSet(); 897 float jumpOffset = DpUtil.dpToPx(getContext(), HINT_JUMP_DP); 898 float dipOffset = DpUtil.dpToPx(getContext(), HINT_DIP_DP); 899 float scaleSize = HINT_SCALE_RATIO; 900 float textOffset = jumpOffset + (scaleSize - 1) * contactPuckBackground.getHeight(); 901 int shortAnimTime = 902 getContext().getResources().getInteger(android.R.integer.config_shortAnimTime); 903 int mediumAnimTime = 904 getContext().getResources().getInteger(android.R.integer.config_mediumAnimTime); 905 906 // Puck squashes to anticipate jump 907 ObjectAnimator puckAnticipate = 908 ObjectAnimator.ofPropertyValuesHolder( 909 contactPuckContainer, 910 PropertyValuesHolder.ofFloat(View.SCALE_Y, .95f), 911 PropertyValuesHolder.ofFloat(View.SCALE_X, 1.05f)); 912 puckAnticipate.setRepeatCount(1); 913 puckAnticipate.setRepeatMode(ValueAnimator.REVERSE); 914 puckAnticipate.setDuration(shortAnimTime / 2); 915 puckAnticipate.setInterpolator(new DecelerateInterpolator()); 916 puckAnticipate.addListener( 917 new AnimatorListenerAdapter() { 918 @Override 919 public void onAnimationStart(Animator animation) { 920 super.onAnimationStart(animation); 921 contactPuckContainer.setPivotY(contactPuckContainer.getHeight()); 922 } 923 924 @Override 925 public void onAnimationEnd(Animator animation) { 926 super.onAnimationEnd(animation); 927 contactPuckContainer.setPivotY(contactPuckContainer.getHeight() / 2); 928 } 929 }); 930 931 // Ensure puck is at the right starting point for the jump 932 ObjectAnimator puckResetTranslation = 933 ObjectAnimator.ofPropertyValuesHolder( 934 contactPuckContainer, 935 PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0), 936 PropertyValuesHolder.ofFloat(View.TRANSLATION_X, 0)); 937 puckResetTranslation.setDuration(shortAnimTime / 2); 938 puckAnticipate.setInterpolator(new DecelerateInterpolator()); 939 940 Animator textUp = ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, -textOffset); 941 textUp.setInterpolator(new LinearOutSlowInInterpolator()); 942 textUp.setDuration(shortAnimTime); 943 944 Animator puckUp = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, -jumpOffset); 945 puckUp.setInterpolator(new LinearOutSlowInInterpolator()); 946 puckUp.setDuration(shortAnimTime); 947 948 Animator puckScaleUp = 949 createUniformScaleAnimators( 950 contactPuckBackground, 1f, scaleSize, shortAnimTime, new LinearOutSlowInInterpolator()); 951 952 Animator rejectHintShow = 953 ObjectAnimator.ofPropertyValuesHolder( 954 swipeToRejectText, 955 PropertyValuesHolder.ofFloat(View.ALPHA, 1f), 956 PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f)); 957 rejectHintShow.setDuration(shortAnimTime); 958 959 Animator rejectHintDip = 960 ObjectAnimator.ofFloat(swipeToRejectText, View.TRANSLATION_Y, dipOffset); 961 rejectHintDip.setInterpolator(new LinearOutSlowInInterpolator()); 962 rejectHintDip.setDuration(shortAnimTime); 963 964 Animator textDown = ObjectAnimator.ofFloat(swipeToAnswerText, View.TRANSLATION_Y, 0); 965 textDown.setInterpolator(new LinearOutSlowInInterpolator()); 966 textDown.setDuration(mediumAnimTime); 967 968 Animator puckDown = ObjectAnimator.ofFloat(contactPuckContainer, View.TRANSLATION_Y, 0); 969 BounceInterpolator bounce = new BounceInterpolator(); 970 puckDown.setInterpolator(bounce); 971 puckDown.setDuration(mediumAnimTime); 972 973 Animator puckScaleDown = 974 createUniformScaleAnimators( 975 contactPuckBackground, scaleSize, 1f, shortAnimTime, new LinearOutSlowInInterpolator()); 976 977 Animator rejectHintUp = ObjectAnimator.ofFloat(swipeToRejectText, View.TRANSLATION_Y, 0); 978 rejectHintUp.setInterpolator(new LinearOutSlowInInterpolator()); 979 rejectHintUp.setDuration(mediumAnimTime); 980 981 lockHintAnim.play(puckAnticipate).with(puckResetTranslation).before(puckUp); 982 lockHintAnim 983 .play(textUp) 984 .with(puckUp) 985 .with(puckScaleUp) 986 .with(rejectHintDip) 987 .with(rejectHintShow); 988 lockHintAnim.play(textDown).with(puckDown).with(puckScaleDown).with(rejectHintUp).after(puckUp); 989 lockHintAnim.start(); 990 991 rejectHintHide = ObjectAnimator.ofFloat(swipeToRejectText, View.ALPHA, 0); 992 rejectHintHide.setStartDelay(HINT_REJECT_SHOW_DURATION_MILLIS); 993 rejectHintHide.addListener( 994 new AnimatorListenerAdapter() { 995 996 private boolean canceled; 997 998 @Override 999 public void onAnimationCancel(Animator animation) { 1000 super.onAnimationCancel(animation); 1001 canceled = true; 1002 rejectHintHide = null; 1003 } 1004 1005 @Override 1006 public void onAnimationEnd(Animator animation) { 1007 super.onAnimationEnd(animation); 1008 onHintAnimationDone(canceled); 1009 } 1010 }); 1011 rejectHintHide.start(); 1012 } 1013 1014 @VisibleForTesting 1015 void onHintAnimationDone(boolean canceled) { 1016 if (!canceled && animationState == AnimationState.HINT) { 1017 setAnimationState(AnimationState.BOUNCE); 1018 } 1019 rejectHintHide = null; 1020 } 1021 1022 private void clearSwipeToAnswerUi() { 1023 LogUtil.i("FlingUpDownMethod.clearSwipeToAnswerUi", "Clear swipe animation."); 1024 endAnimation(); 1025 swipeToAnswerText.setVisibility(View.GONE); 1026 contactPuckContainer.setVisibility(View.GONE); 1027 } 1028 1029 private void endAnimation() { 1030 LogUtil.i("FlingUpDownMethod.endAnimation", "End animations."); 1031 if (lockSettleAnim != null) { 1032 lockSettleAnim.cancel(); 1033 lockSettleAnim = null; 1034 } 1035 if (lockBounceAnim != null) { 1036 lockBounceAnim.cancel(); 1037 lockBounceAnim = null; 1038 } 1039 if (lockEntryAnim != null) { 1040 lockEntryAnim.cancel(); 1041 lockEntryAnim = null; 1042 } 1043 if (lockHintAnim != null) { 1044 lockHintAnim.cancel(); 1045 lockHintAnim = null; 1046 } 1047 if (rejectHintHide != null) { 1048 rejectHintHide.cancel(); 1049 rejectHintHide = null; 1050 } 1051 if (vibrationAnimator != null) { 1052 vibrationAnimator.end(); 1053 vibrationAnimator = null; 1054 } 1055 answerHint.onBounceEnd(); 1056 } 1057 1058 // Create an animator to scale on X/Y directions uniformly. 1059 private Animator createUniformScaleAnimators( 1060 View target, float begin, float end, long duration, Interpolator interpolator) { 1061 ObjectAnimator animator = 1062 ObjectAnimator.ofPropertyValuesHolder( 1063 target, 1064 PropertyValuesHolder.ofFloat(View.SCALE_X, begin, end), 1065 PropertyValuesHolder.ofFloat(View.SCALE_Y, begin, end)); 1066 animator.setDuration(duration); 1067 animator.setInterpolator(interpolator); 1068 return animator; 1069 } 1070 1071 private void addVibrationAnimator(AnimatorSet animatorSet) { 1072 if (vibrationAnimator != null) { 1073 vibrationAnimator.end(); 1074 } 1075 1076 // Note that we animate the value between 0 and 1, but internally VibrateInterpolator will 1077 // translate it into actually X translation value. 1078 vibrationAnimator = 1079 ObjectAnimator.ofFloat( 1080 contactPuckContainer, View.TRANSLATION_X, 0 /* begin value */, 1 /* end value */); 1081 vibrationAnimator.setDuration(VIBRATION_TIME_MILLIS); 1082 vibrationAnimator.setInterpolator(new VibrateInterpolator(getContext())); 1083 1084 animatorSet.play(vibrationAnimator).after(0 /* delay */); 1085 } 1086 1087 private void performAccept() { 1088 LogUtil.i("FlingUpDownMethod.performAccept", null); 1089 swipeToAnswerText.setVisibility(View.GONE); 1090 contactPuckContainer.setVisibility(View.GONE); 1091 1092 // Complete the animation loop. 1093 setAnimationState(AnimationState.COMPLETED); 1094 getParent().answerFromMethod(); 1095 } 1096 1097 private void performReject() { 1098 LogUtil.i("FlingUpDownMethod.performReject", null); 1099 swipeToAnswerText.setVisibility(View.GONE); 1100 contactPuckContainer.setVisibility(View.GONE); 1101 1102 // Complete the animation loop. 1103 setAnimationState(AnimationState.COMPLETED); 1104 getParent().rejectFromMethod(); 1105 } 1106 1107 /** Custom interpolator class for puck vibration. */ 1108 private static class VibrateInterpolator implements Interpolator { 1109 1110 private static final long RAMP_UP_BEGIN_MS = 583; 1111 private static final long RAMP_UP_DURATION_MS = 167; 1112 private static final long RAMP_UP_END_MS = RAMP_UP_BEGIN_MS + RAMP_UP_DURATION_MS; 1113 private static final long RAMP_DOWN_BEGIN_MS = 1_583; 1114 private static final long RAMP_DOWN_DURATION_MS = 250; 1115 private static final long RAMP_DOWN_END_MS = RAMP_DOWN_BEGIN_MS + RAMP_DOWN_DURATION_MS; 1116 private static final long RAMP_TOTAL_TIME_MS = RAMP_DOWN_END_MS; 1117 private final float ampMax; 1118 private final float freqMax = 80; 1119 private Interpolator sliderInterpolator = new FastOutSlowInInterpolator(); 1120 1121 VibrateInterpolator(Context context) { 1122 ampMax = DpUtil.dpToPx(context, 1 /* dp */); 1123 } 1124 1125 @Override 1126 public float getInterpolation(float t) { 1127 float slider = 0; 1128 float time = t * RAMP_TOTAL_TIME_MS; 1129 1130 // Calculate the slider value based on RAMP_UP and RAMP_DOWN times. Between RAMP_UP and 1131 // RAMP_DOWN, the slider remains the maximum value of 1. 1132 if (time > RAMP_UP_BEGIN_MS && time < RAMP_UP_END_MS) { 1133 // Ramp up. 1134 slider = 1135 sliderInterpolator.getInterpolation( 1136 (time - RAMP_UP_BEGIN_MS) / (float) RAMP_UP_DURATION_MS); 1137 } else if ((time >= RAMP_UP_END_MS) && time <= RAMP_DOWN_BEGIN_MS) { 1138 // Vibrate at maximum 1139 slider = 1; 1140 } else if (time > RAMP_DOWN_BEGIN_MS && time < RAMP_DOWN_END_MS) { 1141 // Ramp down. 1142 slider = 1143 1 1144 - sliderInterpolator.getInterpolation( 1145 (time - RAMP_DOWN_BEGIN_MS) / (float) RAMP_DOWN_DURATION_MS); 1146 } 1147 1148 float ampNormalized = ampMax * slider; 1149 float freqNormalized = freqMax * slider; 1150 1151 return (float) (ampNormalized * Math.sin(time * freqNormalized)); 1152 } 1153 } 1154} 1155