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