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